feat(totp): scanner le QR 2FA affiche a l'ecran (capture + ZXing), seul un otpauth:// part vers la page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-09-18 22:24:35 +01:00
parent 508d5345bf
commit 72f9acd733
7 changed files with 182 additions and 1 deletions
+10
View File
@@ -768,6 +768,16 @@ Sidebar Tools expose 2 items MFA :
secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien
n'est sauvegardé. 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, Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom,
**pas `remaining`** (utiliser `t.secondsLeft`). **pas `remaining`** (utiliser `t.secondsLeft`).
+1
View File
@@ -24,6 +24,7 @@ uses
PM.Licence in 'Source\PM.Licence.pas', PM.Licence in 'Source\PM.Licence.pas',
PM.LicenceGate in 'Source\PM.LicenceGate.pas', PM.LicenceGate in 'Source\PM.LicenceGate.pas',
DelphiZXingQRCode in 'Source\DelphiZXingQRCode.pas', DelphiZXingQRCode in 'Source\DelphiZXingQRCode.pas',
PM.ScreenQr in 'Source\PM.ScreenQr.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas', PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.Favicon in 'Source\PM.Favicon.pas', PM.Favicon in 'Source\PM.Favicon.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
+2
View File
@@ -91,6 +91,7 @@
<Base>true</Base> <Base>true</Base>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Base)'!=''"> <PropertyGroup Condition="'$(Base)'!=''">
<DCC_UnitSearchPath>..\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)</DCC_UnitSearchPath>
<DCC_E>false</DCC_E> <DCC_E>false</DCC_E>
<DCC_F>false</DCC_F> <DCC_F>false</DCC_F>
<DCC_K>false</DCC_K> <DCC_K>false</DCC_K>
@@ -250,6 +251,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.Licence.pas"/> <DCCReference Include="Source\PM.Licence.pas"/>
<DCCReference Include="Source\PM.LicenceGate.pas"/> <DCCReference Include="Source\PM.LicenceGate.pas"/>
<DCCReference Include="Source\DelphiZXingQRCode.pas"/> <DCCReference Include="Source\DelphiZXingQRCode.pas"/>
<DCCReference Include="Source\PM.ScreenQr.pas"/>
<DCCReference Include="Source\PM.AutoStart.pas"/> <DCCReference Include="Source\PM.AutoStart.pas"/>
<DCCReference Include="Source\PM.Favicon.pas"/> <DCCReference Include="Source\PM.Favicon.pas"/>
<DCCReference Include="Source\PM.ProcessLockdown.pas"/> <DCCReference Include="Source\PM.ProcessLockdown.pas"/>
+115
View File
@@ -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<Byte>;
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<Byte>;
LW, LH: Integer;
LSource: TLuminanceSource;
LBinarizer: THybridBinarizer;
LImage: TBinaryBitmap;
LReader: TMultiFormatReader;
LHints: TDictionary<TDecodeHintType, TObject>;
LFormats: TList<TBarcodeFormat>;
LRes: TReadResult;
begin
Result := '';
LPixels := CaptureVirtualScreen(LW, LH);
if LPixels = nil then Exit;
LFormats := TList<TBarcodeFormat>.Create;
LFormats.Add(TBarcodeFormat.QR_CODE);
LHints := TDictionary<TDecodeHintType, TObject>.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.
+35 -1
View File
@@ -35,7 +35,7 @@ uses
FMX.TMSFNCEdgeWebBrowser, FMX.TMSFNCEdgeWebBrowser,
{$ENDIF} {$ENDIF}
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart, 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 FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
const const
// Bump on each release. Surfaced to JS via cmd://app/version, displayed // 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 // 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 // parti chez Google a un appui pres. Un lien a schema propre n'a qu'une
// application pour l'ouvrir : la notre. // 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 else if ACmd = 'sync/qr' then
begin begin
var LQrUrl := PM.UserPrefs.GetPref('syncUrl'); var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
+1
View File
@@ -47,6 +47,7 @@
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol> <symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol> <symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol>
<symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol> <symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h3v3h-3zM21 14v.01M14 21h.01M17 21h4v-4"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol> <symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none"> <symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/> <circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
+18
View File
@@ -4833,6 +4833,24 @@ function soTotpField(plainSecret) {
}); });
row.appendChild(input); row.appendChild(input);
row.appendChild(toggle); 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); row.appendChild(clear);
wrap.appendChild(row); wrap.appendChild(row);