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:
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user