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:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<Base>true</Base>
|
||||
</PropertyGroup>
|
||||
<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_F>false</DCC_F>
|
||||
<DCC_K>false</DCC_K>
|
||||
@@ -250,6 +251,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.Licence.pas"/>
|
||||
<DCCReference Include="Source\PM.LicenceGate.pas"/>
|
||||
<DCCReference Include="Source\DelphiZXingQRCode.pas"/>
|
||||
<DCCReference Include="Source\PM.ScreenQr.pas"/>
|
||||
<DCCReference Include="Source\PM.AutoStart.pas"/>
|
||||
<DCCReference Include="Source\PM.Favicon.pas"/>
|
||||
<DCCReference Include="Source\PM.ProcessLockdown.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<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,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');
|
||||
|
||||
@@ -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-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-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-empty-vault" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user