feat(ext/setup): Step 3b attach flow with decrypt verification

Replace placeholder renderStep3Attach/attachStep3Attach with the real
attach flow: file-picker for reference JPEG, passphrase input with
visibility toggle, then fetch salt+params+manifest.enc, call
unlock()+manifest_decrypt() to AEAD-verify credentials before
advancing to Step 4. Wrong passphrase/image shows a clear error;
partial handles are locked on failure to avoid key-material leaks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 18:32:27 -04:00
parent 214f8da673
commit e79e80b000

View File

@@ -9,6 +9,7 @@
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
import { probeVault } from './probe';
import type { VaultConfig } from '../shared/types';
import type { SessionHandle } from 'relicario-wasm';
// --- WASM module (loaded dynamically) ---
@@ -47,7 +48,7 @@ interface WizardState {
passphraseVisible: boolean;
confirmVisible: boolean;
referenceImageBytes: Uint8Array | null;
verifiedHandle: number | null;
verifiedHandle: SessionHandle | null;
creating: boolean;
attaching: boolean;
error: string | null;
@@ -323,14 +324,151 @@ function attachStep0(): void {
});
}
// --- Step 3 (attach variant) — placeholder; filled in Task 8 ---
// --- Step 3 (attach variant) ---
function renderStep3Attach(): string {
return '<div class="wizard-step"><p>Step 3b (placeholder)</p></div>';
const p = state.passphrase;
const pType = state.passphraseVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const hasImage = !!state.referenceImageBytesAttach;
const gateDisabled = state.attaching || !p || !hasImage;
return `
<div class="wizard-step">
<h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;">
Use your existing passphrase and reference image to attach this browser
to your vault. We'll verify both before registering this device.
</p>
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasImage
? '<p class="secondary">reference image loaded</p>'
: '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">
The reference image is the JPEG you saved when you first created this vault —
<strong>not the original photo</strong>. It has the 256-bit secret embedded.
</p>
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>
${state.attaching ? '<span class="spinner"></span> verifying...' : 'verify and attach'}
</button>
</div>
</div>
`;
}
function attachStep3Attach(): void {
/* filled in Task 8 */
const refDrop = document.getElementById('ref-drop')!;
const refInput = document.getElementById('ref-input') as HTMLInputElement;
refDrop.addEventListener('click', () => refInput.click());
refInput.addEventListener('change', () => {
const file = refInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
render();
};
reader.readAsArrayBuffer(file);
});
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
if (btn) btn.disabled = state.attaching || !state.passphrase || !state.referenceImageBytesAttach;
});
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
const btn = document.getElementById('eye-btn');
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
passInput?.focus();
});
document.getElementById('back-btn')?.addEventListener('click', () => {
state.step = 2;
state.error = null;
render();
});
document.getElementById('attach-btn')?.addEventListener('click', async () => {
if (!state.referenceImageBytesAttach || !state.passphrase) return;
state.attaching = true;
state.error = null;
render();
const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? '');
let stage = 'init';
let handle: SessionHandle | null = null;
try {
stage = 'load wasm';
log(stage);
const w = await loadWasm();
stage = 'fetch vault metadata';
log(stage);
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
const [salt, paramsBytes, encryptedManifest] = await Promise.all([
host.readFile('.relicario/salt'),
host.readFile('.relicario/params.json'),
host.readFile('manifest.enc'),
]);
const paramsJson = new TextDecoder().decode(paramsBytes);
stage = 'derive session handle';
log(stage);
handle = w.unlock(state.passphrase, state.referenceImageBytesAttach, salt, paramsJson);
stage = 'decrypt manifest';
log(stage);
// Throws if AEAD verification fails — wrong passphrase or wrong image.
w.manifest_decrypt(handle, encryptedManifest);
log('attach verified — advancing');
state.verifiedHandle = handle;
state.attaching = false;
state.step = 4;
state.error = null;
render();
} catch (err: unknown) {
console.error(`[relicario setup] attach FAILED during "${stage}":`, err);
state.attaching = false;
// Lock any partial handle to avoid leaking key material.
if (handle !== null) {
try { (await loadWasm()).lock(handle); } catch { /* best effort */ }
}
state.verifiedHandle = null;
const detail = err instanceof Error ? err.message : String(err);
// Stage-aware copy: if we got past 'fetch', this is a credential failure.
if (stage === 'derive session handle' || stage === 'decrypt manifest') {
state.error = 'Could not decrypt vault — wrong passphrase or reference image.';
} else {
state.error = `Attach failed at "${stage}": ${detail}`;
}
render();
}
});
}
// --- Step 1: Choose Host ---