e23a78dda7
- 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>
233 lines
7.0 KiB
ObjectPascal
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.
|