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(); + } } }