feat(crypto): vault status endpoint + settings page hydration

Closes the Phase 9 Milestone 4 known limitation where the settings
page always started in 'idle' state regardless of whether the user
had already enabled zero-knowledge mode. Adds a cheap server-side
status read + hydrates the page on mount.

Server side
-----------
New VaultStatus interface and getStatus(userId) method on
EncryptionVaultService — single SELECT against encryption_vaults,
no decryption, no audit logging (this gets called on every settings
page mount and we don't want to flood the audit log with read-only
metadata fetches). Returns sane defaults when the vault row doesn't
exist yet so the client can avoid a 404 dance.

  GET /api/v1/me/encryption-vault/status →
  {
    vaultExists: boolean,
    hasRecoveryWrap: boolean,
    zeroKnowledge: boolean,
    recoverySetAt: string | null
  }

Client side
-----------
vault-client.ts gains a `getStatus()` method that bypasses the
fetchVault retry helper (status reads should be cheap and one-shot;
if they fail we let the caller fall back to defaults). Re-exports
VaultStatus + RecoveryCodeSetupResult from the crypto barrel.

settings/security/+page.svelte
------------------------------
onMount kicks off a getStatus() call. Two things change based on
the response:

  1. If the server says zero_knowledge=true, jump zkSetupStep to
     'enabled' so the page renders the active-state UI directly
     instead of the setup flow.

  2. New `hasRecoveryWrap` state tracks whether a wrap is stored,
     even if ZK isn't active yet. The idle branch now has TWO
     variants:

     - hasRecoveryWrap=false: original "Recovery-Code einrichten"
       single button (unchanged from milestone 4)

     - hasRecoveryWrap=true:  amber notice "you have a code stored
       but ZK isn't active" with three buttons:
       * "Zero-Knowledge jetzt aktivieren" (jumps straight to the
         enable call)
       * "Neuen Recovery-Code generieren" (rotates the wrap)
       * "Recovery-Code entfernen" (with two-click confirmation,
         calls DELETE /recovery-wrap)

This handles the previously-orphaned state where a user generated a
code, copied it to their password manager, but never confirmed the
final activation step. Without this branch, after a reload the
settings page would show "Setup" again and the call would fail
with "vault is already in zero-knowledge mode" — except it wouldn't,
because the vault wasn't actually in ZK yet, just had a recovery wrap
stored. Either way the state was confusing.

handleSetupRecoveryCode + handleClearRecoveryCode now keep
hasRecoveryWrap in sync after the round trip.

Fail-quiet on getStatus error: if the network/auth/server-side fetch
fails, the page stays at the idle default. The user can still run
the setup flow, and any inconsistencies surface via the usual
server-side error responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 23:19:49 +02:00
parent 56312ff579
commit 78d949d051
5 changed files with 205 additions and 10 deletions

View file

@ -40,6 +40,18 @@ type AppContext = Context<{ Variables: { user: AuthUser } }>;
export function createEncryptionVaultRoutes(vaultService: EncryptionVaultService) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
// ─── GET /status ─────────────────────────────────────────
// Cheap metadata read used by the settings page to hydrate the UI
// after a reload. No decryption, no audit logging — pure SELECT.
// Returns the same shape regardless of whether the vault row
// exists yet, so the client can avoid a 404 dance for the
// "vault not initialised" case.
app.get('/status', async (c) => {
const user = c.get('user');
const status = await vaultService.getStatus(user.userId);
return c.json(status);
});
// ─── POST /init ──────────────────────────────────────────
// Idempotent. First call creates a vault row; subsequent calls
// return the existing master key. The client uses this on first

View file

@ -74,6 +74,25 @@ export interface RecoveryWrapInput {
recoveryIv: string;
}
/** Snapshot of the vault row's high-level state, exposed via
* GET /api/v1/me/encryption-vault/status. The settings page reads
* this on mount to render the right UI section without having to
* trigger a full unwrap of the master key. Cheap (single SELECT,
* no decrypt). */
export interface VaultStatus {
/** True iff a vault row exists for this user. */
vaultExists: boolean;
/** True iff the user has a recovery wrap stored. Independent of
* whether zero-knowledge is currently active. */
hasRecoveryWrap: boolean;
/** True iff zero-knowledge mode is active (server has no usable
* KEK wrap, recovery wrap is the only way to unlock). */
zeroKnowledge: boolean;
/** ISO timestamp of when the recovery wrap was last set, or null
* if never set. Useful for "last backup" hints in the UI. */
recoverySetAt: string | null;
}
export class EncryptionVaultService {
constructor(private db: Database) {}
@ -262,6 +281,46 @@ export class EncryptionVaultService {
});
}
/**
* Cheap status read for UI rendering. No decryption, no audit
* row (this gets called on every settings page mount and we don't
* want to flood the audit log with read-only metadata fetches).
*
* Returns sane defaults when the vault row doesn't exist yet
* the page can render "vault not initialised" without needing a
* separate code path.
*/
async getStatus(userId: string): Promise<VaultStatus> {
return this.withUserScope(userId, async (tx) => {
const rows = await tx
.select({
recoveryWrappedMk: encryptionVaults.recoveryWrappedMk,
recoverySetAt: encryptionVaults.recoverySetAt,
zeroKnowledge: encryptionVaults.zeroKnowledge,
})
.from(encryptionVaults)
.where(eq(encryptionVaults.userId, userId))
.limit(1);
if (rows.length === 0) {
return {
vaultExists: false,
hasRecoveryWrap: false,
zeroKnowledge: false,
recoverySetAt: null,
};
}
const row = rows[0];
return {
vaultExists: true,
hasRecoveryWrap: row.recoveryWrappedMk !== null,
zeroKnowledge: row.zeroKnowledge,
recoverySetAt: row.recoverySetAt ? row.recoverySetAt.toISOString() : null,
};
});
}
// ─── Phase 9: Recovery Wrap + Zero-Knowledge ─────────────
/**