feat(unlock): Quick unlock via DPAPI (remember on this device)
User-controlled opt-in to skip the master-password prompt on subsequent
app starts. The vault state (raw AES key + salt + username + session
token) is bundled and handed to the Delphi side, which DPAPI-encrypts
it with CRYPTPROTECT_CURRENT_USER and stashes the blob at
%LOCALAPPDATA%\PMServer\quickunlock.bin.
Honest threat model
===================
This is NOT biometric authentication. The DPAPI scope is the Windows
USER ACCOUNT — any process running as the same user can decrypt the
blob via the same DPAPI call. The security perimeter is the Windows
account itself. The Settings UI label is "Quick unlock" with an
explainer:
"convenient on a personal machine, not safe on a shared one"
If the user has Windows Hello / fingerprint / PIN configured at the
OS level, then Windows login is biometric-gated and that gating
transitively applies to DPAPI access — but the cryptographic strength
of the encryption isn't tied to the biometric, it's tied to the
Windows account secret. Honest framing matters here, so the feature
isn't sold as "biometric".
Backend
=======
New unit Source/PM.QuickUnlock.pas:
- StoreQuickUnlock(bytes) → DPAPI-encrypt and persist to
%LOCALAPPDATA%\PMServer\quickunlock.bin
- LoadQuickUnlock(out bytes) → read file, DPAPI-decrypt
- ClearQuickUnlock → forget-me
- HasQuickUnlock → file existence probe
DPAPI declarations are local (CryptProtectData / CryptUnprotectData
from crypt32.dll) — Winapi.WinCrypt's signatures drift across Delphi
versions and we don't want to fight that.
Bridge commands (UMainForm.HandleBridgeCommand):
cmd://quickunlock/store?data=<base64> payload opaque to Delphi
cmd://quickunlock/get → ExecuteJavaScript callback
Bridge.onQuickUnlockResult(b64|null)
cmd://quickunlock/clear forget-me
cmd://quickunlock/status → Bridge.onQuickUnlockStatus(bool)
The get / status results are returned via ExecuteJavaScript rather than
HTTP (the bridge is request-only) — JS resolves a Promise that the
caller awaited.
Client
======
state.quickUnlockEnabled mirrors localStorage flag, lazy-cleared if the
backing DPAPI blob has gone missing (e.g., user reset Windows profile).
enableQuickUnlock():
1. askReauth + /reauth to verify it's actually the user.
2. exportKey('raw', state.cryptoKey) — extractable already.
3. JSON-bundle { v, username, salt, token, csrf, key } → base64.
4. cmd://quickunlock/store sends the blob to Delphi.
tryQuickUnlock() (called from init):
1. Probe localStorage flag.
2. cmd://quickunlock/get, await Bridge.onQuickUnlockResult.
3. Decode JSON, importKey, restore state.* + sessionStorage.
4. Return true on success, false to fall through to master-pw login.
Two restore scenarios both covered:
A. Same app session (sessionStorage still populated, only cryptoKey
was wiped by lock). tryQuickUnlock just restores the key.
B. Cold start (sessionStorage empty). tryQuickUnlock restores
EVERYTHING from the DPAPI blob, including the session token.
UI
==
Settings panel → new "Quick unlock" section above Recovery key.
Single toggle button: "Enable on this device" / "Disable" with status
line above. Opens settings → bridgeQuickUnlockStatus() reconciles the
JS-side flag with the actual file (drift detection).
Stale-blob protection
=====================
The stored blob holds the AES key BYTES, which would become useless
if the vault were re-encrypted under a different key. Three paths
that re-encrypt the vault now also wipe the DPAPI blob:
- Explicit doLogout (user said "I'm done")
- Master password change (new key, old blob can't decrypt anything)
- (Recovery redeem already forces master pw change → covered.)
The blob persists across the passive lockVault() flow on purpose —
that's the whole point: lock without losing convenience.
Init wiring
===========
On app start, the existing "restore session from sessionStorage" path
now falls through to tryQuickUnlock if either sessionStorage is empty
OR the cryptoKey is gone. Auth screen shows up only after both
attempts fail.
This commit is contained in:
@@ -15,6 +15,7 @@ uses
|
|||||||
PM.Session in 'Source\PM.Session.pas',
|
PM.Session in 'Source\PM.Session.pas',
|
||||||
PM.HTTPServer in 'Source\PM.HTTPServer.pas',
|
PM.HTTPServer in 'Source\PM.HTTPServer.pas',
|
||||||
PM.Bridge in 'Source\PM.Bridge.pas',
|
PM.Bridge in 'Source\PM.Bridge.pas',
|
||||||
|
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
|
||||||
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
|
PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas',
|
||||||
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
|
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
|
||||||
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
|
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
|
|||||||
<DCCReference Include="Source\PM.Session.pas"/>
|
<DCCReference Include="Source\PM.Session.pas"/>
|
||||||
<DCCReference Include="Source\PM.HTTPServer.pas"/>
|
<DCCReference Include="Source\PM.HTTPServer.pas"/>
|
||||||
<DCCReference Include="Source\PM.Bridge.pas"/>
|
<DCCReference Include="Source\PM.Bridge.pas"/>
|
||||||
|
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
|
||||||
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
|
||||||
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
|
||||||
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
|
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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.
|
||||||
@@ -9,7 +9,7 @@ uses
|
|||||||
FMX.Dialogs,
|
FMX.Dialogs,
|
||||||
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
|
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
|
||||||
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
|
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
|
||||||
PM.HTTPServer, PM.Bridge;
|
PM.HTTPServer, PM.Bridge, PM.QuickUnlock;
|
||||||
|
|
||||||
type
|
type
|
||||||
TMainForm = class(TForm)
|
TMainForm = class(TForm)
|
||||||
@@ -279,6 +279,59 @@ begin
|
|||||||
LogLine('Clipboard cleared by JS request');
|
LogLine('Clipboard cleared by JS request');
|
||||||
end
|
end
|
||||||
|
|
||||||
|
// ---- Quick unlock (DPAPI persistence of the vault key) ----
|
||||||
|
// store: client provides a base64-encoded blob (UTF-8 JSON, content
|
||||||
|
// opaque to us). We DPAPI-encrypt and stash on disk.
|
||||||
|
// get: we DPAPI-decrypt, base64-encode, send back via ExecuteJavaScript.
|
||||||
|
// clear: forget-me.
|
||||||
|
else if ACmd = 'quickunlock/store' then
|
||||||
|
begin
|
||||||
|
LText := GetParam('data'); // base64 of UTF-8 JSON blob
|
||||||
|
if LText = '' then Exit;
|
||||||
|
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText);
|
||||||
|
if PM.QuickUnlock.StoreQuickUnlock(LBytes) then
|
||||||
|
LogLine(Format('Quick unlock stored (%d bytes)', [Length(LBytes)]))
|
||||||
|
else
|
||||||
|
LogLine('Quick unlock store FAILED (DPAPI error)');
|
||||||
|
end
|
||||||
|
|
||||||
|
else if ACmd = 'quickunlock/get' then
|
||||||
|
begin
|
||||||
|
var LBytes: TBytes;
|
||||||
|
if PM.QuickUnlock.LoadQuickUnlock(LBytes) and (Length(LBytes) > 0) then
|
||||||
|
begin
|
||||||
|
var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
|
||||||
|
// Strip newlines that the Base64 encoder may insert (line-wrapping
|
||||||
|
// breaks the JS-side decoder) before injecting into a JS string.
|
||||||
|
LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]);
|
||||||
|
LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]);
|
||||||
|
WebBrowser.ExecuteJavaScript(
|
||||||
|
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
|
||||||
|
'Bridge.onQuickUnlockResult("' + LB64 + '")');
|
||||||
|
LogLine('Quick unlock served');
|
||||||
|
end
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
WebBrowser.ExecuteJavaScript(
|
||||||
|
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
|
||||||
|
'Bridge.onQuickUnlockResult(null)');
|
||||||
|
end;
|
||||||
|
end
|
||||||
|
|
||||||
|
else if ACmd = 'quickunlock/clear' then
|
||||||
|
begin
|
||||||
|
PM.QuickUnlock.ClearQuickUnlock;
|
||||||
|
LogLine('Quick unlock cleared');
|
||||||
|
end
|
||||||
|
|
||||||
|
else if ACmd = 'quickunlock/status' then
|
||||||
|
begin
|
||||||
|
WebBrowser.ExecuteJavaScript(
|
||||||
|
'if(window.Bridge&&Bridge.onQuickUnlockStatus)' +
|
||||||
|
'Bridge.onQuickUnlockStatus(' +
|
||||||
|
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
|
||||||
|
end
|
||||||
|
|
||||||
else
|
else
|
||||||
LogLine('Bridge: unknown command "' + ACmd + '"');
|
LogLine('Bridge: unknown command "' + ACmd + '"');
|
||||||
end;
|
end;
|
||||||
|
|||||||
+16
@@ -378,6 +378,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="slideover-field">
|
||||||
|
<div class="slideover-field-label">Quick unlock</div>
|
||||||
|
<p id="quickUnlockStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||||
|
Disabled.
|
||||||
|
</p>
|
||||||
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
|
||||||
|
Unlock the vault on this device without retyping your
|
||||||
|
master password. The key is stored encrypted with
|
||||||
|
Windows DPAPI (tied to your Windows account) — convenient
|
||||||
|
on a personal machine, not safe on a shared one.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="quickUnlockToggleBtn">
|
||||||
|
Enable on this device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="slideover-field">
|
<div class="slideover-field">
|
||||||
<div class="slideover-field-label">Recovery key</div>
|
<div class="slideover-field-label">Recovery key</div>
|
||||||
<p id="recoveryStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
<p id="recoveryStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ const state = {
|
|||||||
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
|
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
|
||||||
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
|
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
|
||||||
hibpResults: new Map(),
|
hibpResults: new Map(),
|
||||||
|
quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1',
|
||||||
|
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -630,6 +632,13 @@ async function doRegister(e) {
|
|||||||
|
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {}
|
try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {}
|
||||||
|
// Explicit logout → wipe the DPAPI quick-unlock blob too. Logout is the
|
||||||
|
// user saying "I'm done", quite different from a passive lock.
|
||||||
|
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
|
||||||
|
window.location.href = 'cmd://quickunlock/clear';
|
||||||
|
localStorage.removeItem('quickUnlockEnabled');
|
||||||
|
state.quickUnlockEnabled = false;
|
||||||
|
}
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
|
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
|
||||||
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
|
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
|
||||||
@@ -2409,6 +2418,180 @@ function closeReauth(ok) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// QUICK UNLOCK — remember on this device (DPAPI-backed)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// User-controlled convenience feature. When opted in, the current vault
|
||||||
|
// state (raw AES key + salt + username + session token) is bundled into
|
||||||
|
// a JSON blob and handed to the Delphi side, which DPAPI-encrypts it
|
||||||
|
// (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent
|
||||||
|
// app starts can recover the entire session without re-entering the
|
||||||
|
// master password.
|
||||||
|
//
|
||||||
|
// Honest trade-off: the encrypted blob is readable by ANY process
|
||||||
|
// running as the same Windows user. The protection is no stronger than
|
||||||
|
// the Windows account itself. Users with Windows Hello / fingerprint /
|
||||||
|
// PIN configured at the OS level get biometric gating transitively
|
||||||
|
// (via login). Without that, this is "remember on trusted device".
|
||||||
|
//
|
||||||
|
// Only available when running inside the Delphi host (Bridge.active);
|
||||||
|
// the PHP standalone has no DPAPI equivalent.
|
||||||
|
|
||||||
|
// Resolver for the Delphi → JS callback. Delphi side fires
|
||||||
|
// Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get.
|
||||||
|
let quickUnlockResolver = null;
|
||||||
|
|
||||||
|
// Same for the status query.
|
||||||
|
let quickUnlockStatusResolver = null;
|
||||||
|
|
||||||
|
function bridgeRequestQuickUnlock() {
|
||||||
|
if (!Bridge.active) return Promise.resolve(null);
|
||||||
|
return new Promise(resolve => {
|
||||||
|
quickUnlockResolver = resolve;
|
||||||
|
// Safety: if Delphi never responds, time out after 3 s so the auth
|
||||||
|
// screen doesn't hang. Falls back to master-pw login.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (quickUnlockResolver === resolve) {
|
||||||
|
quickUnlockResolver = null;
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
window.location.href = 'cmd://quickunlock/get';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeQuickUnlockStatus() {
|
||||||
|
if (!Bridge.active) return Promise.resolve(false);
|
||||||
|
return new Promise(resolve => {
|
||||||
|
quickUnlockStatusResolver = resolve;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (quickUnlockStatusResolver === resolve) {
|
||||||
|
quickUnlockStatusResolver = null;
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
window.location.href = 'cmd://quickunlock/status';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above.
|
||||||
|
Bridge.onQuickUnlockResult = function(b64) {
|
||||||
|
if (quickUnlockResolver) {
|
||||||
|
const cb = quickUnlockResolver;
|
||||||
|
quickUnlockResolver = null;
|
||||||
|
cb(b64);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Bridge.onQuickUnlockStatus = function(configured) {
|
||||||
|
if (quickUnlockStatusResolver) {
|
||||||
|
const cb = quickUnlockStatusResolver;
|
||||||
|
quickUnlockStatusResolver = null;
|
||||||
|
cb(!!configured);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function enableQuickUnlock() {
|
||||||
|
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
|
||||||
|
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
|
||||||
|
|
||||||
|
const masterPwd = await askReauth(
|
||||||
|
'Confirm your master password to enable Quick unlock on this device.');
|
||||||
|
if (!masterPwd) return;
|
||||||
|
try {
|
||||||
|
await api('/reauth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ masterPassword: masterPwd }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toast('Wrong master password', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the raw key + bundle session pieces needed for a cold-start
|
||||||
|
// restore (no master pw available). Send as base64-encoded UTF-8 JSON.
|
||||||
|
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||||||
|
const blob = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
username: state.username,
|
||||||
|
salt: state.salt,
|
||||||
|
token: state.token,
|
||||||
|
csrf: state.csrf,
|
||||||
|
key: bytesToBase64(raw),
|
||||||
|
});
|
||||||
|
const b64 = bytesToBase64(new TextEncoder().encode(blob));
|
||||||
|
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
|
||||||
|
|
||||||
|
localStorage.setItem('quickUnlockEnabled', '1');
|
||||||
|
state.quickUnlockEnabled = true;
|
||||||
|
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
|
||||||
|
toast('Quick unlock enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableQuickUnlock() {
|
||||||
|
window.location.href = 'cmd://quickunlock/clear';
|
||||||
|
localStorage.removeItem('quickUnlockEnabled');
|
||||||
|
state.quickUnlockEnabled = false;
|
||||||
|
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
|
||||||
|
toast('Quick unlock disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickUnlockUI() {
|
||||||
|
const lbl = $('#quickUnlockStatus');
|
||||||
|
const btn = $('#quickUnlockToggleBtn');
|
||||||
|
if (!lbl || !btn) return;
|
||||||
|
if (state.quickUnlockEnabled) {
|
||||||
|
lbl.textContent = 'Enabled on this device.';
|
||||||
|
btn.textContent = 'Disable';
|
||||||
|
btn.classList.add('is-danger');
|
||||||
|
} else {
|
||||||
|
lbl.textContent = 'Disabled.';
|
||||||
|
btn.textContent = 'Enable on this device';
|
||||||
|
btn.classList.remove('is-danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to unlock via DPAPI. Returns true if the vault is now unlocked,
|
||||||
|
// false otherwise (caller falls back to master-pw login).
|
||||||
|
async function tryQuickUnlock() {
|
||||||
|
if (!Bridge.active) return false;
|
||||||
|
if (localStorage.getItem('quickUnlockEnabled') !== '1') return false;
|
||||||
|
|
||||||
|
const b64 = await bridgeRequestQuickUnlock();
|
||||||
|
if (!b64) return false;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
const jsonStr = new TextDecoder().decode(base64ToBytes(b64));
|
||||||
|
parsed = JSON.parse(jsonStr);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
|
||||||
|
|
||||||
|
// Restore session state from the blob.
|
||||||
|
state.username = parsed.username;
|
||||||
|
state.salt = parsed.salt;
|
||||||
|
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
|
||||||
|
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
|
||||||
|
sessionStorage.setItem('username', state.username);
|
||||||
|
sessionStorage.setItem('salt', state.salt);
|
||||||
|
if (state.token) sessionStorage.setItem('authToken', state.token);
|
||||||
|
if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf);
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw', base64ToBytes(parsed.key),
|
||||||
|
{ name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
|
||||||
|
await persistCryptoKey();
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.locked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RECOVERY KEY — one-shot emergency access
|
// RECOVERY KEY — one-shot emergency access
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -2788,6 +2971,15 @@ async function doChangeMasterPassword() {
|
|||||||
state.entries[i].totp_iv = nc.totp_iv || null;
|
state.entries[i].totp_iv = nc.totp_iv || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DPAPI quick-unlock blob (if any) still holds the OLD AES key
|
||||||
|
// bundle, which would unlock to entries encrypted with the new key
|
||||||
|
// → unreadable. Clear it; user can re-enable from Settings.
|
||||||
|
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
|
||||||
|
window.location.href = 'cmd://quickunlock/clear';
|
||||||
|
localStorage.removeItem('quickUnlockEnabled');
|
||||||
|
state.quickUnlockEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
closeChangeMasterModal();
|
closeChangeMasterModal();
|
||||||
toast('Master password changed · other sessions signed out');
|
toast('Master password changed · other sessions signed out');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3296,6 +3488,23 @@ function openSettings() {
|
|||||||
$('#settingUser').textContent = state.username;
|
$('#settingUser').textContent = state.username;
|
||||||
// Async: query server for recovery key state and update the label
|
// Async: query server for recovery key state and update the label
|
||||||
refreshRecoveryStatus();
|
refreshRecoveryStatus();
|
||||||
|
|
||||||
|
// Sync the quick-unlock UI from the actual DPAPI file (in case the
|
||||||
|
// user cleared the file outside the app or the file is gone for some
|
||||||
|
// other reason like a Windows profile reset).
|
||||||
|
if (Bridge.active) {
|
||||||
|
bridgeQuickUnlockStatus().then(actual => {
|
||||||
|
if (!actual && state.quickUnlockEnabled) {
|
||||||
|
// Drift: localStorage said enabled but Delphi has no blob.
|
||||||
|
localStorage.removeItem('quickUnlockEnabled');
|
||||||
|
state.quickUnlockEnabled = false;
|
||||||
|
}
|
||||||
|
updateQuickUnlockUI();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateQuickUnlockUI();
|
||||||
|
}
|
||||||
|
|
||||||
$('#settingsPanel').classList.add('is-open');
|
$('#settingsPanel').classList.add('is-open');
|
||||||
}
|
}
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
@@ -3594,6 +3803,12 @@ async function init() {
|
|||||||
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
|
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
|
||||||
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
|
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
|
||||||
|
|
||||||
|
// Quick unlock (DPAPI)
|
||||||
|
$('#quickUnlockToggleBtn').addEventListener('click', () => {
|
||||||
|
if (state.quickUnlockEnabled) disableQuickUnlock();
|
||||||
|
else enableQuickUnlock();
|
||||||
|
});
|
||||||
|
|
||||||
// Re-auth modal
|
// Re-auth modal
|
||||||
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
|
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
|
||||||
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
|
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
|
||||||
@@ -3639,13 +3854,26 @@ async function init() {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
await enterApp();
|
await enterApp();
|
||||||
} else {
|
} else {
|
||||||
// session token exists but crypto key gone — user must re-enter master pw
|
// sessionStorage still has token+salt but the crypto key was
|
||||||
|
// cleared (locked / tab closed). Try the DPAPI quick-unlock
|
||||||
|
// path before falling back to the master-pw prompt.
|
||||||
|
if (await tryQuickUnlock()) {
|
||||||
|
await enterApp();
|
||||||
|
} else {
|
||||||
showAuth();
|
showAuth();
|
||||||
$('#loginUsername').value = state.username;
|
$('#loginUsername').value = state.username;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cold start: no sessionStorage at all. Quick unlock can still
|
||||||
|
// restore everything (token + salt + key + username) from the
|
||||||
|
// DPAPI blob.
|
||||||
|
if (await tryQuickUnlock()) {
|
||||||
|
await enterApp();
|
||||||
} else {
|
} else {
|
||||||
showAuth();
|
showAuth();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
Reference in New Issue
Block a user