mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
56312ff579
commit
78d949d051
5 changed files with 205 additions and 10 deletions
|
|
@ -61,6 +61,8 @@ export {
|
|||
type VaultClient,
|
||||
type VaultClientOptions,
|
||||
type VaultUnlockState,
|
||||
type VaultStatus,
|
||||
type RecoveryCodeSetupResult,
|
||||
createVaultClient,
|
||||
} from './vault-client';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue