diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr
index ce0995e..572e081 100644
--- a/delphi-backend/PMServer.dpr
+++ b/delphi-backend/PMServer.dpr
@@ -15,6 +15,7 @@ uses
PM.Session in 'Source\PM.Session.pas',
PM.HTTPServer in 'Source\PM.HTTPServer.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.Auth in 'Handlers\PM.Handler.Auth.pas',
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj
index 293110f..4dff459 100644
--- a/delphi-backend/PMServer.dproj
+++ b/delphi-backend/PMServer.dproj
@@ -214,6 +214,7 @@ $(PreBuildEvent)]]>
+
diff --git a/delphi-backend/Source/PM.QuickUnlock.pas b/delphi-backend/Source/PM.QuickUnlock.pas
new file mode 100644
index 0000000..56296ca
--- /dev/null
+++ b/delphi-backend/Source/PM.QuickUnlock.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.
diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas
index 239b257..d25a492 100644
--- a/delphi-backend/UMainForm.pas
+++ b/delphi-backend/UMainForm.pas
@@ -9,7 +9,7 @@ uses
FMX.Dialogs,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
- PM.HTTPServer, PM.Bridge;
+ PM.HTTPServer, PM.Bridge, PM.QuickUnlock;
type
TMainForm = class(TForm)
@@ -279,6 +279,59 @@ begin
LogLine('Clipboard cleared by JS request');
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
LogLine('Bridge: unknown command "' + ACmd + '"');
end;
diff --git a/index.html b/index.html
index 8c3018f..caf3f49 100644
--- a/index.html
+++ b/index.html
@@ -378,6 +378,22 @@
+
+
Quick unlock
+
+ Disabled.
+
+
+ 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.
+
+
+
+
Recovery key
diff --git a/js/app.js b/js/app.js
index 8ccb813..57cabb9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -85,6 +85,8 @@ const state = {
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
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() {
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();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
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
// ============================================================
@@ -2788,6 +2971,15 @@ async function doChangeMasterPassword() {
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();
toast('Master password changed · other sessions signed out');
} catch (err) {
@@ -3296,6 +3488,23 @@ function openSettings() {
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
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');
}
function closeSettings() {
@@ -3594,6 +3803,12 @@ async function init() {
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
+ // Quick unlock (DPAPI)
+ $('#quickUnlockToggleBtn').addEventListener('click', () => {
+ if (state.quickUnlockEnabled) disableQuickUnlock();
+ else enableQuickUnlock();
+ });
+
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
@@ -3639,12 +3854,25 @@ async function init() {
if (ok) {
await enterApp();
} else {
- // session token exists but crypto key gone — user must re-enter master pw
- showAuth();
- $('#loginUsername').value = state.username;
+ // 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();
+ $('#loginUsername').value = state.username;
+ }
}
} else {
- showAuth();
+ // 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 {
+ showAuth();
+ }
}
}