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'; // --------------------------------------------------------------------------- // Storage helpers // --------------------------------------------------------------------------- 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. Result := TPath.Combine( GetEnvironmentVariable('LOCALAPPDATA'), 'PMServer'); 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.