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

@ -61,6 +61,8 @@ export {
type VaultClient,
type VaultClientOptions,
type VaultUnlockState,
type VaultStatus,
type RecoveryCodeSetupResult,
createVaultClient,
} from './vault-client';

View file

@ -83,6 +83,16 @@ export interface RecoveryCodeSetupResult {
formattedCode: string;
}
/** Snapshot of the server-side vault status. Returned by getStatus() so
* the UI can render the right section without triggering a full unwrap. */
export interface VaultStatus {
vaultExists: boolean;
hasRecoveryWrap: boolean;
zeroKnowledge: boolean;
/** ISO timestamp string, or null if never set. */
recoverySetAt: string | null;
}
export interface VaultClient {
/** Unlocks the in-memory key provider by fetching from the server.
* On first call per device, automatically initialises the vault.
@ -145,6 +155,13 @@ export interface VaultClient {
* (wrong code or tampered blob the caller maps both to "wrong
* recovery code, try again"). */
unlockWithRecoveryCode(code: string): Promise<VaultUnlockState>;
/** Cheap server-side metadata fetch. Returns the vault row's
* high-level state (exists, has recovery wrap, zero-knowledge
* active, recovery set timestamp) so the UI can render the
* right section without triggering a full unwrap. Used by the
* settings page on mount to hydrate after a reload. */
getStatus(): Promise<VaultStatus>;
}
/**
@ -600,6 +617,22 @@ export function createVaultClient(options: VaultClientOptions): VaultClient {
return state;
}
async function getStatus(): Promise<VaultStatus> {
const token = await getToken();
if (!token) throw new Error('no auth token available');
// Status is a cheap metadata read — no retry loop, no audit
// noise. If it fails, we let the caller decide whether to fall
// back to "vault not initialised" defaults or surface the error.
const res = await fetch(`${authUrl}/api/v1/me/encryption-vault/status`, {
method: 'GET',
...authHeaders(token),
});
if (!res.ok) {
throw new Error(`vault status fetch failed: ${res.status}`);
}
return (await res.json()) as VaultStatus;
}
return {
unlock,
lock,
@ -611,6 +644,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient {
enableZeroKnowledge,
disableZeroKnowledge,
unlockWithRecoveryCode,
getStatus,
};
}

View file

@ -57,6 +57,11 @@
let zkBusy = $state(false);
let confirmDisableZk = $state(false);
let confirmClearRecovery = $state(false);
/** True iff a recovery wrap is stored on the server, regardless of
* whether ZK is currently active. Hydrated from getStatus() on
* mount so the UI can show "Recovery-Code entfernen" without
* walking through the setup flow again. */
let hasRecoveryWrap = $state(false);
async function handleSetupRecoveryCode() {
zkError = null;
@ -65,6 +70,7 @@
const result = await vaultClient.setupRecoveryCode();
generatedCode = result.formattedCode;
zkSetupStep = 'generated';
hasRecoveryWrap = true;
} catch (e) {
zkError = (e as Error).message;
} finally {
@ -133,6 +139,7 @@
toast.success('Recovery-Code entfernt');
confirmClearRecovery = false;
zkSetupStep = 'idle';
hasRecoveryWrap = false;
} catch (e) {
zkError = (e as Error).message;
} finally {
@ -167,6 +174,25 @@
const next = vaultClient.getState();
if (next.status !== vaultState.status) vaultState = next;
}, 1000);
// Hydrate the ZK section from the server's actual vault state
// so a reload after enabling zero-knowledge doesn't drop the
// user back into the setup flow. Best-effort: failures leave
// zkSetupStep at 'idle' which is the safe default.
void vaultClient
.getStatus()
.then((status) => {
hasRecoveryWrap = status.hasRecoveryWrap;
if (status.zeroKnowledge) {
zkSetupStep = 'enabled';
}
})
.catch(() => {
// Status fetch failed (network, auth, server). Stay on the
// idle default — the user can still run the setup flow,
// and the server-side error handling will catch any
// inconsistencies.
});
});
onDestroy(() => {
@ -354,6 +380,57 @@
{/if}
{#if zkSetupStep === 'idle'}
{#if hasRecoveryWrap}
<div class="zk-info">
Du hast bereits einen Recovery-Code gespeichert, aber Zero-Knowledge ist noch nicht
aktiv. Du kannst direkt aktivieren oder den Code zurücksetzen.
</div>
<div class="zk-actions">
<button
class="btn btn-danger"
type="button"
disabled={vaultState.status !== 'unlocked' || zkBusy}
onclick={handleEnableZeroKnowledge}
>
{zkBusy ? 'Aktiviere …' : 'Zero-Knowledge jetzt aktivieren'}
</button>
<button
class="btn"
type="button"
disabled={vaultState.status !== 'unlocked' || zkBusy}
onclick={handleSetupRecoveryCode}
>
Neuen Recovery-Code generieren
</button>
{#if !confirmClearRecovery}
<button
class="btn btn-ghost"
type="button"
disabled={zkBusy}
onclick={() => (confirmClearRecovery = true)}
>
Recovery-Code entfernen
</button>
{:else}
<button
class="btn btn-danger"
type="button"
disabled={zkBusy}
onclick={handleClearRecoveryCode}
>
{zkBusy ? 'Entferne …' : 'Ja, entfernen'}
</button>
<button
class="btn btn-ghost"
type="button"
disabled={zkBusy}
onclick={() => (confirmClearRecovery = false)}
>
Abbrechen
</button>
{/if}
</div>
{:else}
<div class="zk-actions">
<button
class="btn btn-primary"
@ -365,6 +442,7 @@
</button>
</div>
{/if}
{/if}
{#if zkSetupStep === 'generated' && generatedCode}
<div class="zk-step">
@ -679,6 +757,16 @@
color: rgb(185, 28, 28);
}
.zk-info {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 0.5rem;
font-size: 0.875rem;
color: rgb(180, 83, 9);
}
.zk-actions {
display: flex;
flex-wrap: wrap;

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 ─────────────
/**