Files
r-zakarya b00da43ab0 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>
2026-06-29 04:41:39 +01:00

156 lines
4.1 KiB
ObjectPascal

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.