diff --git a/CLAUDE.md b/CLAUDE.md index bdc9374..4fc0b78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -768,6 +768,16 @@ Sidebar Tools expose 2 items MFA : secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien n'est sauvegardé. +**Scanner le QR à l'écran** (bouton QR du champ 2FA de l'éditeur, 2026-09-18) : +`cmd://qr/screen` → la fenêtre passe au tray, `PM.ScreenQr` capture **tout le +bureau virtuel** en mémoire (GDI, octets bruts, jamais sur disque) et le décode +par ZXing.Delphi — le même que l'APK, `android-client\ThirdParty\ZXing` dans le +chemin de recherche du `.dproj`. Pas de sélection de zone : le décodeur trouve +le QR où qu'il soit. **Seul un `otpauth://` repart vers la page** +(`Bridge.onScreenQr`), le reste de l'écran ne quitte pas l'exe. Format `BGR32` +et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent +→ image blanche. + Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom, **pas `remaining`** (utiliser `t.secondsLeft`). diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index a02d495..d07b3b9 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -24,6 +24,7 @@ uses PM.Licence in 'Source\PM.Licence.pas', PM.LicenceGate in 'Source\PM.LicenceGate.pas', DelphiZXingQRCode in 'Source\DelphiZXingQRCode.pas', + PM.ScreenQr in 'Source\PM.ScreenQr.pas', PM.AutoStart in 'Source\PM.AutoStart.pas', PM.Favicon in 'Source\PM.Favicon.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 1a37745..b6c22ff 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -91,6 +91,7 @@ true + ..\android-client\ThirdParty\ZXing;..\android-client\ThirdParty\ZXing\1D Barcodes;..\android-client\ThirdParty\ZXing\2D Barcodes;..\android-client\ThirdParty\ZXing\2D Barcodes\Decoder;..\android-client\ThirdParty\ZXing\2D Barcodes\Detector;..\android-client\ThirdParty\ZXing\Common;..\android-client\ThirdParty\ZXing\Common\Detector;..\android-client\ThirdParty\ZXing\Common\ReedSolomon;..\android-client\ThirdParty\ZXing\Filtering;$(DCC_UnitSearchPath) false false false @@ -250,6 +251,7 @@ $(PreBuildEvent)]]> + diff --git a/delphi-backend/Source/PM.ScreenQr.pas b/delphi-backend/Source/PM.ScreenQr.pas new file mode 100644 index 0000000..42476e8 --- /dev/null +++ b/delphi-backend/Source/PM.ScreenQr.pas @@ -0,0 +1,115 @@ +unit PM.ScreenQr; + +// Lit un QR affiche a l'ecran - le secret 2FA qu'un site montre a l'activation. +// Capture de TOUS les ecrans (bureau virtuel) en memoire, jamais sur le disque, +// puis decodage par ZXing.Delphi (Apache 2.0, deja vendu pour l'APK dans +// android-client\ThirdParty\ZXing). Aucune selection de zone : le decodeur +// trouve le QR n'importe ou dans l'image. +// +// GDI pur et octets bruts, pas de TBitmap FMX : tout se fait dans un thread +// d'arriere-plan, et le decodage d'un bureau entier prend un moment. + +interface + +/// Texte du premier QR trouve a l'ecran, '' si aucun. +function ScanScreenForQr: string; + +implementation + +uses + Winapi.Windows, System.SysUtils, System.Generics.Collections, + ZXing.LuminanceSource, ZXing.RGBLuminanceSource, ZXing.HybridBinarizer, + ZXing.BinaryBitmap, ZXing.MultiFormatReader, ZXing.BarcodeFormat, + ZXing.DecodeHintType, ZXing.ReadResult; + +const + CAPTURE_BLT = $40000000; // absente de Winapi.Windows ; inclut les fenetres superposees + +function CaptureVirtualScreen(out AWidth, AHeight: Integer): TArray; +var + LX, LY: Integer; + LScreenDC, LMemDC: HDC; + LBmp: HBITMAP; + LOld: HGDIOBJ; + LInfo: TBitmapInfo; +begin + Result := nil; + LX := GetSystemMetrics(SM_XVIRTUALSCREEN); + LY := GetSystemMetrics(SM_YVIRTUALSCREEN); + AWidth := GetSystemMetrics(SM_CXVIRTUALSCREEN); + AHeight := GetSystemMetrics(SM_CYVIRTUALSCREEN); + if (AWidth <= 0) or (AHeight <= 0) then Exit; + + LScreenDC := GetDC(0); + LMemDC := CreateCompatibleDC(LScreenDC); + LBmp := CreateCompatibleBitmap(LScreenDC, AWidth, AHeight); + try + LOld := SelectObject(LMemDC, LBmp); + BitBlt(LMemDC, 0, 0, AWidth, AHeight, LScreenDC, LX, LY, SRCCOPY or CAPTURE_BLT); + SelectObject(LMemDC, LOld); + + FillChar(LInfo, SizeOf(LInfo), 0); + LInfo.bmiHeader.biSize := SizeOf(TBitmapInfoHeader); + LInfo.bmiHeader.biWidth := AWidth; + LInfo.bmiHeader.biHeight := -AHeight; // negatif = de haut en bas + LInfo.bmiHeader.biPlanes := 1; + LInfo.bmiHeader.biBitCount := 32; + LInfo.bmiHeader.biCompression := BI_RGB; + SetLength(Result, AWidth * AHeight * 4); + if GetDIBits(LMemDC, LBmp, 0, AHeight, @Result[0], LInfo, DIB_RGB_COLORS) = 0 then + Result := nil; + finally + DeleteObject(LBmp); + DeleteDC(LMemDC); + ReleaseDC(0, LScreenDC); + end; +end; + +function ScanScreenForQr: string; +var + LPixels: TArray; + LW, LH: Integer; + LSource: TLuminanceSource; + LBinarizer: THybridBinarizer; + LImage: TBinaryBitmap; + LReader: TMultiFormatReader; + LHints: TDictionary; + LFormats: TList; + LRes: TReadResult; +begin + Result := ''; + LPixels := CaptureVirtualScreen(LW, LH); + if LPixels = nil then Exit; + + LFormats := TList.Create; + LFormats.Add(TBarcodeFormat.QR_CODE); + LHints := TDictionary.Create; + LHints.Add(TDecodeHintType.POSSIBLE_FORMATS, LFormats); + LHints.Add(TDecodeHintType.TRY_HARDER, nil); + LReader := TMultiFormatReader.Create; + LReader.Hints := LHints; + // BGR32 et non BGRA32 : le 4e octet d'une capture GDI vaut 0, et la + // conversion BGRA le lirait comme une transparence totale - image blanche. + LSource := TRGBLuminanceSource.Create(LPixels, LW, LH, TBitmapFormat.BGR32); + LBinarizer := THybridBinarizer.Create(LSource); + LImage := TBinaryBitmap.Create(LBinarizer); + LRes := nil; + try + try + LRes := LReader.decode(LImage, True); + if LRes <> nil then Result := Trim(LRes.Text); + except + Result := ''; + end; + finally + LRes.Free; + LImage.Free; + LBinarizer.Free; + LSource.Free; + LReader.Free; + LFormats.Free; + LHints.Free; + end; +end; + +end. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index c7a1d2a..e38d809 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -35,7 +35,7 @@ uses FMX.TMSFNCEdgeWebBrowser, {$ENDIF} PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart, - PM.Favicon, DelphiZXingQRCode, + PM.Favicon, DelphiZXingQRCode, PM.ScreenQr, FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check const // Bump on each release. Surfaced to JS via cmd://app/version, displayed @@ -1393,6 +1393,40 @@ begin // envoie tout texte a une recherche web (Google, Chrome) - le jeton serait // parti chez Google a un appui pres. Un lien a schema propre n'a qu'une // application pour l'ouvrir : la notre. + // QR 2FA affiche par un site : la fenetre s'efface le temps de la capture + // (elle recouvre souvent le QR), puis revient. Seul un lien otpauth:// part + // vers la page - le reste de l'ecran ne quitte pas la memoire de l'exe. + else if ACmd = 'qr/screen' then + begin + LogLine('qr/screen: scanning'); + FBridge.MinimizeToTray(False, False); + TThread.CreateAnonymousThread( + procedure + var + LText: string; + begin + Sleep(400); // laisser Windows retirer la fenetre de l'ecran + LText := ScanScreenForQr; + if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := ''; + TThread.Queue(nil, + procedure + var + LJs: TJSONString; + begin + RestoreWindowFromTray; + if LText = '' then LogLine('qr/screen: no otpauth QR found') + else LogLine('qr/screen: otpauth QR found'); + LJs := TJSONString.Create(LText); + try + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON + ')'); + finally + LJs.Free; + end; + end); + end).Start; + end + else if ACmd = 'sync/qr' then begin var LQrUrl := PM.UserPrefs.GetPref('syncUrl'); diff --git a/index.html b/index.html index 3ea50da..2dfc81d 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,7 @@ + diff --git a/js/app.js b/js/app.js index eaa85c0..ba6d323 100644 --- a/js/app.js +++ b/js/app.js @@ -4833,6 +4833,24 @@ function soTotpField(plainSecret) { }); row.appendChild(input); row.appendChild(toggle); + // Lit le QR qu'affiche le site a l'activation de la 2FA : l'exe s'efface, + // capture l'ecran et ne renvoie qu'un lien otpauth:// (qr/screen). + if (Bridge.active) { + const scan = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Scan the 2FA QR code shown on screen' }); + scan.appendChild(icon('i-qr')); + scan.addEventListener('click', () => { + Bridge.onScreenQr = (text) => { + const secret = parseOtpAuthUri(text || ''); + if (!secret) { toast('No 2FA QR code found on screen', 'warning'); return; } + input.value = secret; + updateTotpDisplay(); + soDirtyCheck(); + toast('2FA secret read from the QR code'); + }; + window.location.href = 'cmd://qr/screen'; + }); + row.appendChild(scan); + } row.appendChild(clear); wrap.appendChild(row);