feat: PIN unlock + table column picker + edit-position chooser + UX

- 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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-29 04:41:39 +01:00
parent 1f56a03492
commit b00da43ab0
11 changed files with 1417 additions and 71 deletions
+38
View File
@@ -454,6 +454,44 @@ hotkeys autofill, etc. **Device-only** (localStorage seulement) :
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` `quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). (toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
## PIN unlock
Optional shortcut unlock with a 412 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 (~1340 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 ## Quick Unlock
DPAPI blob à `%LOCALAPPDATA%\PMServer\quickunlock.bin` (tied to Windows DPAPI blob à `%LOCALAPPDATA%\PMServer\quickunlock.bin` (tied to Windows
+51 -1
View File
@@ -85,7 +85,57 @@ re-vérifier explicitement.
- [✅] "Clear" → vide tout l'historique - [✅] "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é - [✅] 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 412 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 - [✅] Quick unlock encore fonctionnel après update
- [✅] Master password rotation OK + entries déchiffrables après - [✅] Master password rotation OK + entries déchiffrables après
- [✅] Autofill (Ctrl+Shift+L/P) toujours OK - [✅] Autofill (Ctrl+Shift+L/P) toujours OK
+99 -9
View File
@@ -1859,6 +1859,32 @@ input[type="range"]::-webkit-slider-thumb {
color: var(--text-faint); 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 modal (tray menu) ------------------ */
.quick-search-panel { .quick-search-panel {
padding: 0; padding: 0;
@@ -1986,12 +2012,57 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 50; z-index: 50;
} }
.slideover.is-open { transform: translateX(0); } .slideover.is-open { transform: translateX(0); }
/* Push the app shell to the left when the slideover is open, so the /* Suppress the slide animation when the panel is closed — otherwise
table, sort buttons, pagination and search bar are no longer hidden changing editor-position animates the off-screen position from right
behind it. Width matches .slideover (420px) — bumped to 100% via the to left and the panel briefly crosses the viewport. */
responsive override below 720px. */ .slideover:not(.is-open) { transition: none; }
body:has(.slideover.is-open) #appShell { margin-right: 420px; } /* Push the app shell to the left when the entry slideover is open,
#appShell { transition: margin-right var(--t-base); } 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 { .slideover-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; padding: 16px 20px;
@@ -2289,7 +2360,7 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; }
.slideover { width: 100%; } .slideover { width: 100%; }
/* On a narrow viewport the slideover fills the screen — no point /* On a narrow viewport the slideover fills the screen — no point
pushing the shell, the table isn't visible anyway. */ 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 -------------------------------- */ /* ---- 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-selected { background: var(--accent-soft); }
.entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } .entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
.col-check { width: 36px; } .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 { .col-name .entry-avatar-sm {
width: 22px; height: 22px; width: 22px; height: 22px;
display: inline-grid; place-items: center; 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; } .entry-row:hover .col-user .icon-btn { opacity: 0.7; }
.col-folder { color: var(--text-dim); white-space: nowrap; } .col-folder { color: var(--text-dim); white-space: nowrap; }
.col-updated { color: var(--text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; } .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); } .col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; } .entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; } .col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }
@@ -23,6 +23,7 @@ implementation
uses uses
System.SysUtils, System.JSON, System.SysUtils, System.JSON,
Data.DB,
FireDAC.Comp.Client, FireDAC.Stan.Param, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer, IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit; PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit;
@@ -182,10 +183,17 @@ begin
'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)'; 'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('eid').AsInteger := LEntryId; LQ.ParamByName('eid').AsInteger := LEntryId;
LQ.ParamByName('name').AsString := LFilename; // Force ftWideString / ftMemo so unicode filenames (Arabic,
LQ.ParamByName('mime').AsString := LMime; // 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('sz').AsInteger := LSize;
LQ.ParamByName('blob').AsString := LBlob; LQ.ParamByName('blob').DataType := ftMemo;
LQ.ParamByName('blob').Value := LBlob;
LQ.ParamByName('iv').AsString := LIv; LQ.ParamByName('iv').AsString := LIv;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments'); LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments');
+1
View File
@@ -17,6 +17,7 @@ uses
PM.HTTPServer in 'Source\PM.HTTPServer.pas', PM.HTTPServer in 'Source\PM.HTTPServer.pas',
PM.Bridge in 'Source\PM.Bridge.pas', PM.Bridge in 'Source\PM.Bridge.pas',
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.PinUnlock in 'Source\PM.PinUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.UserPrefs in 'Source\PM.UserPrefs.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',
+1
View File
@@ -225,6 +225,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.HTTPServer.pas"/> <DCCReference Include="Source\PM.HTTPServer.pas"/>
<DCCReference Include="Source\PM.Bridge.pas"/> <DCCReference Include="Source\PM.Bridge.pas"/>
<DCCReference Include="Source\PM.QuickUnlock.pas"/> <DCCReference Include="Source\PM.QuickUnlock.pas"/>
<DCCReference Include="Source\PM.PinUnlock.pas"/>
<DCCReference Include="Source\PM.UserPrefs.pas"/> <DCCReference Include="Source\PM.UserPrefs.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"/>
+155
View File
@@ -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.
+46 -1
View File
@@ -32,7 +32,7 @@ uses
{$IFDEF USE_EDGE_BROWSER} {$IFDEF USE_EDGE_BROWSER}
FMX.TMSFNCEdgeWebBrowser, FMX.TMSFNCEdgeWebBrowser,
{$ENDIF} {$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, PM.Favicon,
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
const const
@@ -635,6 +635,51 @@ begin
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')'); BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
end 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 -------------------------------------------------------- // ---- Autofill --------------------------------------------------------
// configure: JS calls this on page load / settings change to sync the // configure: JS calls this on page load / settings change to sync the
// hotkey registration state with the user's localStorage preference. // hotkey registration state with the user's localStorage preference.
Binary file not shown.
+68 -5
View File
@@ -105,7 +105,7 @@
<span>Username</span> <span>Username</span>
<input id="loginUsername" type="text" required> <input id="loginUsername" type="text" required>
</label> </label>
<label class="field"> <label class="field" id="loginPasswordField">
<span>Master password</span> <span>Master password</span>
<div class="input-with-action"> <div class="input-with-action">
<input id="loginPassword" type="password" required> <input id="loginPassword" type="password" required>
@@ -114,6 +114,15 @@
</button> </button>
</div> </div>
</label> </label>
<label class="field" id="loginPinField" style="display:none">
<span>PIN</span>
<input id="loginPin" type="password" inputmode="numeric"
autocomplete="off" maxlength="12">
</label>
<button type="button" id="loginUseMasterBtn" class="btn btn-link btn-block"
style="font-size:12px;margin-top:-4px;display:none">
Use master password instead
</button>
<label class="remember-row"> <label class="remember-row">
<input type="checkbox" id="loginRememberUser"> <input type="checkbox" id="loginRememberUser">
<span>Remember username on this device</span> <span>Remember username on this device</span>
@@ -177,6 +186,10 @@
<span>Notes</span> <span>Notes</span>
<span class="nav-count" id="countNotes">0</span> <span class="nav-count" id="countNotes">0</span>
</button> </button>
<button class="nav-item" id="sidebarRecentBtn">
<svg><use href="#i-rotate-ccw"/></svg>
<span>Recently used</span>
</button>
</nav> </nav>
<div class="sidebar-section" data-section="folders"> <div class="sidebar-section" data-section="folders">
@@ -224,10 +237,6 @@
<svg><use href="#i-key"/></svg> <svg><use href="#i-key"/></svg>
<span>TOTP generator</span> <span>TOTP generator</span>
</button> </button>
<button class="nav-item" id="sidebarRecentBtn">
<svg><use href="#i-rotate-ccw"/></svg>
<span>Recently used</span>
</button>
<button class="nav-item" id="sidebarHealthBtn"> <button class="nav-item" id="sidebarHealthBtn">
<svg><use href="#i-alert"/></svg> <svg><use href="#i-alert"/></svg>
<span>Vault health</span> <span>Vault health</span>
@@ -281,6 +290,12 @@
<svg><use href="#i-table"/></svg> <svg><use href="#i-table"/></svg>
</button> </button>
</div> </div>
<div class="cols-wrap" id="colsWrap" style="display:none;position:relative">
<button class="icon-btn" id="colsBtn" title="Show / hide columns">
<svg><use href="#i-settings"/></svg>
</button>
<div class="cols-menu is-hidden" id="colsMenu"></div>
</div>
<div class="filters-wrap"> <div class="filters-wrap">
<button class="icon-btn" id="filtersBtn" title="Filters"> <button class="icon-btn" id="filtersBtn" title="Filters">
<svg><use href="#i-list"/></svg> <svg><use href="#i-list"/></svg>
@@ -409,6 +424,27 @@
<option value="created:asc">Oldest created</option> <option value="created:asc">Oldest created</option>
</select> </select>
</div> </div>
<div class="setting-row">
<span>
Editor position
<small class="setting-hint">Where the add / edit panel and Settings appear. Affects both side panels.</small>
</span>
<select id="settingEditorPosition">
<option value="right">Slide-over (right)</option>
<option value="left">Slide-over (left)</option>
<option value="center">Centered modal</option>
</select>
</div>
<div class="setting-row">
<span>
Confirm before closing unsaved edits
<small class="setting-hint">When the entry editor has changes, ask before dismissing it via X, Esc, or click-outside.</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingConfirmUnsaved">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row"> <div class="setting-row">
<span>Compact action menu (⋯)</span> <span>Compact action menu (⋯)</span>
<label class="toggle"> <label class="toggle">
@@ -678,6 +714,33 @@
</button> </button>
</div> </div>
<div class="slideover-field" id="pinUnlockField" style="display:none">
<div class="slideover-field-label">PIN unlock</div>
<p id="pinStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
No PIN set.
</p>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
Shortcut unlock with a 46 digit PIN. Stored DPAPI-encrypted on
this device. <b>Master password is still required for sensitive
actions</b> (export, change master password, recovery code…).
</p>
<div class="setting-row" style="margin:0 0 8px">
<span>
Unlock method
<small class="setting-hint">Master pw (default), PIN only, or both required.</small>
</span>
<select id="settingUnlockMode">
<option value="pw">Master password</option>
<option value="pin">PIN only</option>
<option value="both">Master password + PIN</option>
</select>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" id="pinSetBtn">Set PIN</button>
<button class="btn btn-ghost btn-sm is-danger" id="pinRemoveBtn" style="display:none">Remove PIN</button>
</div>
</div>
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Quick unlock</div> <div class="slideover-field-label">Quick unlock</div>
<p id="quickUnlockStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5"> <p id="quickUnlockStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
+943 -48
View File
File diff suppressed because it is too large Load Diff