Files
r-zakarya e23a78dda7 feat: entry templates + tag autocomplete + slideover push + robustness bundle
- Entry templates: new vault_entries.template column drives a typed
  sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
  + table label off the template, badge reads "credit card" instead of
  "note". Templates seed kind=note (no site/password required), use
  custom_fields with optional dropdown options (brand, month/year,
  protocol). Round-tripped across export/import/duplicate/master-pw
  rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
  (card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
  filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
  commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
  via mousedown origin tracker), Esc closes, X closes. App shell is
  pushed left by 420px when the panel is open so the table / pagination
  / sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
  (decrypted to base64, re-encrypted under current key on restore),
  icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
  preserves title/template/custom_fields. CSV parser detects kind/
  template columns.
- Bulk-import response returns `ids[]` parallel to input so the
  client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
  addTag, batchAddTag were all silently wiping TOTP / custom_fields
  / kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
  the very next request after rotation no longer ESessionRejects.
  Client adopts the new pair. Attachments are re-encrypted client-side
  during rotation (GET old → decrypt with old key → encrypt with new
  → PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
  .Value assignment so SQLite TEXT no longer truncates to 4000 chars
  (deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
  raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
  error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
  when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
  Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
  surfaced in Settings → Account. Autostart launches suppress the
  first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 21:20:07 +01:00

233 lines
7.0 KiB
ObjectPascal

unit PM.QuickUnlock;
{
Quick unlock — persistent on-device cache of the vault key, encrypted
with the Windows DPAPI (CryptProtectData with CRYPTPROTECT_CURRENT_USER).
Designed so the user can unlock the vault on a trusted device without
retyping the master password every time.
Threat model & honesty
----------------------
This is NOT biometric authentication. The cached key is tied to the
Windows USER ACCOUNT — any process running as the same user can read
it back via the same DPAPI call. The security perimeter is therefore
the Windows account itself.
If the user has Windows Hello / fingerprint / PIN configured at the
OS level, then logging into Windows implies biometric / strong-cred
authentication, which transitively gates DPAPI access. Without that
OS-level configuration, this feature is "convenience unlock", not
cryptographic 2FA.
Storage
-------
Encrypted blob lives at
%LOCALAPPDATA%\PMServer\quickunlock.bin
One blob per Windows user. Multiple vault accounts on the same machine
share the file — only the last opt-in wins. Acceptable for v1 since
the typical Windows-user-to-vault-account ratio is 1:1.
Wire format
-----------
The bytes passed to Store/Load are opaque to this unit. The bridge
layer (UMainForm) hands us the UTF-8 bytes of a JSON object that
carries whatever the JS layer needs at restore time (currently the
raw vault key + username + salt — see app.js).
}
interface
uses
System.SysUtils, System.Classes, System.IOUtils,
Winapi.Windows;
// Persist APayload (DPAPI-encrypted) for the current Windows user.
// Returns False on any I/O or DPAPI failure — caller decides whether
// to surface the error.
function StoreQuickUnlock(const APayload: TBytes): Boolean;
// Read + decrypt. Returns False when no blob exists, the file is
// corrupted, or DPAPI fails (e.g., user account changed).
function LoadQuickUnlock(out APayload: TBytes): Boolean;
// Delete the blob file. Idempotent — silent if the file is absent.
procedure ClearQuickUnlock;
// Is a blob currently stored?
function HasQuickUnlock: Boolean;
implementation
// ---------------------------------------------------------------------------
// DPAPI declarations
// ---------------------------------------------------------------------------
// Winapi.WinCrypt provides these on some Delphi versions, but the declarations
// are inconsistent across releases. Declare locally so the unit doesn't
// depend on a specific RTL revision.
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';
var LoadedConfig:Boolean=False;
StorageDir_:String='';
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
procedure LoadConfig;
begin
if LoadedConfig then
exit;
Var ConfigList := TStringList.Create;
try
StorageDir_ := TPath.Combine(ExtractFileDir(ParamStr(0)),'config.txt');
if TFile.Exists(StorageDir_) then
begin
ConfigList.LoadFromFile(StorageDir_);
StorageDir_ := ConfigList.Values['PathUnlock'];
if StorageDir_.ToLower.Equals('same') then
StorageDir_ := ExtractFileDir(ParamStr(0))
end
else
StorageDir_ := '';
finally
FreeAndNil(ConfigList);
LoadedConfig :=True;
end;
end;
function StorageDir: string;
begin
// %LOCALAPPDATA%\PMServer — per-user, roaming-disabled. DPAPI keys live
// alongside the user profile so they survive Windows updates but not
// a profile reset.
LoadConfig;
if StorageDir_.IsEmpty then
Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'),'PMServer')
else
Result :=StorageDir_;
end;
function StorageFile: string;
begin
Result := TPath.Combine(StorageDir, 'quickunlock.bin');
end;
procedure EnsureStorageDir;
begin
if not TDirectory.Exists(StorageDir) then
TDirectory.CreateDirectory(StorageDir);
end;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function StoreQuickUnlock(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;
// No optional entropy, no description, no flags. Default scope is
// CRYPTPROTECT_CURRENT_USER → ties to the Windows user account.
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 LoadQuickUnlock(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;
// Read encrypted bytes from disk.
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;
// Decrypt. Fails if the current Windows user differs from the one
// that called Store (different machine, profile reset, etc.) — we
// return False so the caller falls back to the master-pw flow.
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 ClearQuickUnlock;
begin
try
if TFile.Exists(StorageFile) then
TFile.Delete(StorageFile);
except
// Best-effort cleanup. File-system errors aren't worth surfacing
// for what's a "forget me" action.
end;
end;
function HasQuickUnlock: Boolean;
begin
Result := TFile.Exists(StorageFile);
end;
end.