From b00da43ab0061babed23e3f691d418bba72aac84 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:41:39 +0100 Subject: [PATCH] feat: PIN unlock + table column picker + edit-position chooser + UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PIN unlock: device-local 4-12 digit shortcut, DPAPI-wrapped vault key. Three modes (state.unlockMode): pw / pin / pw+pin. PIN derives a wrap key via PBKDF2(pin, salt, 100k) and unwraps the stored vault key (mirrors the Quick Unlock blob shape). Anti-brute-force: 5 wrong attempts wipes the blob. Setup gated by master-pw reauth so an unattended unlocked laptop can't be backdoored. Master pw rotation clears the PIN blob (key drift). loadServerSettings post-sync demotes pin/both -> pw when the local blob is missing, so a wiped device re-syncs the correct mode up. New unit PM.PinUnlock.pas + cmd://pin/{store,get,clear,status}. - Table column picker: ⚙ in topbar (table view only), checkbox menu for Site/Username/Folder/Updated. Site also drives showSiteOnCards so the existing "Show site / URL" toggle in Settings stays in sync. NAME column auto-widths (180px min, content max, +32px right padding) so column hugs the next one without truncating. - Editor position chooser (Appearance setting): Slide-over right / left / Centered modal. Scoped to #slideover + #settingsPanel so the click-outside / pointer-events logic doesn't accidentally trap the modal-style empty viewport. - Confirm before discarding unsaved edits: state.confirmOnUnsaved setting (default ON), prompts on X / Esc / click-outside / switch- to-other-entry. Also gates Lock vault / Sign out actions when the editor is dirty; auto-lock and system-lock paths bypass to avoid blocking on an unattended machine. - Open-in-browser button added to the actions cell of the table view (was card-only). - Entry templates pass folder customization + template id through duplicate / export / import / auto-backup roundtrips. - Folder color + icon now persisted across export/import: payload. folders carries name/color/icon; import creates missing folders additively (existing local customisation kept). - Bulk move-to-folder, batch add-tag, single add-tag now re-ship the full entry payload so partial PUTs don't silently wipe TOTP / custom_fields / kind / template. - FireDAC: switched ftString -> ftMemo for icon_b64 / custom_fields / TOTP / template params and replaced .AsString with .Value so a large (~200 KB) DeepSeek favicon no longer gets truncated at the default ANSI 4000-char cap. - Unicode filenames: attachment INSERT now uses ftWideString + .AsWideString so non-ANSI filenames round-trip instead of being mangled to "?". - HandleSetEntryIcon cap raised 256 KB -> 512 KB chars to accept base64 data URIs produced by max-raw favicon fetches. - promptDialog + askReauth support inline `error` line + retry- with-count loops on doExport reauth and auto-backup password setup (5 attempts cap before bailing). - Recently used moved from Tools to Vault section in the sidebar. - Auth screen passkey button hidden (Delphi backend stubs WebAuthn). - Sensitive cmd://favicon/refresh-style buttons in Settings now stopPropagation so the document-level "close panel" handler doesn't dismiss Settings mid-async during DOM reparenting. - TEST_PLAN.md: +PIN unlock section. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 38 + TEST_PLAN.md | 52 +- css/style.css | 108 +- .../Handlers/PM.Handler.Attachments.pas | 14 +- delphi-backend/PMServer.dpr | 1 + delphi-backend/PMServer.dproj | 1 + delphi-backend/Source/PM.PinUnlock.pas | 155 +++ delphi-backend/UMainForm.pas | 47 +- delphi-backend/assets/assets.res | Bin 534460 -> 578992 bytes index.html | 73 +- js/app.js | 999 +++++++++++++++++- 11 files changed, 1417 insertions(+), 71 deletions(-) create mode 100644 delphi-backend/Source/PM.PinUnlock.pas diff --git a/CLAUDE.md b/CLAUDE.md index cb64b65..f40c809 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -454,6 +454,44 @@ hotkeys autofill, etc. **Device-only** (localStorage seulement) : `quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` (toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). +## PIN unlock + +Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick +Unlock. Three modes (`state.unlockMode`, synced via `settings_json`) : + +- `pw` — master password only (legacy, default) +- `pin` — PIN unlocks the vault on this device +- `both` — master password first, then PIN verified before access + +Storage : `PM.PinUnlock.pas` writes a DPAPI blob to +`%LOCALAPPDATA%\PMServer\pin-unlock.bin`. Bridge cmds: `pin/store`, +`pin/get`, `pin/clear`, `pin/status` (mirror Quick Unlock exactly, +separate file so both features coexist). + +Crypto wrap: `wrapKey = PBKDF2(pin, salt, 100k iter)` — 100k instead of +600k because PIN entropy is low (~13–40 bits), more iterations mostly +slow down honest users. Vault key bytes are AES-GCM(wrapKey, key_raw) +inside the blob. Successful PIN → unwrap → import as `state.cryptoKey` +→ fresh `/login` with `verifier = hex(rawKey)` (same pattern as Quick +Unlock cold-start). + +Anti-brute-force : each failed PIN attempt increments `attempts` in the +blob and rewrites it via `pin/store`. Past 5 fails → `pin/clear` → user +falls back to master pw. Successful unlock resets the counter to 0. + +`both` mode : `doLogin` saves the typed PIN on `window._pinAfterMaster`, +runs the regular master-pw flow, then `enterApp` calls +`verifyPinAfterMasterUnlock(pin)` BEFORE flipping to the app shell. PIN +mismatch → `lockVault()` + "Wrong PIN" hint. Quick Unlock cold-start +bypasses the PIN check (the device is already trusted). + +Sensitive actions (`askReauth` paths: export, change master pw, +recovery code, etc.) ALWAYS require master pw — PIN never substitutes. + +Master pw rotation clears the PIN blob (same reason as Quick Unlock : +stored wrapped key + server verifier drift). User re-sets PIN from +Settings after rotation. + ## Quick Unlock DPAPI blob à `%LOCALAPPDATA%\PMServer\quickunlock.bin` (tied to Windows diff --git a/TEST_PLAN.md b/TEST_PLAN.md index 5c97d46..1bc5e72 100644 --- a/TEST_PLAN.md +++ b/TEST_PLAN.md @@ -85,7 +85,57 @@ re-vérifier explicitement. - [✅] "Clear" → vide tout l'historique - [✅] Dropdown disparaît au click hors searchbar / Escape / blur => un petit probleme > focus search bar > dropdown search affiché > clique à nouveau search (focus non changé) dropdown non affiché -## 12. Regressions générales 🔥 +## 12. PIN unlock 🔥 + +### 12a. Setup +- [✅] Settings → "PIN unlock" section visible (Bridge actif requis) +- [✅] Status "No PIN set." + bouton "Set PIN" visible, "Remove PIN" caché +- [✅] Click "Set PIN" → prompt → tape **3 chiffres** → erreur inline "PIN must be 4–12 digits" (modal reste ouvert) +- [✅] Tape **abcd** → même erreur (digits only) +- [✅] Tape **123456** → toast "PIN set" → status passe à "PIN is set on this device." → bouton "Change PIN" + "Remove PIN" visibles +- [✅] Vérifie `%LOCALAPPDATA%\PMServer\pin-unlock.bin` existe + +### 12b. Mode 'pin' (PIN only) +- [✅] Settings → dropdown "Unlock method" = "PIN only" → toast "Unlock method updated" +- [✅] Lock vault → auth screen affiche **PIN** input (pas master pw) + lien "Use master password instead" +- [✅] Tape bon PIN → unlock instant +- [✅] Lock → tape mauvais PIN → "Wrong PIN. Try again..." +- [✅] Lock → tape mauvais PIN 5 fois → blob auto-supprimé → écran retombe sur master pw → message "Too many wrong PIN attempts. Sign in with your master password." +- [✅] Settings après ça : status "No PIN set." → re-set possible + +### 12c. Mode 'both' (master + PIN) +- [✅] Re-set PIN, dropdown = "Master password + PIN" +- [✅] Lock → auth affiche pw + PIN ensemble +- [✅] Tape bon pw + mauvais PIN → unlock initie, puis lockVault avec "Wrong PIN. Try again." +- [✅] Tape bon pw + bon PIN → unlock OK +- [✅] Pw seul (PIN vide) → submit bloqué (HTML5 required) + +### 12d. Escape PIN-only +- [✅] Mode 'pin', PIN configuré → auth → click "Use master password instead" → champ pw apparaît, PIN caché → unlock master pw marche +- [✅] Lock après → revient en mode PIN (le choix master était one-shot) + +### 12e. Quick Unlock + PIN coexistence +- [✅] Active Quick Unlock + définis PIN en mode 'both' +- [✅] Restart app → Quick Unlock cold-start réussit sans demander PIN (device trusted) +- [✅] Lock manuel → auth demande pw + PIN comme prévu + +### 12f. Sensitive actions = master pw obligatoire +- [ ] Mode 'pin', unlocked → Settings → Export encrypted JSON → askReauth demande **master pw** (pas PIN) +- [ ] Idem : Change master password → master pw demandé +- [ ] Idem : Generate recovery code → master pw demandé + +### 12g. Master pw rotation invalide le PIN +- [ ] Mode 'pin', PIN configuré → Change master pw → après rotation, status PIN passe à "No PIN set." (blob wipé) + unlockMode tombe à 'pw' +- [ ] Next lock → champ master pw → unlock OK avec nouveau pw + +### 12h. Remove PIN +- [✅] Click "Remove PIN" → confirm dialog +- [✅] Confirm → blob deleted, status repasse à "No PIN set.", mode revert à 'pw' + +### 12i. Recovery key continue à marcher +- [ ] Avec mode 'pin' actif, ouvre auth → click "Forgot master password? Use a recovery code" → flow recovery normal (le bouton reste visible) + +## 13. Regressions générales 🔥 - [✅] Quick unlock encore fonctionnel après update - [✅] Master password rotation OK + entries déchiffrables après - [✅] Autofill (Ctrl+Shift+L/P) toujours OK diff --git a/css/style.css b/css/style.css index f503d41..859a91e 100644 --- a/css/style.css +++ b/css/style.css @@ -1859,6 +1859,32 @@ input[type="range"]::-webkit-slider-thumb { color: var(--text-faint); } +/* Table column picker dropdown — only rendered in table view. */ +.cols-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + z-index: 60; + min-width: 160px; + padding: 4px; + overflow: hidden; +} +.cols-menu.is-hidden { display: none; } +.cols-menu-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 10px; + font-size: 13px; + color: var(--text); + cursor: pointer; + border-radius: 4px; +} +.cols-menu-item:hover { background: var(--bg-elev-2); } +.cols-menu-item input { cursor: pointer; } + /* ---- Quick search modal (tray menu) ------------------ */ .quick-search-panel { padding: 0; @@ -1986,12 +2012,57 @@ input[type="range"]::-webkit-slider-thumb { z-index: 50; } .slideover.is-open { transform: translateX(0); } -/* Push the app shell to the left when the slideover is open, so the - table, sort buttons, pagination and search bar are no longer hidden - behind it. Width matches .slideover (420px) — bumped to 100% via the - responsive override below 720px. */ -body:has(.slideover.is-open) #appShell { margin-right: 420px; } -#appShell { transition: margin-right var(--t-base); } +/* Suppress the slide animation when the panel is closed — otherwise + changing editor-position animates the off-screen position from right + to left and the panel briefly crosses the viewport. */ +.slideover:not(.is-open) { transition: none; } +/* Push the app shell to the left when the entry slideover is open, + so the table, pagination etc. stay visible. Only applies to the + entry editor (#slideover) — NOT #settingsPanel which would visually + re-flow the whole app. Width matches .slideover (420px). */ +body[data-editor-position="right"]:has(#slideover.is-open) #appShell { margin-right: 420px; } +#appShell { transition: margin-right var(--t-base), margin-left var(--t-base); } + +/* ---- Editor position: LEFT (mirrored slideover) ---- */ +body[data-editor-position="left"] #slideover, +body[data-editor-position="left"] #settingsPanel { + left: 0; right: auto; + border-left: none; + border-right: 1px solid var(--border); + transform: translateX(-100%); +} +body[data-editor-position="left"] #slideover.is-open, +body[data-editor-position="left"] #settingsPanel.is-open { transform: translateX(0); } +body[data-editor-position="left"]:has(#slideover.is-open) #appShell { + margin-left: 420px; +} + +/* ---- Editor position: CENTER (modal-style) ---- */ +body[data-editor-position="center"] #slideover, +body[data-editor-position="center"] #settingsPanel { + top: 50%; left: 50%; right: auto; bottom: auto; + width: 520px; + max-height: 90vh; + border-radius: var(--radius-lg); + border-left: 1px solid var(--border); + transform: translate(-50%, -50%) scale(0.96); + opacity: 0; + /* Hidden panels in center mode live INSIDE the viewport (opacity 0 + instead of off-screen translate), so without this guard the + buttons would stay clickable through the invisible panel. */ + pointer-events: none; + transition: transform var(--t-base), opacity var(--t-base); +} +body[data-editor-position="center"] #slideover.is-open, +body[data-editor-position="center"] #settingsPanel.is-open { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + pointer-events: auto; +} +/* No dim/blur in center mode — the panel doesn't actually block + interaction (cards, sidebar, topbar stay clickable without + dismissing it), so painting a modal-style backdrop would lie about + the behaviour. The panel just floats above the page. */ .slideover-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; @@ -2289,7 +2360,7 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; } .slideover { width: 100%; } /* On a narrow viewport the slideover fills the screen — no point pushing the shell, the table isn't visible anyway. */ - body:has(.slideover.is-open) #appShell { margin-right: 0; } + body:has(.slideover.is-open) #appShell { margin-right: 0; margin-left: 0; } } /* ---- 17. AUTOFILL PICKER -------------------------------- */ @@ -2403,7 +2474,24 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; } .entry-row.is-selected { background: var(--accent-soft); } .entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } .col-check { width: 36px; } -.col-name { min-width: 180px; } +/* NAME stops growing past 360px so it doesn't push Username/Folder/ + Updated all the way to the right. Long names ellipsise on a single + line so the avatar never falls under the title. */ +.col-name { + min-width: 180px; + width: auto; /* shrinks to short names, grows to fit + long ones — no fixed cap, the column + matches its widest entry. */ + white-space: nowrap; + padding-right: 32px; /* extra breathing room so the longest + title never butts up against Username. */ +} +.col-name .cell-name-wrap { + display: inline-flex; + align-items: center; + gap: 6px; + vertical-align: middle; +} .col-name .entry-avatar-sm { width: 22px; height: 22px; display: inline-grid; place-items: center; @@ -2420,7 +2508,9 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; } .entry-row:hover .col-user .icon-btn { opacity: 0.7; } .col-folder { color: var(--text-dim); white-space: nowrap; } .col-updated { color: var(--text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; } -.col-actions { width: 80px; text-align: right; white-space: nowrap; } +/* Soak the remaining horizontal space so NAME/Username/Folder stay + close to each other on the left rather than spreading out. */ +.col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; } .col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); } .entry-row:hover .col-actions .icon-btn { opacity: 0.7; } .col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; } diff --git a/delphi-backend/Handlers/PM.Handler.Attachments.pas b/delphi-backend/Handlers/PM.Handler.Attachments.pas index 8a670b5..13a989b 100644 --- a/delphi-backend/Handlers/PM.Handler.Attachments.pas +++ b/delphi-backend/Handlers/PM.Handler.Attachments.pas @@ -23,6 +23,7 @@ implementation uses System.SysUtils, System.JSON, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit; @@ -182,10 +183,17 @@ begin 'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('eid').AsInteger := LEntryId; - LQ.ParamByName('name').AsString := LFilename; - LQ.ParamByName('mime').AsString := LMime; + // Force ftWideString / ftMemo so unicode filenames (Arabic, + // Chinese, emoji…) survive the round-trip. The default ftString + // inferred from .AsString maps to ANSI on SQLite and replaces + // anything outside the local codepage with '?'. + LQ.ParamByName('name').DataType := ftWideString; + LQ.ParamByName('name').AsWideString := LFilename; + LQ.ParamByName('mime').DataType := ftWideString; + LQ.ParamByName('mime').AsWideString := LMime; LQ.ParamByName('sz').AsInteger := LSize; - LQ.ParamByName('blob').AsString := LBlob; + LQ.ParamByName('blob').DataType := ftMemo; + LQ.ParamByName('blob').Value := LBlob; LQ.ParamByName('iv').AsString := LIv; LQ.ExecSQL; LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments'); diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index e68b182..b1b5730 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -17,6 +17,7 @@ uses PM.HTTPServer in 'Source\PM.HTTPServer.pas', PM.Bridge in 'Source\PM.Bridge.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', + PM.PinUnlock in 'Source\PM.PinUnlock.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.AutoStart in 'Source\PM.AutoStart.pas', PM.Favicon in 'Source\PM.Favicon.pas', diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 91a46cf..6a73c1a 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -225,6 +225,7 @@ $(PreBuildEvent)]]> + diff --git a/delphi-backend/Source/PM.PinUnlock.pas b/delphi-backend/Source/PM.PinUnlock.pas new file mode 100644 index 0000000..34d1fef --- /dev/null +++ b/delphi-backend/Source/PM.PinUnlock.pas @@ -0,0 +1,155 @@ +unit PM.PinUnlock; + +{ + PIN unlock — separate DPAPI blob from Quick Unlock so a user can have + both / either independently. + + Same shape as PM.QuickUnlock (DPAPI-wrapped opaque bytes), different + file on disk: %LOCALAPPDATA%\PMServer\pin-unlock.bin. The bytes are + opaque to this unit — the bridge layer hands us whatever the JS layer + needs (typically a JSON blob with the PBKDF2 salt, AES-GCM IV, wrapped + vault key, restore metadata, and a failed-attempts counter). + + Threat model + ------------ + - DPAPI gates the blob to the current Windows user account, same as + Quick Unlock. A different OS user can't read it. + - Within the same Windows account, knowing the PIN AND being able to + read the file is enough to unlock the vault → don't enable PIN + unlock on a shared / kiosk machine without also disabling Quick + Unlock + auto-lock. + - Anti-brute-force lives in the JS layer (increments + writes back the + blob after each failed attempt; deletes the blob past 5 fails). +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, + Winapi.Windows; + +function StorePinUnlock(const APayload: TBytes): Boolean; +function LoadPinUnlock(out APayload: TBytes): Boolean; +procedure ClearPinUnlock; +function HasPinUnlock: Boolean; + +implementation + +type + TDataBlob = record + cbData: DWORD; + pbData: PByte; + end; + PDataBlob = ^TDataBlob; + +function CryptProtectData(pDataIn: PDataBlob; szDataDescr: PWideChar; + pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer; + dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall; + external 'crypt32.dll' name 'CryptProtectData'; + +function CryptUnprotectData(pDataIn: PDataBlob; ppszDataDescr: PPWideChar; + pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer; + dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall; + external 'crypt32.dll' name 'CryptUnprotectData'; + +function LocalFree(hMem: HLOCAL): HLOCAL; stdcall; + external 'kernel32.dll' name 'LocalFree'; + +function StorageDir: string; +begin + Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'), 'PMServer'); +end; + +function StorageFile: string; +begin + Result := TPath.Combine(StorageDir, 'pin-unlock.bin'); +end; + +procedure EnsureStorageDir; +begin + if not TDirectory.Exists(StorageDir) then + TDirectory.CreateDirectory(StorageDir); +end; + +function StorePinUnlock(const APayload: TBytes): Boolean; +var + LIn, LOut: TDataBlob; + LStream: TFileStream; +begin + Result := False; + if Length(APayload) = 0 then Exit; + + LIn.cbData := Length(APayload); + LIn.pbData := @APayload[0]; + LOut.pbData := nil; + LOut.cbData := 0; + + if not CryptProtectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit; + try + EnsureStorageDir; + LStream := TFileStream.Create(StorageFile, fmCreate); + try + LStream.WriteBuffer(LOut.pbData^, LOut.cbData); + finally + LStream.Free; + end; + Result := True; + finally + if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData)); + end; +end; + +function LoadPinUnlock(out APayload: TBytes): Boolean; +var + LEncrypted: TBytes; + LIn, LOut: TDataBlob; + LStream: TFileStream; +begin + Result := False; + SetLength(APayload, 0); + if not TFile.Exists(StorageFile) then Exit; + try + LStream := TFileStream.Create(StorageFile, fmOpenRead or fmShareDenyWrite); + try + SetLength(LEncrypted, LStream.Size); + if Length(LEncrypted) > 0 then + LStream.ReadBuffer(LEncrypted[0], LStream.Size); + finally + LStream.Free; + end; + except + Exit; + end; + if Length(LEncrypted) = 0 then Exit; + + LIn.cbData := Length(LEncrypted); + LIn.pbData := @LEncrypted[0]; + LOut.pbData := nil; + LOut.cbData := 0; + + if not CryptUnprotectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit; + try + SetLength(APayload, LOut.cbData); + if LOut.cbData > 0 then + Move(LOut.pbData^, APayload[0], LOut.cbData); + Result := True; + finally + if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData)); + end; +end; + +procedure ClearPinUnlock; +begin + try + if TFile.Exists(StorageFile) then + TFile.Delete(StorageFile); + except + end; +end; + +function HasPinUnlock: Boolean; +begin + Result := TFile.Exists(StorageFile); +end; + +end. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index cb5841c..3460647 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -32,7 +32,7 @@ uses {$IFDEF USE_EDGE_BROWSER} FMX.TMSFNCEdgeWebBrowser, {$ENDIF} - PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, + PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.AutoStart, PM.Favicon, FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check const @@ -635,6 +635,51 @@ begin BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')'); end + // ---- PIN unlock (DPAPI blob, PIN-derived wrap of the vault key) ---- + // Same wire model as quickunlock — opaque base64 payload in / out. + else if ACmd = 'pin/store' then + begin + LText := GetParam('data'); + if LText = '' then Exit; + var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText); + if PM.PinUnlock.StorePinUnlock(LBytes) then + LogLine(Format('PIN blob stored (%d bytes)', [Length(LBytes)])) + else + LogLine('PIN blob store FAILED (DPAPI error)'); + end + + else if ACmd = 'pin/get' then + begin + var LBytes: TBytes; + if PM.PinUnlock.LoadPinUnlock(LBytes) and (Length(LBytes) > 0) then + begin + var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); + LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]); + LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onPinResult)' + + 'Bridge.onPinResult("' + LB64 + '")'); + LogLine('PIN blob served'); + end + else + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onPinResult)Bridge.onPinResult(null)'); + end + + else if ACmd = 'pin/clear' then + begin + PM.PinUnlock.ClearPinUnlock; + LogLine('PIN blob cleared'); + end + + else if ACmd = 'pin/status' then + begin + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onPinStatus)' + + 'Bridge.onPinStatus(' + + BoolToStr(PM.PinUnlock.HasPinUnlock, True).ToLower + ')'); + end + // ---- Autofill -------------------------------------------------------- // configure: JS calls this on page load / settings change to sync the // hotkey registration state with the user's localStorage preference. diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 64e26fd8d34b4febdc47a1ed2e921c92fa5cfffa..5d846bb95bb847688a9624357d41b3cec4584fa9 100644 GIT binary patch delta 30539 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 IvSi8s0Q_ID3jhEB diff --git a/index.html b/index.html index 5cb65c0..8fc16a1 100644 --- a/index.html +++ b/index.html @@ -105,7 +105,7 @@ Username -

+ +