749dc87058f574110cd0125b216194c3365820a5
1 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
749dc87058 |
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.
|