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 { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
||||||
import { probeVault } from './probe';
|
import { probeVault } from './probe';
|
||||||
import type { VaultConfig } from '../shared/types';
|
import type { VaultConfig } from '../shared/types';
|
||||||
|
import type { SessionHandle } from 'relicario-wasm';
|
||||||
|
|
||||||
// --- WASM module (loaded dynamically) ---
|
// --- WASM module (loaded dynamically) ---
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ interface WizardState {
|
|||||||
passphraseVisible: boolean;
|
passphraseVisible: boolean;
|
||||||
confirmVisible: boolean;
|
confirmVisible: boolean;
|
||||||
referenceImageBytes: Uint8Array | null;
|
referenceImageBytes: Uint8Array | null;
|
||||||
verifiedHandle: number | null;
|
verifiedHandle: SessionHandle | null;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
attaching: boolean;
|
attaching: boolean;
|
||||||
error: string | null;
|
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 {
|
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 {
|
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 ---
|
// --- Step 1: Choose Host ---
|
||||||
|
|||||||
Reference in New Issue
Block a user