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 64e26fd..5d846bb 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index 5cb65c0..8fc16a1 100644 --- a/index.html +++ b/index.html @@ -105,7 +105,7 @@ Username -