MFA, Step-Up And Device Approvals (Design Note)
Approved design for multi-factor sign-in, step-up re-authentication and device approvals in Vaulthalla (#325). A specification, not an implementation.
MFA, Step-Up And Device Approvals
Design for #325, approved (PR #343, 2026-11-05). Binding inputs: the three maintainer comments on #325 (the design review, the 1Password/WebAuthn addendum and the Desktop clarification) and the maintainer's decisions on PR #343. Section 1 maps each of them to the part of the design that encodes it, and section 14 records the decisions. The work is split into the issues in section 13.
This is a specification. Approving it is not a claim that any of it is implemented or validated at runtime. Until the issues in section 13 land with their tests, Desktop interop tests and the recorded browser demos (with and without 1Password), Vaulthalla has no MFA, and nothing may describe it as having MFA.
1. Decisions At A Glance
| Maintainer requirement (#325 comments, PR #343 decisions) | Decision in this design |
|---|---|
| Password login must not get a full session until required MFA succeeds | A pending-MFA state that is not a signed-in session: no session->user, no access token, no refresh-token row, and a five-command allowlist (section 5.2). |
| Passkeys first, standard WebAuthn, no 1Password SDK | WebAuthn Level 3 with userVerification: "required" everywhere; 1Password, Apple Passwords, Windows Hello, Google Password Manager and FIDO2 keys all go through the same code path (section 4). |
| TOTP as a full fallback; no SMS or email OTP | RFC 6238 TOTP, sealed seeds, replay-protected. No SMS, email OTP or email reset path exists or is added. |
| Recovery codes: random, individually hashed, one-time, shown once, acknowledged | Ten 50-bit codes, keyed hash per code, a batch is inactive until acknowledged (section 4.3). |
| Step-up is fresh, scoped, short-lived, bound to user/session/action, never implied by a refresh token, open session or mTLS | Step-up grants: in memory, bound to session, refresh jti, user, action family and (for the riskiest families) the exact target; 5 minutes; single-use where target-bound (section 5.4). |
| Desktop routine sync never prompts; no WebAuthn/cookies on device connects | Device sessions keep mTLS-only authentication and never see step_up_required for routine commands. A contract test enumerates them (section 6). |
No client-supplied biometric_ok / touch_id flag |
Every approval is a signature the server verifies: a WebAuthn assertion with the UV flag, or an action-bound signature by a registered Secure Enclave approval key (section 6.2). |
| 1Password per-vault key backup must not become a prompt per backup | A narrow, revocable, device-bound key-backup grant, created once by a deliberate approval (section 6.4). Existing Desktops keep backing up through migrated grants that carry exactly their current authority (D1). |
| No forever-authorized, general-purpose key-export channel | A grant covers named vaults on one device, is capped, audited per use and revoked by the triggers in section 6.4. Exports outside it always need a target-bound step-up |
| Refresh cookies, pairing and recovery never bypass enrollment | Policy is checked against the refresh row on every use (section 5.3). Pairing needs device.trust. Recovery ends in enrollment plus a cooldown (section 7.4) |
| Recovery without a one-click takeover; email reset never bypasses MFA | Recovery codes need the password too; recovery sign-in revokes other sessions, starts a cooldown on sensitive actions and notifies (section 7.4). |
| Required admin enrollment with a guided, recoverable first run | Policy admins by default: password-OK admins without a factor land in a pending-enrollment state that can only enroll (section 7.1). |
| Design note with a state diagram and walkthroughs | Sections 5.1 and 8. |
| Browser demo with and without 1Password; Desktop interop tests; an auditable recovery path | Sections 11 and 12, required by the implementation issues. Every MFA, step-up, approval, grant and recovery event goes to the tamper-evident audit trail (#323, section 7.5). |
2. Where The Code Is Today
| Area | Today | File |
|---|---|---|
| Password sign-in | Argon2id verify, uniform failure (#321), then setAuthenticatedUser + promote() straight to a full session |
core/src/auth/Manager.cpp:98-122 |
| Session promotion | Stores the socket's refresh token as a refresh_tokens row, issues the access token, starts the idle clock; adopts an existing row when the jti already has one |
core/src/auth/session/Manager.cpp:163-215 (row adoption :189-195) |
| Token minting | HS256 JWTs: random jti, token_kind, subject ip:user-agent; refresh rows store sha256: digests (#171) |
core/src/auth/session/Issuer.cpp:51-122, :181-183 |
| Refresh cookie | Set only on the ws handshake of an anonymous socket, which gets a fresh refresh JWT with no row | core/src/protocols/ws/Session.cpp:332-351, :355-378; core/include/protocols/ws/RefreshCookie.hpp:11-16 |
| Rehydration | Every cookie use reads the stored row (revocation, owner, authorizing()), cached 30 s per jti and policy epoch |
core/src/auth/session/Manager.cpp:56-86, :469-506 |
| Router gate | Exact per-mode allowlists; only auth.login/logout/refresh/isAuthenticated skip token validation |
core/src/protocols/ws/Router.cpp:38-46, :248-294, :315-335 |
| Idle sign-out (#319) | refresh_tokens.last_activity_at (migration 116), server clock, human web sessions only |
core/include/auth/session/Idle.hpp:7-27, core/src/auth/session/Idle.cpp:14-17, Manager.cpp:571-602 |
| Login throttling | auth.login: 10/min per IP + account, 30/15 min sustained; failures only |
core/src/protocols/ws/ShareRateLimit.cpp:16-23, :159-177 |
| Device sessions | TLS 1.3 client key authorized once at the handshake; no tokens; validate() is true while the user may sign in |
core/src/auth/session/Manager.cpp:432-436; .claude/context/architecture.md "Device listener and pairing" |
| Device pairing | Windows, commit-then-reveal requests, number matching, approve inserts the devices row |
core/src/ops/Devices.cpp:119-198 |
| Key export (Desktop decision D4) | Bytes only to the CLI or a paired device; console sessions get export_requires_device |
core/src/ops/VaultKeys.cpp:77-81 |
| CLI | Unix socket, peer credentials (UID in the vaulthalla group, UID-to-user mapping) |
core/src/protocols/shell/Server.cpp:235-245 |
| Hashed one-time codes | Share email challenge (#336): domain-separated keyed hash, constant-time compare, attempts, single use | core/src/share/EmailChallenge.cpp:60-81 |
| Sealed server secrets | crypto::secrets::Manager over the TPM2 / Secure Enclave master; seal-and-read-back, fail closed, never regenerate (#338) |
core/src/share/Token.cpp:55-105, core/include/crypto/secrets/Manager.hpp |
| Console login | auth.login then auth.refresh on every load; access token in memory only |
web/src/lib/session.ts:22-61, web/src/features/auth/LoginForm.tsx, web/src/components/shell/SessionGate.tsx |
| Audit trail (#323) | audit::record(Event, message): structured, hash-chained audit_event rows with signed heads; `vh audit list |
verify |
Two facts shape the design:
- The refresh JWT is minted at the handshake, before anyone signs in. Its claims can't say how the session was authenticated. The stored row can, and every use of the cookie already reads it.
- Everything that grants authority keys off
session->user(router,validate(), HTTP lanes, idle). A pending principal that never touchessession->useris refused everywhere by construction.
3. Threat Model
What MFA and step-up defend
| Threat | Today | With this design | Residual |
|---|---|---|---|
| Password reuse / credential stuffing | Rate limit only | A correct password reaches pending-MFA, which can do nothing but finish or cancel | A correct password is confirmed to the attacker by the mfa_required answer (rate-limited, notified after repeated factor failures) |
| Phishing (real-time proxy) | Full takeover | Passkeys are origin-bound and don't work on the phishing origin. TOTP and recovery codes are phishable | Admins can be held to WebAuthn only (admin_webauthn_only) |
| Stolen refresh cookie | Full session for up to refresh_token_expiry_days |
The thief gets the session's level but no step-up: no key export, device approval, privilege, secret or factor change | Ordinary data access as the user until idle sign-out, revocation or expiry. Rotation on use stays tracked separately in #325 |
| Stolen, unlocked Desktop | Routine access, key export, approving devices | Routine access continues (by design). Key export outside the backup grant and approving devices need a Secure Enclave approval (Touch ID or the Mac password) or a console approval | A grant's vaults remain exportable from that Mac until the device is revoked |
| Rogue device pairing | Owner approves in a signed-in session | Approval needs a fresh UV assertion, password+TOTP, or an existing device's signed approval, each bound to the request, the new key and the chosen code | Local CLI pairing stays a host-account action |
| Session fixation | n/a | A refresh row is written once per jti, only at completion, and never adopted (section 5.3) |
None known |
| Lost factor leading to admin lockout | n/a | Two-factor enrollment nudges, recovery codes, admin reset, CLI reset on the host | A host operator with CLI access can reset anyone's MFA (intended) |
What it does not defend
- The host. Root on the daemon host can read memory, the sealed secrets once unsealed, and the database. The CLI is trusted as the host account (section 5.6).
- Malware in a signed-in browser. It can do what the user can do without step-up, and it can trigger a step-up prompt the user may approve. WebAuthn signs the challenge, not the dialog text: the console shows the action, but the authenticator can't.
- An authorized insider. Admin MFA resets are audited and step-up protected, not prevented.
- Trust-on-first-use enrollment. Whoever first completes enrollment with a correct password owns that factor. The generated super-admin password (0600 file) and enrollment notifications narrow this; nothing removes it.
- What a device does with keys it receives. The server can't prove Desktop put an exported key into 1Password (section 6.4).
HIPAA
45 CFR §164.312(d) (person or entity authentication) requires procedures to verify that a person seeking access to
ePHI is who they claim to be. It doesn't name MFA. The January 2025 Security Rule NPRM proposes making MFA required;
check its status before quoting it. This design gives a covered entity MFA, admin enforcement (policy all for a
compliance mode), step-up for privileged actions and audit events (useful for §164.312(b)). Vaulthalla doesn't make
an operator compliant, and the docs must not say it does.
4. Factors
4.1 WebAuthn and passkeys (primary)
Verifier. An in-tree verifier in core/src/auth/webauthn/ on libcbor (CBOR, Ubuntu libcbor-dev, Homebrew
libcbor) and the OpenSSL 3 and libsodium already linked (core/meson.build:156, :170). It handles
registration (none and packed self attestation) and assertions, with an interface clean enough to extract later
as a vl-* library like vl-sigv4 (decision D5).
The verifier is acceptable only when all of these hold:
- every check in the table below is implemented;
- the published W3C WebAuthn L3 test vectors pass;
- every check has a negative test;
- CBOR, COSE and
authenticatorDataare parsed strictly (definite lengths, no trailing bytes, no duplicate map keys, bounded nesting and sizes); - libFuzzer targets for the CBOR/COSE,
authenticatorDataandclientDataJSONparsers run in CI.
The verifier invents no cryptography and skips no WebAuthn check to simplify packaging. If a maintained verifier turns out to carry measurably lower risk, the choice is reopened.
Considered and not chosen:
- libfido2 (Yubico): pam-u2f uses it as a verifier, but it's a client library that links a HID stack into a
network daemon. It doesn't check
clientDataJSONor BE/BS, so most checks would be ours anyway. - A new
vl-webauthnlibrary from day one. Cleaner reuse (Apthalla could use it), but more release overhead before the first user.
Exact checks, in order. Any failure refuses with no detail to the client; the reason goes to the auth log.
| # | Check | Registration | Assertion |
|---|---|---|---|
| 1 | Challenge record: looked up by value (constant time), unexpired (120 s), unused, same ws session uuid, same purpose (register, login, mfa, stepup, approval), same user (except discoverable login), same family/target for step-up. Marked used before the signature check, so a failed attempt burns it |
yes | yes |
| 2 | clientDataJSON is UTF-8 JSON ≤ 4 KiB; type is webauthn.create / webauthn.get |
yes | yes |
| 3 | challenge (base64url) equals the record's 32 bytes |
yes | yes |
| 4 | origin is exactly one of auth.webauthn.origins (configured, never read from Host, Origin or forwarded headers) |
yes | yes |
| 5 | crossOrigin absent or false; topOrigin absent |
yes | yes |
| 6 | authenticatorData ≥ 37 bytes; rpIdHash = SHA-256(auth.webauthn.rp_id) |
yes | yes |
| 7 | Flags: UP = 1 and UV = 1 (every ceremony requests and requires UV) | yes | yes |
| 8 | AT = 1 with attested credential data; credential id 16..1023 bytes and not already registered to anyone | yes | no |
| 9 | COSE key: alg ∈ {-8 EdDSA/Ed25519, -7 ES256/P-256, -257 RS256}, kty/crv consistent with alg; P-256 point on the curve; Ed25519 32-byte key; RSA modulus ≥ 2048 bits; no trailing CBOR bytes; nesting and sizes bounded |
yes | no |
| 10 | Attestation: fmt none accepted; packed without x5c (self) has its signature verified with the credential key; any other fmt is recorded and treated as none. No trust chain, no MDS |
yes | no |
| 11 | Credential lookup by id: exists, not disabled or suspended, rp_id equals the configured one, owner equals the expected user; discoverable login: userHandle equals that owner's webauthn_user_handle |
no | yes |
| 12 | Signature over authenticatorData ‖ SHA-256(clientDataJSON) with the stored key (ES256: DER ECDSA via EVP; EdDSA: crypto_sign_verify_detached; RS256: RSASSA-PKCS1-v1_5/SHA-256 via EVP) |
packed self only | yes |
| 13 | BE (backup eligible) equals the stored value (BE never changes for a credential); BS (backup state) stored as reported | stored | compared |
| 14 | signCount, below |
stored | compared |
signCount (backup-aware). Synced passkeys (1Password, Apple, Google) report 0 forever and must never be refused for it.
| Stored | Received | BE | Result |
|---|---|---|---|
| 0 | 0 | any | accept |
| n | > n | any | accept, store |
| n > 0 | ≤ n | 1 (synced) | accept, audit auth.mfa.webauthn.counter_regression |
| n > 0 | ≤ n | 0 (device-bound) | refuse, suspend the credential (possible clone), audit and notify. The user removes it or an admin clears the suspension |
Ceremony options. Registration: attestation: "none", authenticatorSelection: {residentKey: "preferred", userVerification: "required"}, pubKeyCredParams in the order -8, -7, -257, excludeCredentials = the user's
credentials, extensions: {credProps: true}, user.id = a random 32-byte per-user handle (never the numeric id or
the name). Assertions: userVerification: "required", allowCredentials = the user's credentials (password+passkey
and step-up) or empty (discoverable sign-in). Shapes are the WebAuthn L3 JSON forms
(PublicKeyCredential.parseCreationOptionsFromJSON / toJSON(), base64url), with a small polyfill in the console.
RP ID and origins. auth.webauthn.rp_id (a host name, never an IP address: WebAuthn refuses IPs) and
auth.webauthn.origins (exact https://host[:port] strings, plus http://localhost:<port> for development) are
explicit config set in Settings → Security or config.yaml. Nothing is derived from request headers at runtime,
so a misconfigured reverse proxy can't widen them. Without an rp_id, passkeys are unavailable and the console says
why (TOTP still works). Each credential stores the rp_id it was made for; changing rp_id makes those credentials
unusable (kept, marked, never deleted), so the settings form warns and needs step-up.
Discoverable sign-in vs password + passkey are separate ceremonies with separate challenge purposes (login vs
mfa). A login assertion must carry a userHandle; an mfa assertion must match the user whose password was just
verified. A UV passkey alone is AAL2 (possession plus the authenticator's local PIN or biometric).
4.2 TOTP (fallback)
- RFC 6238, HMAC-SHA1, 6 digits, 30 s period, the parameters every authenticator app (1Password, Google Authenticator, Aegis) accepts. 20-byte CSPRNG secret, base32.
- Sealed at rest: AES-256-GCM under a 32-byte
mfa_sealkey held incrypto::secrets::Manager(the TPM2 / Secure Enclave master), created with the share pepper's seal-and-read-back (share/Token.cpp:55-60). AAD ="vh/mfa/totp/v1" ‖ user_id ‖ factor_uuid, so a row can't be moved to another user. Ifmfa_sealcan't be unsealed, TOTP fails closed and is never regenerated (compatibility policy rule 3). Passkeys and the CLI reset still work.mfa_sealjoins thevh secrets exportset. - Enrollment:
startreturns the secret andotpauth://URI once (the console renders the QR locally). The factor stays unconfirmed (and unusable) until a correct code arrives within 10 minutes; unconfirmed rows are purged. - Verification: window ±1 step; the accepted step must be greater than the factor's
last_step, written by one conditionalUPDATE … WHERE last_step < $step(replay within the window and concurrent double use both fail); constant-time compare. Codes, secrets and URIs are added toLogRedactionand never logged or sent to telemetry.
4.3 Recovery codes
- Ten codes per batch, each 10 Crockford base32 characters shown as
XXXXX-XXXXX(50 bits). - Stored as keyed BLAKE2b-256 under a sealed
mfa_pepper(domain"vh/mfa/recovery/v1" ‖ user_id), one row per code. High entropy plus a sealed key makes a slow hash unnecessary (the #171 reasoning). Verification is oneUPDATE … SET used_at = NOW() WHERE user_id = $1 AND code_hash = $2 AND used_at IS NULL RETURNING id: single use, race-free. - Shown once. A new batch is inactive until acknowledged (
auth.mfa.recovery.acknowledge); generating a batch supersedes the previous one on acknowledgement. Generating after the first batch needs step-up. - A recovery code is used only with the password, only at sign-in, and never satisfies step-up.
4.4 1Password and other passkey managers (factor holders)
1Password gets no special code. As a holder it's one of:
| Holds | How it shows up | Notes |
|---|---|---|
| A passkey | A synced WebAuthn credential: BE = 1, BS = 1, signCount 0, AAGUID 1Password's |
UV comes from 1Password's own unlock (Touch ID, Windows Hello, or the account password when the extension runs without the desktop app). The server sees only the UV flag, as with any authenticator |
| A TOTP seed | A standard authenticator (scan the QR or paste the URI) | Phishable like any TOTP |
| Recovery codes | A secure note | Keeping recovery codes in the same vault as the passkey makes them one point of failure. The enrollment screen recommends storing them somewhere else |
Factor backup. The console encourages a second factor before MFA is enforced: a passkey in 1Password (synced across devices) plus a device-bound one (a security key or a platform passkey), or TOTP. The wording is "Passkey / Security key", with the optional hint "Works with 1Password, your device's passkey manager, and security keys". No 1Password account is required or advertised.
Administrators who need device possession set auth.mfa.admin_device_bound_only: true: admin step-up and sign-in
then accept only BE = 0 credentials (hardware keys, non-synced platform credentials). Off by default.
The Desktop key-backup grant, the other "1Password backup", is about vault keys, not factors (section 6.4).
5. Sessions And Assurance
5.1 State diagram
Web console (human) sessions:
1stateDiagram-v22 direction LR3 [*] --> Anonymous: ws handshake, fresh refresh JWT, no row4 Anonymous --> PendingMfa: auth.login ok and factors enrolled5 Anonymous --> PendingEnrollment: auth.login ok, MFA required, no factor6 Anonymous --> AAL1: auth.login ok, MFA not required, no factor7 Anonymous --> AAL2: auth.passkey.login with UV8 PendingMfa --> AAL2: auth.mfa.verify with passkey or TOTP9 PendingMfa --> RecoveryRestricted: auth.mfa.verify with recovery code10 PendingEnrollment --> AAL2: factor enrolled and codes acknowledged11 PendingMfa --> Anonymous: cancel, 5 min, 5 failures, socket closed12 PendingEnrollment --> Anonymous: cancel, 10 min, socket closed13 AAL2 --> AAL2: step-up grant (scoped, 5 min)14 AAL1 --> AAL1: step-up by password (D3)15 RecoveryRestricted --> AAL2: cooldown elapsed16 AAL1 --> Ended: logout, idle, revoke, expiry, policy raised17 AAL2 --> Ended: logout, idle, revoke, expiry18 RecoveryRestricted --> Ended: logout, idle, revoke, expiry19 Ended --> [*]
Device listener sessions (Vaulthalla Desktop):
1stateDiagram-v22 direction LR3 [*] --> PairingOnly: TLS handshake with an unknown key4 PairingOnly --> Device: owner approves the request (step-up or device approval)5 [*] --> Device: TLS handshake with a paired key6 Device --> Device: routine sync, browsing, transfers, MCP reads (never prompts)7 Device --> Device: key export inside a key-backup grant (no prompt)8 Device --> DeviceApproved: approval-key signature or console approval9 DeviceApproved --> Device: grant used or expired10 Device --> [*]: device revoked, user deactivated, remote turned off
Levels are written on the refresh row and in memory: aal 1 or 2, amr (methods: pwd, hwk/swk for device-bound
or synced passkeys, otp, rec) and auth_time. Device sessions have no level: they're device and never use
aal checks (section 6).
5.2 Pending-MFA
A password-verified session that still owes a factor:
- Not signed in.
loginUserdoesn't callsetAuthenticatedUserorpromote()when MFA is due. It stores aPendingAuth {user_id, kind: mfa|enrollment, methods, deadline, attempts, challenge}on the ws session.session->userstays null, so every existing gate (router allowlists,validate(), HTTP lanes, idle, RBAC) refuses by construction. - No authority in any token. No access token is issued. The cookie's refresh JWT (minted at this socket's
handshake) has no row until completion, so
auth.refresh, a reconnect, another tab or an HTTP lane presenting it find nothing and fail like an unknown token (Manager.cpp:56-62). - Allowlist (new router mode, exact):
auth.mfa.verify,auth.mfa.cancel,auth.logout, plus the enrollment commands whilekind = enrollment(auth.mfa.webauthn.register.*,auth.mfa.totp.enroll.*,auth.mfa.recovery.acknowledge). Anything else is UNAUTHORIZED, includingauth.login(no second password attempt on a pending socket) andmeta.hello. - Bounded. 5 minutes (10 for enrollment), 5 factor failures, or the socket closing ends it, and the user starts again with the password. It's in memory and bound to the socket, so it doesn't survive a reconnect or a daemon restart, which closes the reconnect-bypass class.
5.3 Completion, refresh tokens, auth_time
On completion (auth.mfa.verify, auth.passkey.login, finished enrollment, or a password login that needs no
factor):
setAuthenticatedUserandpromote()write the row once:INSERTwithaal,amr,auth_time = NOW(). The "existing row" branch (Manager.cpp:189-195) is removed: ajtithat already has a row is never promoted again, andauth.loginon a socket that already has a user is refused (already_signed_in). The row is new and its JWT was minted by this server for this socket's handshake, so a planted cookie can't become an authenticated session (fixation regression test).- The access token is issued in the response envelope as today.
- Every later use of the cookie reads the row (rehydrate,
validate, HTTP lanes, which already happens) and also checks policy against the row: if MFA is required for the user and the row'saalis 1, the row is revoked and the session refused. This is how a policy raise, an upgrade or an admin "require MFA" takes effect: at next use, not by trusting claims. The 30 s validation cache (Manager.cpp:483-498) is keyed torbac::policyEpoch(); MFA policy changes and factor resets bump it.
The JWT carries no level. It's minted before anyone signs in, and the row is the authority anyway.
5.4 Step-up
Families (from the ws command map). Enforced in the ops layer through a thread-local auth::stepup::Scope set by
the router per request (like DevicePrincipalScope and AgentCallScope), so the CLI, ws and /mcp paths share one
check and payload-dependent cases (an auth.user.update that changes a role) are judged where the mutation happens.
The check runs after validation and typed confirmations, immediately before the mutation, so the person approves
exactly what will run.
| Family | Commands | Grant |
|---|---|---|
keys |
vault.keys.export (outside a key-backup grant), vault.keys.import |
target-bound (vault_id or all), single use |
device.trust |
device.pairing.approve, device.remote.enable, device.backup.grant.create / .extend |
target-bound (request id + new SPKI + chosen code; grant id), single use |
account.credentials |
auth.user.change_password (own or another's), auth.mfa.factor.remove, a second or later auth.mfa.webauthn.register.* / auth.mfa.totp.enroll.*, auth.mfa.recovery.generate (after the first), auth.mfa.admin.reset |
family, 5 min |
identity.admin |
auth.register, auth.user.update (role, is_active, or another user), auth.user.delete, role.admin.add/update/delete, role.vault.add/update/delete/assign/unassign, role.vault.overrides.*, group.member.add/remove, group.remove |
family, 5 min |
secrets |
storage.apiKey.add/update/remove, email.provider.secret.set, s3.gateway.credentials.create/scope.update/roles.*/defaultRole.*, upstream.admin.enable |
family, 5 min |
settings |
settings.update (any section: auth, sharing (share policy), websocket_server, device_server, email, ...) |
family, 5 min |
data.destroy |
storage.vault.remove, recovery.point.delete, vault.recovery.suspend, upstream.bucket.cleanup/unprotect, a destructive upstream.bucket.lifecycle.set |
target-bound, single use |
Not stepped up: reads, file operations, uploads and downloads, share links, device.pairing.open (a window grants
nothing by itself), device.revoke and share.link.revoke (revocation makes things safer), device.identity.rotate
(proven by the old key), vault.keys.export.confirm (a record, no bytes). vh secrets export and vh vault keys export stay CLI-only and are exempt as host actions (section 5.6).
Satisfying step-up (web console):
- a WebAuthn assertion with UV,
allowCredentials= the user's credentials, purposestepup, bound to family and target; or - password + TOTP together (a TOTP code alone is never enough, matching "password + approved factor"); or
- for a user with no factor under policy
optional: the password alone, except forkeys,device.trustanddata.destroy, which always need a factor (decision D3). The server enforces this inauth::stepup, not the console; - a fresh sign-in counts for family grants (not target-bound ones) when the row is
aal2 andauth_timeis within the step-up window, so enrolling a second passkey right after signing in doesn't prompt twice.
Recovery codes, a refresh token, an open session and Desktop mTLS never satisfy step-up.
Grant record (memory only): {user_id, session_uuid, refresh_jti, family, target?, expires_at, uses_left}. Dropped
with the session (logout, idle, revocation, restart). A grant from one session is never visible to another. Five
failed step-ups in 15 minutes revoke the session (a session guessing codes is likely stolen) and notify.
Refusal (additive error code): status ERROR, data:
1{2 "code": "step_up_required",3 "family": "identity.admin",4 "target": null,5 "methods": ["webauthn", "password_totp"],6 "max_age_seconds": 3007}
The console treats it like needs_confirmation: the Step-up dialog names the action, runs auth.stepup.begin and
auth.stepup.finish, and resends the original request.
Agents: /mcp and vh mcp calls with agent: true can't produce a step-up. On /mcp, the device's Desktop may
satisfy it with an approval-key signature after local user verification (section 6.2), never automatically.
5.5 Idle sign-out (#319)
- Idle sign-out applies as today to AAL1, AAL2 and recovery-restricted sessions. It revokes the row, which also drops any step-up grants.
- Pending states have shorter deadlines (5 and 10 minutes) and no row to touch.
- Step-up and MFA ceremonies are user input: the console's activity reporter already sends
auth.activity, andauth.mfa.verify/auth.stepup.finishalso touchlast_activity_atserver-side. - Device and share sessions stay exempt (
Idle.cpp:14-17). auth_timeis never refreshed by activity. It records the last full sign-in and drives step-up freshness and the session list.
5.6 Exempt surfaces
| Surface | Why it's exempt | What still applies |
|---|---|---|
| CLI over the unix socket | Authenticated by kernel peer credentials (UID in the vaulthalla group, UID-to-user mapping); the host account is the trust anchor, and anyone with it can already read local state |
RBAC, audit, typed confirmations; vh user mfa reset is the documented host-level recovery |
| Desktop device sessions | Authenticated by the paired Secure Enclave key over TLS 1.3 at every connection; routine sync must never prompt | Sensitive families need a device approval or a console approval (section 6) |
| Share sessions | Not accounts; their own grants and email challenge (#336) | Share rate limits |
| S3 gateway | SigV4 credentials, not people | Minting credentials is secrets step-up |
6. Desktop And Device Approvals
6.1 Device authentication is not human authorization
The mTLS key proves which paired device is connecting, not that a person is present. The key stays the only thing
routine traffic uses, with no change to connect, reconnect, the file provider, transfers, /mcp reads or low-risk
device commands. Fresh human authorization is a separate, explicit, cryptographic artifact, needed only for the
families in section 5.4.
6.2 Device approval key
-
A second Secure Enclave P-256 key per Desktop, separate from the mTLS key (which must work unattended). Created with
SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage | .userPresence), so every signature needs Touch ID or the Mac password. -
Registered at pairing: the pairing request carries
approval_key(SPKI DER, base64), shown with the request and approved with it (adocs/pairing.mdchange in vaulthalla-desktop). Already-paired devices register it once through a console approval (section 6.3). -
What the server verifies: an ECDSA P-256/SHA-256 signature by the registered key over
1"vh-device-approval-v1" 0x00 node_id 0x00 server_spki_sha256[32] approving_device_spki_sha256[32]2challenge[32] family 0x00 target 0x00 UInt64BE(expires_at)with a server challenge (
device.approval.challenge) that is single use, expires in 120 s and is bound to the device session, family and target. -
What it doesn't verify: that the key's access control really requires user presence. macOS gives the server no attestation of that. The key is trusted because a human registered it through a step-up-protected action, it's non-exportable, and every signature is bound to one action. That's weaker than a WebAuthn UV flag, so an approval key is accepted only for
device.trust,keysand the key-backup grant (decision D6). Every other privileged family needs console WebAuthn: a console approval (section 6.3) or doing the action in the console. -
Never human presence: a routine mTLS connection, a Desktop reporting that the OS checked a biometric, or any client flag. Only a verified signature over a fresh, single-use, server-issued challenge bound to the action, target and device session counts.
-
For a future sealed-vault (device E2EE) design: adding a device authorized for encryption keys must also need an existing device's approval. Server-side MFA alone isn't enough. The approval key is the hook for that.
6.3 Approving from another channel ("approve in the console")
For a Desktop without an approval key, or a family the key can't approve:
- The device session calls
device.approval.request {family, target, summary}and gets{approval_id, expires_at}(5 min, at most 3 open per device, rate limited). - The user's signed-in console sessions get a ws push
auth.approval.pending, and Settings → Security → Pending approvals lists it with the device name, action and target. - The person approves with a UV passkey bound to
approval_id(auth.approval.approve) or declines (auth.approval.deny). A TOTP-only user can't approve a device's request and does the action in the console instead (with password+TOTP step-up where the family allows it). - The device polls
device.approval.statusand gets a grant on its session, with the same binding rules as a web grant.
6.4 Key-backup grant (1Password backup of vault keys)
Vaulthalla Desktop backs up every needed version of each vault key to 1Password in the background
(.claude/context/key-backup.md; Desktop decision D4: key bytes only to the CLI or a paired device). Three things are kept apart:
| Act | Prompt | Mechanism |
|---|---|---|
| Turning on backup for vaults (consent) | Once, deliberate | device.backup.grant.create {vault_ids}, device.trust approval: approval key (Touch ID) or console |
| Ongoing backup: reconnects, new key versions, rotations, mid-rotation versions | Never | vault.keys.export from that device for a vault in the grant needs no step-up |
| Discretionary export (a vault outside the grant, a manual "export now" to a file) | Every time | keys step-up, target-bound |
Grant properties:
- Bound to one device row and one user; lists vault ids explicitly. A new vault is added by
device.backup.grant.extend(one approval when a vault is created, a deliberate event). - Revoked automatically when: the device is revoked; the user is deactivated or loses encryption-key Export permission; an admin resets the user's MFA; a recovery-code sign-in happens (section 7.4); the device hasn't connected for 90 days. Revocable by hand from the console and Desktop.
- Audited per use in the audit trail (
audit::record, actionkeys.backup.export, details{grant_id, device, versions}, origindevice <id16>).last_used_atis shown next to the device. Grant create, extend, confirm, suspend and revoke are audit events too (section 7.5). - After a recovery-code sign-in revokes the grant, the same device may re-create it during the cooldown with its approval key, but only if that key was registered before the recovery, and only for the vaults the revoked grant listed. That avoids a backup gap on the legitimate Mac without letting a factor enrolled during recovery widen anything (section 7.4).
- Exports via a grant return only the needed versions (
historical_keysrules unchanged) and are capped at 60 vault exports per hour per grant. Above that the grant is suspended and the user notified (a stolen Mac sweeping keys). vault.keys.export.confirmstays the device's statement that 1Password stored the key. The server records it and never treats it as proof.
Exposure: a Mac that holds a grant is a sensitive endpoint. Malware running as the logged-in user can get every granted key without a prompt. The grant makes this explicit, narrow and revocable. It doesn't remove it.
Upgrade (decision D1). Existing paired Desktops keep their automatic backup. Pausing it until consent would leave any key version created by a rotation in the gap without a backup (compatibility rule 3).
- What the migration creates. The device-approvals migration creates one
origin = 'migrated'grant per live (non-revoked) device whose user holds encryption-key Export permission at migration time. The grant covers exactly the vaults that user may export then. Nothing is created for anyone else, and no vault is added that the user couldn't export before. - Confirming. The console and Desktop show a non-blocking "Confirm or revoke automatic key backup". It never
interrupts sync, backup or rotation. Confirming takes one deliberate approval (approval key or console) and turns
the grant into
origin = 'approved'. Revoking stops backup for that device. - The same rules as any grant. Migrated grants are visible, revocable, device-bound, audited (the migration
writes one
keys.backup.grant.migrateevent per grant) and subject to the triggers above. They never widen on their own: a later vault is added only throughextend. - Verification, required by the implementation issue:
- a migration test asserts that, for every seeded device, the set of vaults it may export is identical before and after the migration (no escalation, no loss);
- a rotation run in the same test window shows that every needed version stays exportable through the grant (no backup gap).
6.5 New-device approval
device.pairing.approve needs device.trust with the target request_id ‖ new SPKI ‖ chosen code. The approval can
come from:
- the console: a UV passkey or password+TOTP;
- an already-paired Desktop:
device.pairing.pendingon its device session, then the person picks the code and Desktop signs with its approval key (Touch ID); - the local CLI (
vh device pair, including the macOS installer'svh device pair --json): exempt as a host action (section 5.6).
7. Enrollment, Policy And Recovery
7.1 Policy
1auth:2 mfa:3 policy: admins # optional | admins | all4 pending_ttl_seconds: 3005 step_up_ttl_seconds: 300 # 60..9006 recovery_cooldown_hours: 247 admin_webauthn_only: false # admins can't use TOTP (phishing-resistant only)8 admin_device_bound_only: false9 webauthn:10 rp_id: vault.example.com # a host name; never an IP11 rp_name: Vaulthalla12 origins: [https://vault.example.com]
| Policy | Who must have MFA |
|---|---|
optional |
Nobody (per-user required still applies) |
admins (default, new and upgraded installs) |
The super admin and every user whose admin role holds a privileged permission (users, roles, settings, encryption keys; the set is defined in code and published in the contract) |
all |
Every human account (the compliance mode) |
- An admin can set
requiredfor one user (user_mfa.required). - Changing policy or
auth.webauthn.*issettingsstep-up. Weakening it (tooptional, admin-only flags off, orrp_idchanges) is audited and sent tooperator_emails.recipients.security. - Upgrade effect (decision D2): under
admins, existing admin refresh rows areaal1. They are refused at next use (section 5.3) and never grandfathered into elevated authority. Those admins sign in once more and go through guided enrollment (passkey or TOTP, then recovery codes). The release fragment documents this, and the local CLI recovery (vh user mfa reset,vh setup set-super-admin-password) stays available. Device sessions aren't affected.
7.2 Enrollment
- First run (guided): password OK, MFA required, no factor → pending-enrollment. The screen offers Create passkey (recommended) or Use an authenticator app. The first factor's ceremony (UV registration, or a correct TOTP code) proves possession. Then ten recovery codes are shown and must be acknowledged, and only then does the session reach AAL2. The console then suggests a second passkey (another device or 1Password) before leaving.
- Optional users enroll in Account → Security. Their first factor doesn't need step-up (there's nothing to
step up with); later factor changes are
account.credentials. - Removal: removing the last factor while MFA is required is refused (
mfa_last_factor). Removing a factor revokes the user's other refresh rows. - Enroll, remove, rename, suspend, a new recovery batch and every reset are audit events and notify the user (email when they have one and email is configured).
7.3 Lockout and rate limits
| Limit | Value | On breach |
|---|---|---|
auth.login |
unchanged: 10/min per IP + account, 30/15 min sustained | rate_limited |
| Factor attempts per pending session | 5 | pending ends, start again with the password |
| Factor failures per account | 5 in 15 min, then a delay doubling from 1 min up to 15 min; never a permanent lock (an attacker could lock out a real user) | rate_limited with retry_after; notify the user and security recipients at 10 |
| Step-up failures per session | 5 in 15 min | the session is revoked, notify |
auth.passkey.options |
30/min per IP; at most 10,000 outstanding challenges (bounded map, oldest first) | rate_limited |
| Console approvals per device | 3 open, 10/hour | rate_limited |
7.4 Recovery
| Lost | Path | Effect |
|---|---|---|
| One factor, another remains | Sign in with the other, remove the lost one, revoke the lost Mac in Devices | Normal step-up |
| All factors, recovery codes kept | Password + recovery code → RecoveryRestricted | See below (decision D4) |
| Everything, another admin exists | auth.mfa.admin.reset {user_id, reason} by a user with users-manage permission, under account.credentials step-up; a super admin's MFA only by another super admin |
Factors disabled (kept for audit), codes voided, sessions and grants revoked, enrollment required at next sign-in, the same cooldown, notifications to the target and security recipients |
| Everything, sole super admin | vh user mfa reset <user> on the host, by the UID bound to that user or a CLI user with users-manage (root/sudo refused, like vh setup set-super-admin-password) |
As above. This is the documented self-hosted manual recovery |
| Password | Unchanged (another admin, or vh setup set-super-admin-password). A password reset never touches factors |
MFA still applies at next sign-in |
Recovery-code sign-in (decision D4) leads to RecoveryRestricted and these effects:
- Automatic at sign-in: every other refresh row of the user (all other web sessions) is revoked, and their key-backup grants are revoked. The user and the security recipients are notified.
- Required next: enroll a new factor and acknowledge new recovery codes. The number of codes remaining is shown.
- Offered, not automatic: the user's paired devices are listed with targeted revocation. A lost browser factor doesn't break a legitimate Desktop's sync, so nothing is revoked without the user choosing it.
- Cooldown: until
recovery_cooldown_hourselapse,keys,device.trust,identity.admin,secretsandsettingsare refused (recovery_cooldown). Factors enrolled during recovery are markedcreated_during_recoveryand don't count as step-up for those families until the cooldown ends. - The one exception: a device approval key registered before the recovery may re-create that device's revoked key-backup grant, for the same vaults only (section 6.4).
There is no email reset, and none is added. If one ever is, it resets only the password.
7.5 Audit
Every MFA, step-up, approval and grant change is written with audit::record (#323) to the hash-chained audit
trail. These events show in vh audit list --action auth.mfa. and are covered by vh audit verify and the signed
heads. details holds ids, methods and outcomes only: never codes, secrets, credential ids in full, assertions,
names or paths (#327).
| Action | Outcome values | Details |
|---|---|---|
auth.mfa.challenge (pending started) |
info | {methods} |
auth.mfa.verify |
success, failure, denied | {method, factor_id, attempts} |
auth.passkey.login |
success, failure | {factor_id} |
auth.mfa.factor.enroll / .remove / .rename / .suspend |
success | {factor_id, kind, during_recovery} |
auth.mfa.recovery.generate / .acknowledge / .use |
success, failure | {batch_id, remaining} |
auth.mfa.admin.reset / .require |
success, denied | {target_id, reason, channel} (actor = the admin or CLI user) |
auth.mfa.policy.change |
success | {from, to} |
auth.stepup.grant / .fail |
success, failure, denied (session revoked) | {family, target, method} |
auth.stepup.required (a command answered step_up_required) |
info, denied (agent or device) | {family, target, methods} |
device.approval.submit / auth.approval.approve / .deny |
success, failure, denied | {family, target, approval_id, device} |
keys.backup.grant.create / .extend / .confirm / .migrate / .suspend / .revoke |
success | {grant_id, device, vault_count, reason} |
keys.backup.export |
success | {grant_id, versions} (vault in vault_id) |
Auditable recovery path. A recovery is reconstructible from the trail alone: the recovery code use, the revoked
sessions and grants, factor enrollments flagged during_recovery, cooldown refusals, and any admin or CLI reset.
vh audit verify proves the trail is intact. An implementation test asserts this sequence.
8. Walkthroughs
8.1 First sign-in (admin, passkey)
1sequenceDiagram2 autonumber3 actor A as Admin4 participant B as Browser + passkey manager5 participant C as Console6 participant S as vaulthalla (ws)7 B->>S: ws handshake (no cookie)8 S-->>B: Set-Cookie refresh=R0 (no row)9 A->>C: name + generated password10 C->>S: auth.login11 S->>S: Argon2id ok, policy admins, no factor12 S-->>C: ERROR mfa_enrollment_required (pending, 10 min)13 C->>S: auth.mfa.webauthn.register.options14 S-->>C: creation options (challenge, rp, user handle, UV required)15 C->>B: navigator.credentials.create()16 B->>A: Touch ID / PIN17 B-->>C: attestation (fmt none)18 C->>S: auth.mfa.webauthn.register.finish19 S->>S: checks 1-10, store credential (BE, BS, alg)20 S-->>C: factor + recovery codes (batch inactive)21 A->>C: "I saved these codes"22 C->>S: auth.mfa.recovery.acknowledge23 S->>S: INSERT refresh row R0 (aal 2, amr hwk|swk, auth_time)24 S-->>C: user + access token25 C->>A: suggest a second passkey
8.2 Returning sign-in with TOTP
1sequenceDiagram2 autonumber3 actor U as User4 participant C as Console5 participant S as vaulthalla6 participant T as Authenticator app or 1Password7 C->>S: auth.login {name, password}8 S-->>C: ERROR mfa_required {methods [webauthn, totp, recovery_code], webauthn options}9 U->>T: read code10 C->>S: auth.mfa.verify {method totp, code}11 S->>S: unseal seed, window +-1, step > last_step (conditional UPDATE)12 alt code ok13 S->>S: INSERT refresh row (aal 2, amr pwd+otp)14 S-->>C: user + access token15 else wrong code16 S-->>C: ERROR mfa_invalid {attempts_remaining}17 end
Returning sign-in with a passkey is one step: auth.passkey.options → navigator.credentials.get() (no username) →
auth.passkey.login → AAL2.
8.3 Sensitive action: key export from Desktop outside the grant
1sequenceDiagram2 autonumber3 actor U as User4 participant D as Desktop (Secure Enclave)5 participant S as vaulthalla (device listener)6 participant C as Console7 D->>S: vault.keys.export {vault_id 7} over mTLS8 S->>S: Device channel ok, vault 7 not in grant9 S-->>D: ERROR step_up_required {family keys, target vault:7}10 alt Desktop has an approval key11 D->>S: device.approval.challenge {family keys, target vault:7}12 S-->>D: challenge (120 s, single use, this session)13 D->>U: "Export the key of vault Finance?" Touch ID14 U-->>D: Touch ID ok, SE signs bound payload15 D->>S: device.approval.submit {challenge_id, signature}16 S->>S: verify ECDSA with registered approval key, bindings17 else no approval key18 D->>S: device.approval.request {family keys, target vault:7}19 S-->>C: push auth.approval.pending20 U->>C: approve, passkey with UV21 C->>S: auth.approval.approve {approval_id, credential}22 D->>S: device.approval.status23 end24 S-->>D: grant (single use, target vault:7)25 D->>S: vault.keys.export {vault_id 7}26 S-->>D: key versions (audited, via device)
8.4 Desktop pairing: a new Mac approved from a paired Mac
1sequenceDiagram2 autonumber3 actor U as User4 participant N as New Mac (Desktop)5 participant S as vaulthalla6 participant P as Paired Mac (Desktop)7 U->>P: Add a device (opens the pairing window)8 P->>S: device.pairing.open9 N->>S: TLS with new key K2, device.pair.request {commitment, approval_key AK2}10 N->>S: device.pair.confirm {nonce}11 P->>S: device.pairing.pending12 S-->>P: request r1 (name, platform, address, three codes)13 U->>P: picks the code New Mac shows14 P->>S: device.approval.challenge {family device.trust, target r1+K2+code}15 S-->>P: challenge16 P->>U: Touch ID17 P->>S: device.approval.submit (signed by AK1)18 S->>S: verify, grant bound to r1+K2+code19 P->>S: device.pairing.approve {request_id r1, code}20 S->>S: insert device K2 with approval key AK2, audit21 N->>S: device.pair.status22 S-->>N: approved (node id, device id)
From the console the same approval is a UV passkey (or password+TOTP) bound to r1 ‖ K2 ‖ code; from the local CLI
it's vh device pair as today.
8.5 Lost device: recovery-code sign-in
1sequenceDiagram2 autonumber3 actor U as User4 participant C as Console5 participant S as vaulthalla6 participant E as Email / security recipients7 C->>S: auth.login {name, password}8 S-->>C: ERROR mfa_required9 U->>C: "Use a recovery code"10 C->>S: auth.mfa.verify {method recovery_code, code}11 S->>S: keyed hash, conditional UPDATE used_at (single use)12 S->>S: revoke other refresh rows and key-backup grants13 S->>S: INSERT row (aal 2, amr pwd+rec), cooldown 24 h14 S-->>C: user + token + recovery {codes_remaining 9, cooldown_until}15 S->>E: "Recovery code used for <user>"16 C->>U: enroll a new factor, new codes, review devices17 U->>C: revoke the lost Mac (optional)18 Note over S: keys, device.trust, identity.admin, secrets, settings refused until cooldown_until
8.6 A passkey held in 1Password
1sequenceDiagram2 autonumber3 actor U as User4 participant B1 as Browser A + 1Password5 participant B2 as Browser B + 1Password6 participant S as vaulthalla7 B1->>S: auth.mfa.webauthn.register.options8 S-->>B1: options (UV required, residentKey preferred)9 B1->>U: 1Password "Save passkey?" + unlock (Touch ID or account password)10 B1->>S: register.finish (fmt none, BE 1, BS 1, signCount 0)11 S->>S: store, amr swk12 Note over B2: another computer, same 1Password account13 B2->>S: auth.passkey.options (no username)14 S-->>B2: challenge, empty allowCredentials15 B2->>U: 1Password offers the synced passkey, unlock = UV16 B2->>S: auth.passkey.login (userHandle, signCount 0, UV 1)17 S->>S: counter 0/0 accepted (synced), checks 1-1418 S-->>B2: user + token (aal 2)
Without 1Password the same flows run with Apple Passwords, Windows Hello, Google Password Manager or a security key; nothing in the server or console differs.
9. Data Model
Migrations use the next free numbers at implementation time (117_audit_trail.sql is on main now, and other work may take more). Each issue below owns its own migration file, named by content (NNN_refresh_token_assurance.sql, NNN_mfa_factors.sql, NNN_device_approval_keys.sql, NNN_key_backup_grants.sql).
Every statement is additive and idempotent (IF NOT EXISTS), safe on upgraded installs, and covered by a
SqlDeployerHistoryDb test.
MFA core (issue 1 takes the refresh_tokens columns; issue 3 the factor tables)
1ALTER TABLE refresh_tokens2 ADD COLUMN IF NOT EXISTS aal SMALLINT NOT NULL DEFAULT 1 CHECK (aal IN (1, 2)),3 ADD COLUMN IF NOT EXISTS amr TEXT[] NOT NULL DEFAULT '{pwd}',4 ADD COLUMN IF NOT EXISTS auth_time TIMESTAMP;5UPDATE refresh_tokens SET auth_time = COALESCE(issued_at, NOW()) WHERE auth_time IS NULL;6ALTER TABLE refresh_tokens ALTER COLUMN auth_time SET DEFAULT NOW(), ALTER COLUMN auth_time SET NOT NULL;7 8CREATE TABLE IF NOT EXISTS user_mfa (9 user_id INTEGER PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,10 webauthn_user_handle BYTEA NOT NULL UNIQUE CHECK (octet_length(webauthn_user_handle) = 32),11 required BOOLEAN NOT NULL DEFAULT FALSE,12 recovery_cooldown_until TIMESTAMPTZ,13 reset_at TIMESTAMPTZ,14 reset_by INTEGER REFERENCES users (id) ON DELETE SET NULL,15 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()16);17 18CREATE TABLE IF NOT EXISTS webauthn_credentials (19 id SERIAL PRIMARY KEY,20 user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,21 credential_id BYTEA NOT NULL UNIQUE CHECK (octet_length(credential_id) BETWEEN 16 AND 1023),22 public_key_cose BYTEA NOT NULL,23 cose_alg SMALLINT NOT NULL CHECK (cose_alg IN (-8, -7, -257)),24 rp_id TEXT NOT NULL,25 aaguid UUID,26 sign_count BIGINT NOT NULL DEFAULT 0,27 backup_eligible BOOLEAN NOT NULL,28 backup_state BOOLEAN NOT NULL,29 discoverable BOOLEAN,30 transports TEXT[] NOT NULL DEFAULT '{}',31 attestation_fmt TEXT NOT NULL,32 label TEXT NOT NULL CHECK (char_length(label) BETWEEN 1 AND 64),33 created_during_recovery BOOLEAN NOT NULL DEFAULT FALSE,34 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),35 last_used_at TIMESTAMPTZ,36 suspended_at TIMESTAMPTZ,37 suspended_reason TEXT,38 disabled_at TIMESTAMPTZ39);40CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials (user_id);41 42CREATE TABLE IF NOT EXISTS totp_factors (43 id SERIAL PRIMARY KEY,44 factor_uuid UUID NOT NULL UNIQUE, -- part of the GCM AAD45 user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,46 label TEXT NOT NULL CHECK (char_length(label) BETWEEN 1 AND 64),47 secret_sealed BYTEA NOT NULL, -- AES-256-GCM under mfa_seal48 secret_iv BYTEA NOT NULL,49 last_step BIGINT NOT NULL DEFAULT 0,50 confirmed_at TIMESTAMPTZ, -- NULL: enrollment not finished, unusable51 created_during_recovery BOOLEAN NOT NULL DEFAULT FALSE,52 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),53 last_used_at TIMESTAMPTZ,54 disabled_at TIMESTAMPTZ55);56CREATE INDEX IF NOT EXISTS idx_totp_factors_user ON totp_factors (user_id);57 58CREATE TABLE IF NOT EXISTS recovery_code_batches (59 id UUID PRIMARY KEY,60 user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,61 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),62 acknowledged_at TIMESTAMPTZ,63 superseded_at TIMESTAMPTZ64);65CREATE UNIQUE INDEX IF NOT EXISTS uq_recovery_batch_active66 ON recovery_code_batches (user_id) WHERE acknowledged_at IS NOT NULL AND superseded_at IS NULL;67 68CREATE TABLE IF NOT EXISTS recovery_codes (69 id SERIAL PRIMARY KEY,70 batch_id UUID NOT NULL REFERENCES recovery_code_batches (id) ON DELETE CASCADE,71 user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,72 code_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(code_hash) = 32),73 used_at TIMESTAMPTZ,74 used_ip TEXT75);
MFA state stays out of users and out of identities::UserState: the identity revision triggers (migration 108) and
the "state never carries credentials" rule stay as they are. mfa_seal and mfa_pepper live in internal_secrets
through crypto::secrets::Manager, sealed like the JWT secret and the share pepper.
Device approvals (issue 8 takes device_approval_keys; issue 9 the grant tables)
1CREATE TABLE IF NOT EXISTS device_approval_keys (2 device_id INTEGER PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE,3 public_key_spki BYTEA NOT NULL, -- P-256 SPKI DER4 spki_sha256 TEXT NOT NULL UNIQUE CHECK (spki_sha256 ~ '^[0-9a-f]{64}$'),5 registered_via TEXT NOT NULL CHECK (registered_via IN ('pairing', 'console_approval')),6 registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),7 revoked_at TIMESTAMPTZ8);9 10CREATE TABLE IF NOT EXISTS key_backup_grants (11 id SERIAL PRIMARY KEY,12 device_id INTEGER NOT NULL REFERENCES devices (id) ON DELETE CASCADE,13 user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,14 origin TEXT NOT NULL CHECK (origin IN ('approved', 'migrated')),15 approved_via TEXT CHECK (approved_via IN ('webauthn', 'password_totp', 'device_approval')),16 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),17 confirmed_at TIMESTAMPTZ,18 last_used_at TIMESTAMPTZ,19 suspended_at TIMESTAMPTZ,20 revoked_at TIMESTAMPTZ,21 revoked_reason TEXT22);23CREATE UNIQUE INDEX IF NOT EXISTS uq_key_backup_grant_live ON key_backup_grants (device_id) WHERE revoked_at IS NULL;24 25CREATE TABLE IF NOT EXISTS key_backup_grant_vaults (26 grant_id INTEGER NOT NULL REFERENCES key_backup_grants (id) ON DELETE CASCADE,27 vault_id INTEGER NOT NULL REFERENCES vault (id) ON DELETE CASCADE,28 PRIMARY KEY (grant_id, vault_id)29);30-- D1: one 'migrated' grant per live device whose user may export at migration time, over exactly the vaults they may31-- export then (section 6.4), each with a keys.backup.grant.migrate audit event.
As built (#362, migration 130): migration 129's interim devices.key_backup_revoked_at / key_backup_revoked_reason
(#360) are carried into the D1 snapshot (key_backup_grant_migration) and dropped. A device whose implicit grant they
had revoked gets no live grant: its migrated grant is recorded already revoked, with that time and reason, over what it
could export then (D4's re-creation may restore exactly that). key_backup_grant_vaults.vault_id has no foreign key
to vault (a deleted vault's key stays exportable while it is retained; ids are never reused), and the daemon, not
SQL, decides who may export (ops::backup_grants::migratePending at the first start after the migration).
10. ws Contract Additions
All additive under protocol 1 (docs/reference/ws-protocol.md, "Breaking And Additive Changes"): new commands, new
error codes, new optional fields. protocol_compat passes with --write refreezing v1.json. The console and
server ship together. Desktop picks the new device commands up by commands presence. A console older than the
server answers mfa_required with a generic error and fails closed. No protocol bump, no legacy path.
Changed behavior of existing commands
| Command | Change |
|---|---|
auth.login |
May answer ERROR mfa_required / mfa_enrollment_required with data.pending (below) instead of success; refused on a signed-in socket (already_signed_in) |
auth.refresh, auth.isAuthenticated, HTTP lanes |
Refuse a row whose aal is below the user's policy |
| The families in 5.4 | May answer step_up_required |
device.pair.request |
Optional approval_key (base64 SPKI) |
device.pairing.pending |
Allowed on device sessions; requests carry approval_key_present |
vault.keys.export |
On a device session: no step-up inside a live grant, else step_up_required |
1{ "code": "mfa_required",2 "pending": { "expires_at": "2026-11-05T18:00:00Z",3 "methods": ["webauthn", "totp", "recovery_code"],4 "webauthn": { "publicKey": { "challenge": "…", "rpId": "vault.example.com", "timeout": 120000,5 "userVerification": "required",6 "allowCredentials": [{ "type": "public-key", "id": "…", "transports": ["internal"] }] } } } }
New commands (auth column = contract auth level; pending = the pending-MFA allowlist)
| Command | Auth | Payload | Response |
|---|---|---|---|
auth.mfa.verify |
pending | {method: "webauthn", credential} or {method: "totp" | "recovery_code", code} |
{user: SessionUser, aal, recovery?: {codes_remaining, cooldown_until}} + token; refusals mfa_invalid {attempts_remaining}, mfa_expired, rate_limited |
auth.mfa.cancel |
pending | none | {} |
auth.passkey.options |
session (anonymous) | none | {publicKey, expires_at} |
auth.passkey.login |
session (anonymous) | {credential} |
{user, aal} + token; one uniform refusal |
auth.mfa.status |
human | none | {policy: {required, reason, webauthn_available, admin_webauthn_only, admin_device_bound_only}, factors: [MfaFactor], recovery: {active, remaining, acknowledged}, cooldown_until, step_up: [{family, target, expires_at}]} |
auth.mfa.webauthn.register.options |
human, pending(enrollment) | {label?} |
{registration_id, publicKey} |
auth.mfa.webauthn.register.finish |
human, pending(enrollment) | {registration_id, credential, label} |
{factor: MfaFactor, recovery_codes?: {batch_id, codes}} (codes only for the first factor) |
auth.mfa.totp.enroll.start |
human, pending(enrollment) | {label} |
{enrollment_id, secret_base32, otpauth_uri, expires_at} |
auth.mfa.totp.enroll.confirm |
human, pending(enrollment) | {enrollment_id, code} |
{factor, recovery_codes?} |
auth.mfa.factor.rename |
human | {id, label} |
{factor} |
auth.mfa.factor.remove |
human + account.credentials |
{id} |
{}; mfa_last_factor |
auth.mfa.recovery.generate |
human (+ step-up after the first) | none | {batch_id, codes: [string × 10]} |
auth.mfa.recovery.acknowledge |
human, pending(enrollment) | {batch_id} |
{remaining} (completes enrollment when pending) |
auth.stepup.begin |
human, device | {family, target?} |
{stepup_id, methods, webauthn?: {publicKey}, expires_at} |
auth.stepup.finish |
human | {stepup_id, method: "webauthn", credential} or {stepup_id, method: "password_totp", password, code} or {stepup_id, method: "password", password} (D3) |
{family, target, expires_at, uses} |
auth.sessions.list |
human | none | {sessions: [{id, current, aal, amr, auth_time, last_activity_at, ip_address, user_agent}]} (id is an opaque handle, never the jti) |
auth.sessions.revoke |
human | {id} or {all_others: true} |
{revoked} |
auth.approval.list / .approve / .deny |
human | {approval_id, credential} (.approve: a UV WebAuthn assertion, nothing else) |
{approval} |
auth.mfa.admin.status |
human (users-manage) | {user_id} |
{factors: [MfaFactor without secrets], recovery: {remaining}, required} |
auth.mfa.admin.reset |
human + account.credentials |
{user_id, reason} |
{} |
auth.mfa.admin.require |
human + identity.admin |
{user_id, required} |
{} |
device.approval.challenge |
device | {family, target} |
{challenge_id, challenge, payload_b64, expires_at} |
device.approval.submit |
device | {challenge_id, signature_b64} |
{family, target, expires_at} |
device.approval.request / .status |
device | {family, target, summary} / {approval_id} |
{approval_id, state, expires_at} |
device.approval.key.register |
device + console approval | {public_key_spki_b64} |
{} |
device.backup.grant.list |
human, device | none | {grants: [{id, device_id, origin, vault_ids, created_at, confirmed_at, last_used_at, suspended_at}]} |
device.backup.grant.create / .extend / .confirm / .revoke |
device or human + device.trust (revoke: none) |
{vault_ids} / {grant_id, vault_ids} / {grant_id} / {grant_id} |
{grant} |
Push: auth.approval.pending {approval_id, device, family, summary, expires_at} to the user's console sessions.
Models: MfaFactor {id, kind: "webauthn" | "totp", label, created_at, last_used_at, backup_eligible?, backup_state?, transports?, aaguid?, suspended, created_during_recovery}. New error codes: mfa_required,
mfa_enrollment_required, mfa_invalid, mfa_expired, mfa_last_factor, already_signed_in,
step_up_required, recovery_cooldown, approval_expired. Each step-up command's contract entry gains step_up: "<family>" (descriptive, for the console and a contract test). New capability auth.step_up for clients that need
to detect the behavior rather than a command.
Web: LIFECYCLE (web/src/lib/session.ts:22) and core's isSessionLifecycleCommand gain the pending allowlist,
mirrored and checked by the existing WsAuthRouting tests. WebSocketCommandMap is regenerated from the contract.
11. Test Plan
Unit (gtest, DB-free)
- WebAuthn: the W3C WebAuthn L3 test vectors (ES256, EdDSA, RS256;
noneandpacked) plus fixtures recorded from Chrome's virtual authenticator, 1Password, Safari/iCloud Keychain and a YubiKey. One negative per check in 4.1: wrongrpIdHash, foreign origin,crossOrigin: true, wrongtype, challenge mismatch/reused/expired/other session/other purpose, UP = 0, UV = 0, BE flip, counter regression with BE 0 and with BE 1, unknownalg, RSA-1024, off-curve point, trailing CBOR, oversized and deeply nested CBOR, credential owned by another user,userHandlemismatch. - TOTP: RFC 6238 Appendix B (SHA-1) vectors; ±1 window; replay of the same step; concurrent double use; drift of ±2 steps refused.
- Recovery codes: format, keyed hash, single use under concurrency, inactive until acknowledged, superseded batch refused.
- Sealing: seal-and-read-back, fail closed without the master, no regeneration.
- Step-up grants: other session, other family, other target, expired, consumed, dropped by idle and logout; fresh sign-in counts only for family grants.
- Device approval signatures: canonical payload vector shared with Desktop; wrong node, server SPKI, device, target, expiry, reused challenge.
DB-backed (make test): the new migrations additive and idempotent on an upgraded schema; old refresh rows read
as aal 1; policy admins refuses an old admin row at next use; admin reset effects; grant revocation triggers.
ws contract and routing
protocol_compatpasses; new error codes andstep_upannotations are in the contract.- Table-driven: every command with a
step_upannotation answersstep_up_requiredwithout a grant and runs with one. - Table-driven: every command a Device session may run that has no annotation never answers
step_up_required(the "Desktop never prompts" guarantee). WsAuthRouting: a pending session can run only its allowlist.
The maintainer's regression list, each a named test:
| Case | Test |
|---|---|
| Bypass through refresh/reconnect | During pending: auth.refresh, a new socket with the same cookie, an HTTP lane with the cookie → all refused; no row exists |
| Missing MFA | Policy admins, admin without factor → only enrollment commands work |
| Stolen browser session enrolling a device / exporting keys | AAL2 session without a grant: device.pairing.approve and device.backup.grant.create → step_up_required; console key export stays export_requires_device |
| Stolen recovery code / replay | A used code is refused; a code without the password is impossible; recovery sign-in revokes other rows and grants |
| Time drift | ±1 step accepted, ±2 refused; replay in the window refused |
| WebAuthn origin mismatch | Unit (the browser itself won't produce one) |
| Session fixation | A planted cookie (valid JWT with no row, and a valid row for another user) never becomes the victim's session after their sign-in |
| Privilege elevation | auth.user.update role change, role.admin.update, group.member.add without a grant → refused; with a grant for another family → refused |
| Factor loss | Last-factor removal refused under policy; admin reset and vh user mfa reset paths, cooldown enforced |
Fuzzing (decision D5): libFuzzer targets (clang, -Dfuzz=true, off in normal builds) for the CBOR/COSE decoder,
the authenticatorData parser and the clientDataJSON parser, seeded with the vectors. CI runs them for a bounded
time on every change under core/src/auth/webauthn/, and any crash becomes a regression vector.
Audit: each flow in section 8 asserts its audit_event actions and outcomes (section 7.5), and vh audit verify
passes afterwards. The recovery-code flow is reconstructed from the trail alone.
Desktop interop (vaulthalla-desktop CI against a dev daemon built from the matching server branch):
- 30 minutes of routine sync, File Provider activity, reconnects and a key rotation, with zero
step_up_requiredanswers and zero prompts; - the approval-key payload vector, computed identically in Swift and C++;
- pairing a second device approved by the first's approval key;
- discretionary export prompts once;
- a migrated grant keeps backing up across the upgrade, then is confirmed and revoked from Desktop.
Tier B harness (tools/harness): CLI and ws parity for admin reset and policy; a device session and a web session
of the same user (grant on one never visible to the other); daemon restart drops pending states and grants.
Playwright e2e (web/tests/e2e) with Chrome DevTools Protocol:
1const cdp = await page.context().newCDPSession(page)2await cdp.send('WebAuthn.enable')3const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {4 options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true,5 hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },6})
Specs: first-run enrollment with a passkey; discoverable sign-in; password + passkey; TOTP enrollment and sign-in (codes
computed in the test with node:crypto); recovery codes; step-up dialog on a role change; isUserVerified: false →
refused; WebAuthn.setCredentialProperties with backup flags for the synced-counter case; idle sign-out during
pending. Origin mismatch is unit-only, since the browser enforces it.
12. Demo Plan
Run at implementation time on a dev instance with a real host name and a trusted certificate (or localhost), never
on vh-storage without approval. Record each run (screen capture) and attach it to the implementation PR.
| # | With 1Password (desktop app + browser extension, Chrome and Safari) | Without 1Password |
|---|---|---|
| 1 | First-run admin: Create passkey, 1Password offers to save it, unlock with Touch ID; recovery codes; acknowledge | Same in Safari with Apple Passwords (Touch ID), and in Chrome with a YubiKey (PIN) |
| 2 | Sign out; Sign in with passkey on a second browser through the synced 1Password passkey | Firefox with a security key; Chrome with the profile's passkey manager |
| 3 | TOTP enrollment by 1Password scanning the QR; password + code sign-in | TOTP with a phone authenticator app |
| 4 | Step-up: change a user's role → passkey prompt through 1Password → done; a second change within 5 min doesn't prompt | Same with Apple Passwords |
| 5 | Extension without the desktop app: 1Password asks for the account password as UV, and it still verifies | n/a |
| 6 | Desktop: background sync and file-provider activity for 10 minutes with no prompt; approve a second Mac from the first with Touch ID; manual key export outside the grant prompts once | Same |
| 7 | Recovery: sign in with a recovery code, see the cooldown and notification, enroll a new passkey | Same |
13. Implementation Issues
In dependency order. Each issue lands with its tests, .claude/context updates and release fragments, and owns its
own migration at the next free number.
| # | Issue | Depends on |
|---|---|---|
| 1 | #354 Session assurance on refresh tokens, write-once promotion, session list/revoke | none |
| 2 | #355 WebAuthn verifier (vectors, negative tests, strict parsing, fuzzing, libcbor packaging, auth.webauthn.*) |
none |
| 3 | #356 Factor storage: sealed TOTP, hashed recovery codes | #354 |
| 4 | #357 Pending-MFA sign-in and guided enrollment (server and console, Playwright virtual authenticator) | #354, #355, #356 |
| 5 | #358 Discoverable passkey sign-in | #357 |
| 6 | #359 Step-up framework and families | #357 |
| 7 | #360 Policy, lockout, admin and CLI reset, auditable recovery | #359 |
| 8 | #361 Device approval keys and action-bound approvals (server) | #359 |
| 8a | valkyrianlabs/vaulthalla-desktop#47 Secure Enclave approval key, Touch ID approvals, docs/pairing.md, interop tests |
#361 |
| 9 | #362 Key-backup grants and the migrated-grant upgrade | #361 |
| 9a | valkyrianlabs/vaulthalla-desktop#48 Grant consent, confirm-or-revoke, 1Password backup interop | #362, desktop#47 |
| 10 | #363 Operator docs and the end-to-end browser demo with and without 1Password | #358, #360, #362 |
14. Decisions
Decided by the maintainer on PR #343 (2026-11-05) and folded into the sections cited.
| # | Decision | Where |
|---|---|---|
| D1 | Existing Desktops keep automatic 1Password key backup through origin = 'migrated' grants with exactly their existing export authority. A non-blocking confirm-or-revoke prompt, no prompt per sync, backup or rotation, no silent expansion; visible, revocable, device-bound, audited; migration verified for no backup gap and no escalation |
6.4, 9, 11 |
| D2 | Default policy admins. AAL1 admin sessions are not grandfathered; admins enroll at next sign-in; local CLI recovery stays |
5.3, 7.1 |
| D3 | Password-only step-up only for an explicitly optional account with no factor, and never for keys, device.trust or data.destroy. The server enforces this |
5.4 |
| D4 | A recovery-code sign-in revokes other web sessions and key-backup grants automatically. It offers targeted device revocation and never breaks a legitimate Desktop's sync on its own | 7.4, 6.4 |
| D5 | In-tree verifier behind an extractable interface (libcbor + OpenSSL/libsodium), accepted only with every spec check, published vectors, negative tests, strict parsing and fuzzing. Reopen if a maintained verifier is measurably lower risk | 4.1, 11 |
| D6 | The Secure Enclave approval key authorizes only device.trust, keys and key-backup grants, through signed single-use challenges bound to action, target and session. mTLS and OS biometric claims never count as presence; other privileged families need console WebAuthn |
6.2, 6.3 |
Non-negotiable. Passkeys (including 1Password's) apply to console sign-in and step-up. The pinned mTLS Desktop tunnel reconnects, syncs and backs up approved vault keys with no recurring MFA prompt. There is no general-purpose, forever-authorized key-export channel. Refresh cookies, pairing and recovery never bypass enrollment.
Escalate only a new concrete key-custody threat, irreversible data loss or a real product-policy contradiction.
