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>
This commit is contained in:
@@ -23,6 +23,7 @@ implementation
|
||||
|
||||
uses
|
||||
System.SysUtils, System.JSON,
|
||||
Data.DB,
|
||||
FireDAC.Comp.Client, FireDAC.Stan.Param,
|
||||
IdCustomHTTPServer,
|
||||
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit;
|
||||
@@ -182,10 +183,17 @@ begin
|
||||
'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)';
|
||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||
LQ.ParamByName('eid').AsInteger := LEntryId;
|
||||
LQ.ParamByName('name').AsString := LFilename;
|
||||
LQ.ParamByName('mime').AsString := LMime;
|
||||
// Force ftWideString / ftMemo so unicode filenames (Arabic,
|
||||
// Chinese, emoji…) survive the round-trip. The default ftString
|
||||
// inferred from .AsString maps to ANSI on SQLite and replaces
|
||||
// anything outside the local codepage with '?'.
|
||||
LQ.ParamByName('name').DataType := ftWideString;
|
||||
LQ.ParamByName('name').AsWideString := LFilename;
|
||||
LQ.ParamByName('mime').DataType := ftWideString;
|
||||
LQ.ParamByName('mime').AsWideString := LMime;
|
||||
LQ.ParamByName('sz').AsInteger := LSize;
|
||||
LQ.ParamByName('blob').AsString := LBlob;
|
||||
LQ.ParamByName('blob').DataType := ftMemo;
|
||||
LQ.ParamByName('blob').Value := LBlob;
|
||||
LQ.ParamByName('iv').AsString := LIv;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments');
|
||||
|
||||
@@ -17,6 +17,7 @@ uses
|
||||
PM.HTTPServer in 'Source\PM.HTTPServer.pas',
|
||||
PM.Bridge in 'Source\PM.Bridge.pas',
|
||||
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
|
||||
PM.PinUnlock in 'Source\PM.PinUnlock.pas',
|
||||
PM.UserPrefs in 'Source\PM.UserPrefs.pas',
|
||||
PM.AutoStart in 'Source\PM.AutoStart.pas',
|
||||
PM.Favicon in 'Source\PM.Favicon.pas',
|
||||
|
||||
@@ -225,6 +225,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
||||
<DCCReference Include="Source\PM.HTTPServer.pas"/>
|
||||
<DCCReference Include="Source\PM.Bridge.pas"/>
|
||||
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.PinUnlock.pas"/>
|
||||
<DCCReference Include="Source\PM.UserPrefs.pas"/>
|
||||
<DCCReference Include="Source\PM.AutoStart.pas"/>
|
||||
<DCCReference Include="Source\PM.Favicon.pas"/>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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.
|
||||
@@ -32,7 +32,7 @@ uses
|
||||
{$IFDEF USE_EDGE_BROWSER}
|
||||
FMX.TMSFNCEdgeWebBrowser,
|
||||
{$ENDIF}
|
||||
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart,
|
||||
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.AutoStart,
|
||||
PM.Favicon,
|
||||
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
|
||||
const
|
||||
@@ -635,6 +635,51 @@ begin
|
||||
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
|
||||
end
|
||||
|
||||
// ---- PIN unlock (DPAPI blob, PIN-derived wrap of the vault key) ----
|
||||
// Same wire model as quickunlock — opaque base64 payload in / out.
|
||||
else if ACmd = 'pin/store' then
|
||||
begin
|
||||
LText := GetParam('data');
|
||||
if LText = '' then Exit;
|
||||
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText);
|
||||
if PM.PinUnlock.StorePinUnlock(LBytes) then
|
||||
LogLine(Format('PIN blob stored (%d bytes)', [Length(LBytes)]))
|
||||
else
|
||||
LogLine('PIN blob store FAILED (DPAPI error)');
|
||||
end
|
||||
|
||||
else if ACmd = 'pin/get' then
|
||||
begin
|
||||
var LBytes: TBytes;
|
||||
if PM.PinUnlock.LoadPinUnlock(LBytes) and (Length(LBytes) > 0) then
|
||||
begin
|
||||
var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
||||
LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]);
|
||||
LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onPinResult)' +
|
||||
'Bridge.onPinResult("' + LB64 + '")');
|
||||
LogLine('PIN blob served');
|
||||
end
|
||||
else
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onPinResult)Bridge.onPinResult(null)');
|
||||
end
|
||||
|
||||
else if ACmd = 'pin/clear' then
|
||||
begin
|
||||
PM.PinUnlock.ClearPinUnlock;
|
||||
LogLine('PIN blob cleared');
|
||||
end
|
||||
|
||||
else if ACmd = 'pin/status' then
|
||||
begin
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onPinStatus)' +
|
||||
'Bridge.onPinStatus(' +
|
||||
BoolToStr(PM.PinUnlock.HasPinUnlock, True).ToLower + ')');
|
||||
end
|
||||
|
||||
// ---- Autofill --------------------------------------------------------
|
||||
// configure: JS calls this on page load / settings change to sync the
|
||||
// hotkey registration state with the user's localStorage preference.
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user