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.