mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
feat(vault): rotate recovery code while zero-knowledge is active
Closes backlog #2 from the Phase 9 audit. Lets a user replace their recovery code without going through the disable→generate→re-enable dance. Works in BOTH standard and zero-knowledge modes. vault-client ------------ New rotateRecoveryCode() method on the VaultClient interface. Returns RecoveryCodeSetupResult, identical shape to setupRecoveryCode. Branches on the current vault state via getStatus(): Standard mode: Re-fetches the plaintext MK from the server (same path as the initial setupRecoveryCode), generates a fresh 32-byte recovery secret, derives the new wrap key via HKDF, seals the MK, posts the wrap to /recovery-wrap (idempotent server-side, replaces the existing row in place). Zero-knowledge mode: Server can't hand out the plaintext MK any more, so we use the cachedUnwrappedMkBytes that unlockWithRecoveryCode stashed when the user typed in their old recovery code earlier this session. Throws with a clear message if the cache is empty (e.g. user landed on the page via init rather than recovery-unlock): "sign out and back in with your current recovery code first" so the cache gets repopulated. Both branches: - Wipe the raw MK reference after sealing - Wipe the recovery secret after format - Return the formatted code for the UI to display The OLD recovery code is now permanently invalid. Using it on a future unlock attempt will fail with the standard generic "wrong recovery code" error. Settings UI ----------- New rotateStep state machine ('idle' / 'rotated') runs alongside the existing zkSetupStep so the user can rotate without leaving the active-state UI. In the active-mode card (zkSetupStep === 'enabled'): - Two side-by-side buttons: "🔁 Recovery-Code rotieren" + "Zero-Knowledge-Modus wieder deaktivieren …" - When the user clicks rotate, handleRotateRecoveryCode() runs the flow and renders an inline "Neuer Recovery-Code" subsection (same .recovery-code monospace block + Copy button as the initial setup) with explicit warning that the old code is now invalid. - "Ich habe den neuen Code gesichert" button wipes the displayed code and drops back to idle. - The disable flow stays available (the rotate UI hides itself when the user has clicked into the disable confirmation path). The 28 vault integration tests still pass (39 total in encryption-vault/, including the existing 11 KEK tests). The new rotateRecoveryCode method reuses the already-tested setRecoveryWrap server endpoint, so no new server-side tests are needed for this commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c2c960121e
commit
24001e9545
2 changed files with 166 additions and 1 deletions
|
|
@ -162,6 +162,23 @@ export interface VaultClient {
|
|||
* right section without triggering a full unwrap. Used by the
|
||||
* settings page on mount to hydrate after a reload. */
|
||||
getStatus(): Promise<VaultStatus>;
|
||||
|
||||
/** Generates a fresh recovery code and replaces the existing
|
||||
* recovery wrap on the server. Works in BOTH standard and
|
||||
* zero-knowledge modes:
|
||||
*
|
||||
* - Standard mode: re-fetches the current MK from the server
|
||||
* and seals it with the new recovery key (same path as the
|
||||
* initial setupRecoveryCode call).
|
||||
* - Zero-knowledge mode: uses the cached MK bytes from the most
|
||||
* recent recovery-code unlock, since the server can't hand
|
||||
* out the plaintext MK any more. Throws if the cache is empty
|
||||
* (caller has to re-unlock with the old recovery code first).
|
||||
*
|
||||
* Returns the freshly formatted recovery code for the UI to
|
||||
* display. The OLD code is now invalid — using it on a future
|
||||
* unlock will fail with "wrong recovery code". */
|
||||
rotateRecoveryCode(): Promise<RecoveryCodeSetupResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -617,6 +634,90 @@ export function createVaultClient(options: VaultClientOptions): VaultClient {
|
|||
return state;
|
||||
}
|
||||
|
||||
async function rotateRecoveryCode(): Promise<RecoveryCodeSetupResult> {
|
||||
if (state.status !== 'unlocked') {
|
||||
throw new Error('vault must be unlocked before rotateRecoveryCode()');
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('no auth token available for rotateRecoveryCode()');
|
||||
}
|
||||
|
||||
// Two paths depending on which mode the vault is in:
|
||||
//
|
||||
// Standard: server still has the KEK wrap, so we can re-fetch
|
||||
// the plaintext MK exactly the way setupRecoveryCode
|
||||
// does. This branch is identical to the initial
|
||||
// setup, just labelled differently in the UI.
|
||||
//
|
||||
// ZK: server can't hand out the MK. We use the cached
|
||||
// bytes that unlockWithRecoveryCode stashed when
|
||||
// the user typed in their old recovery code earlier
|
||||
// this session. If the cache is empty (page reload
|
||||
// after unlock + lock cycle, or unlock via init
|
||||
// rather than recovery), we have to bail and ask
|
||||
// the user to re-authenticate first.
|
||||
|
||||
let rawMk: Uint8Array;
|
||||
let needsWipe = true;
|
||||
|
||||
const status = await getStatus();
|
||||
if (status.zeroKnowledge) {
|
||||
if (!cachedUnwrappedMkBytes) {
|
||||
throw new Error(
|
||||
'cannot rotate recovery code: vault is in zero-knowledge mode and the master key bytes are not cached. Sign out and back in with your current recovery code first.'
|
||||
);
|
||||
}
|
||||
// Clone the cache so we don't wipe the source.
|
||||
rawMk = new Uint8Array(cachedUnwrappedMkBytes);
|
||||
} else {
|
||||
// Standard mode — re-fetch from the server.
|
||||
const fetchRes = await fetchVault('/api/v1/me/encryption-vault/key', {
|
||||
method: 'GET',
|
||||
...authHeaders(token),
|
||||
});
|
||||
if (!fetchRes.ok || isRecoveryBlob(fetchRes.data)) {
|
||||
throw new Error('failed to re-fetch master key for recovery wrap rotation');
|
||||
}
|
||||
rawMk = base64ToBytes((fetchRes.data as { masterKey: string }).masterKey);
|
||||
}
|
||||
|
||||
// Generate fresh recovery secret + derive new wrap key.
|
||||
const recoverySecret = generateRecoverySecret();
|
||||
const recoveryWrapKey = await deriveRecoveryWrapKey(recoverySecret);
|
||||
|
||||
// Import the MK as extractable for the wrap operation.
|
||||
const extractableMk = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toBufferSource(rawMk),
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const sealed = await wrapMasterKeyWithRecovery(extractableMk, recoveryWrapKey);
|
||||
|
||||
if (needsWipe) {
|
||||
rawMk.fill(0);
|
||||
}
|
||||
|
||||
// POST replaces the existing wrap (setRecoveryWrap is idempotent
|
||||
// server-side). The OLD recovery code is now permanently invalid.
|
||||
const setRes = await fetchVault('/api/v1/me/encryption-vault/recovery-wrap', {
|
||||
method: 'POST',
|
||||
...authHeaders(token),
|
||||
body: JSON.stringify(sealed),
|
||||
});
|
||||
if (!setRes.ok) {
|
||||
throw new Error(`failed to store rotated recovery wrap: ${setRes.status}`);
|
||||
}
|
||||
|
||||
const formatted = formatRecoveryCode(recoverySecret);
|
||||
recoverySecret.fill(0);
|
||||
|
||||
return { formattedCode: formatted };
|
||||
}
|
||||
|
||||
async function getStatus(): Promise<VaultStatus> {
|
||||
const token = await getToken();
|
||||
if (!token) throw new Error('no auth token available');
|
||||
|
|
@ -645,6 +746,7 @@ export function createVaultClient(options: VaultClientOptions): VaultClient {
|
|||
disableZeroKnowledge,
|
||||
unlockWithRecoveryCode,
|
||||
getStatus,
|
||||
rotateRecoveryCode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@
|
|||
* mount so the UI can show "Recovery-Code entfernen" without
|
||||
* walking through the setup flow again. */
|
||||
let hasRecoveryWrap = $state(false);
|
||||
/** Side flow for rotating the recovery code from the active state.
|
||||
* 'idle' — show "Code rotieren" button
|
||||
* 'rotated' — show the new code + Copy + Done button
|
||||
* Independent of zkSetupStep so the user can rotate without
|
||||
* leaving the active-mode UI. */
|
||||
let rotateStep = $state<'idle' | 'rotated'>('idle');
|
||||
let rotatedCode = $state<string | null>(null);
|
||||
|
||||
async function handleSetupRecoveryCode() {
|
||||
zkError = null;
|
||||
|
|
@ -155,6 +162,36 @@
|
|||
);
|
||||
}
|
||||
|
||||
async function handleRotateRecoveryCode() {
|
||||
zkError = null;
|
||||
zkBusy = true;
|
||||
try {
|
||||
const result = await vaultClient.rotateRecoveryCode();
|
||||
rotatedCode = result.formattedCode;
|
||||
rotateStep = 'rotated';
|
||||
} catch (e) {
|
||||
zkError = (e as Error).message;
|
||||
} finally {
|
||||
zkBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyRotatedCode() {
|
||||
if (!rotatedCode) return;
|
||||
navigator.clipboard.writeText(rotatedCode).then(
|
||||
() => toast.success('Code in die Zwischenablage kopiert'),
|
||||
() => toast.error('Konnte Code nicht kopieren')
|
||||
);
|
||||
}
|
||||
|
||||
function handleFinishRotation() {
|
||||
// User has acknowledged they backed up the new code. Wipe the
|
||||
// reference (the DOM still shows it until the next render
|
||||
// cycle, but our state goes away).
|
||||
rotatedCode = null;
|
||||
rotateStep = 'idle';
|
||||
}
|
||||
|
||||
function handleResetSetup() {
|
||||
zkSetupStep = 'idle';
|
||||
generatedCode = null;
|
||||
|
|
@ -529,8 +566,34 @@
|
|||
Der Server kann deine Daten ab sofort nicht mehr entschlüsseln. Beim nächsten Login auf
|
||||
einem neuen Gerät wirst du nach deinem Recovery-Code gefragt.
|
||||
</p>
|
||||
{#if !confirmDisableZk}
|
||||
|
||||
{#if rotateStep === 'rotated' && rotatedCode}
|
||||
<div class="zk-step">
|
||||
<h3>🔁 Neuer Recovery-Code</h3>
|
||||
<p>
|
||||
<strong>Dein alter Code ist ab sofort ungültig.</strong> Speichere den neuen Code an einem
|
||||
sicheren Ort, bevor du diese Seite verlässt — wir zeigen ihn dir nur ein einziges Mal.
|
||||
</p>
|
||||
<div class="recovery-code">{rotatedCode}</div>
|
||||
<div class="zk-actions">
|
||||
<button class="btn" type="button" onclick={handleCopyRotatedCode}>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" onclick={handleFinishRotation}>
|
||||
Ich habe den neuen Code gesichert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !confirmDisableZk}
|
||||
<div class="zk-actions">
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
disabled={vaultState.status !== 'unlocked' || zkBusy}
|
||||
onclick={handleRotateRecoveryCode}
|
||||
>
|
||||
{zkBusy ? 'Rotiere …' : '🔁 Recovery-Code rotieren'}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue