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:
@@ -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
|
||||
|
||||
+51
-1
@@ -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
|
||||
|
||||
+99
-9
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -225,6 +225,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.HTTPServer.pas"/>
|
||||
<DCCReference Include="Source\PM.Bridge.pas"/>
|
||||
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.PinUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.UserPrefs.pas"/>
|
||||
<DCCReference Include="Source\PM.AutoStart.pas"/>
|
||||
<DCCReference Include="Source\PM.Favicon.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.
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
+68
-5
@@ -105,7 +105,7 @@
|
||||
<span>Username</span>
|
||||
<input id="loginUsername" type="text" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<label class="field" id="loginPasswordField">
|
||||
<span>Master password</span>
|
||||
<div class="input-with-action">
|
||||
<input id="loginPassword" type="password" required>
|
||||
@@ -114,6 +114,15 @@
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<input type="checkbox" id="loginRememberUser">
|
||||
<span>Remember username on this device</span>
|
||||
@@ -177,6 +186,10 @@
|
||||
<span>Notes</span>
|
||||
<span class="nav-count" id="countNotes">0</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarRecentBtn">
|
||||
<svg><use href="#i-rotate-ccw"/></svg>
|
||||
<span>Recently used</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section" data-section="folders">
|
||||
@@ -224,10 +237,6 @@
|
||||
<svg><use href="#i-key"/></svg>
|
||||
<span>TOTP generator</span>
|
||||
</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">
|
||||
<svg><use href="#i-alert"/></svg>
|
||||
<span>Vault health</span>
|
||||
@@ -281,6 +290,12 @@
|
||||
<svg><use href="#i-table"/></svg>
|
||||
</button>
|
||||
</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">
|
||||
<button class="icon-btn" id="filtersBtn" title="Filters">
|
||||
<svg><use href="#i-list"/></svg>
|
||||
@@ -409,6 +424,27 @@
|
||||
<option value="created:asc">Oldest created</option>
|
||||
</select>
|
||||
</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">
|
||||
<span>Compact action menu (⋯)</span>
|
||||
<label class="toggle">
|
||||
@@ -678,6 +714,33 @@
|
||||
</button>
|
||||
</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 4–6 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-label">Quick unlock</div>
|
||||
<p id="quickUnlockStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||
|
||||
Reference in New Issue
Block a user