Setup wizard step 3 now has self-explanatory passphrase feedback: - Strength meter: 5 segments with smooth color transitions (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow. - Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" / "good" / "strong" — color-matched to each tier. - Entropy readout line: "~10^N guesses — <time to crack>" with tiered shorthand (trivial / minutes-on-GPU / hours-to-days / years-on-consumer / beyond consumer / uncrackable). - Live char counter in the strength row. - Eye toggle buttons on both passphrase fields. Flip type="password" <-> type="text" without re-render, preserving focus + cursor. - Live match indicator (✓ / ✗) between the confirm field and its eye toggle. Updates per keystroke. - Create button gate widened: now requires score >= 3 AND confirm field filled AND confirm matches. Disabled button carries a tooltip saying which condition failed. - Contextual help box above the passphrase field explaining the "long phrase > complex password" idea + the score >= 3 threshold. All live-update paths (counter, label, entropy, match indicator, button gate) go through updateStrengthUi() which targets the DOM directly — no full re-render, so focus/cursor survive every keystroke. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
801 lines
29 KiB
TypeScript
801 lines
29 KiB
TypeScript
/// Vault initialization wizard — 4-step flow for creating new relicario vaults.
|
|
///
|
|
/// Step 1: Choose host type (Gitea / GitHub)
|
|
/// Step 2: Configure connection (URL, repo, token) + test
|
|
/// Step 3: Create vault (carrier image, passphrase, generate secrets, push files)
|
|
/// Step 4: Finish (download reference image, push config to extension or copy JSON)
|
|
|
|
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
|
import type { VaultConfig } from '../shared/types';
|
|
|
|
// --- WASM module (loaded dynamically) ---
|
|
|
|
type WasmModule = typeof import('relicario-wasm');
|
|
let wasm: WasmModule | null = null;
|
|
|
|
async function loadWasm(): Promise<WasmModule> {
|
|
if (wasm) return wasm;
|
|
const mod = await import(
|
|
// @ts-ignore TS2307 — resolved at runtime, not by TS/webpack
|
|
/* webpackIgnore: true */ '../relicario_wasm.js'
|
|
) as WasmModule & { default: (input?: string | URL) => Promise<void> };
|
|
await mod.default('../relicario_wasm_bg.wasm');
|
|
wasm = mod;
|
|
return mod;
|
|
}
|
|
|
|
// --- State ---
|
|
|
|
interface WizardState {
|
|
step: number;
|
|
hostType: 'gitea' | 'github';
|
|
hostUrl: string;
|
|
repoPath: string;
|
|
apiToken: string;
|
|
connectionTested: boolean;
|
|
carrierImageBytes: Uint8Array | null;
|
|
passphrase: string;
|
|
passphraseConfirm: string;
|
|
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
|
|
passphraseScore: number;
|
|
passphraseGuessesLog10: number; // -1 before first rating
|
|
passphraseVisible: boolean;
|
|
confirmVisible: boolean;
|
|
referenceImageBytes: Uint8Array | null;
|
|
creating: boolean;
|
|
error: string | null;
|
|
extensionDetected: boolean;
|
|
configPushed: boolean;
|
|
}
|
|
|
|
const state: WizardState = {
|
|
step: 1,
|
|
hostType: 'gitea',
|
|
hostUrl: '',
|
|
repoPath: '',
|
|
apiToken: '',
|
|
connectionTested: false,
|
|
carrierImageBytes: null,
|
|
passphrase: '',
|
|
passphraseConfirm: '',
|
|
passphraseScore: -1,
|
|
passphraseGuessesLog10: -1,
|
|
passphraseVisible: false,
|
|
confirmVisible: false,
|
|
referenceImageBytes: null,
|
|
creating: false,
|
|
error: null,
|
|
extensionDetected: false,
|
|
configPushed: false,
|
|
};
|
|
|
|
// --- Helpers ---
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
interface Strength { score: number; guessesLog10: number }
|
|
|
|
/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4]
|
|
/// and guesses_log10, or -1 on both if the round-trip failed.
|
|
function ratePassphrase(passphrase: string): Promise<Strength> {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
chrome.runtime.sendMessage(
|
|
{ type: 'rate_passphrase', passphrase },
|
|
(response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => {
|
|
if (chrome.runtime.lastError) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
|
|
resolve({ score: -1, guessesLog10: -1 }); return;
|
|
}
|
|
if (!response?.ok) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
|
|
resolve({ score: -1, guessesLog10: -1 }); return;
|
|
}
|
|
resolve({
|
|
score: response.data?.score ?? -1,
|
|
guessesLog10: response.data?.guesses_log10 ?? -1,
|
|
});
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[relicario setup] rate_passphrase threw:', err);
|
|
resolve({ score: -1, guessesLog10: -1 });
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
|
|
/// on every keystroke. The last invocation wins.
|
|
let rateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void {
|
|
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
|
|
rateDebounceTimer = setTimeout(async () => {
|
|
rateDebounceTimer = null;
|
|
if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; }
|
|
onResult(await ratePassphrase(passphrase));
|
|
}, 150);
|
|
}
|
|
|
|
const STRENGTH_LABELS: Record<number, { text: string; cls: string }> = {
|
|
0: { text: 'very weak', cls: 's-very-weak' },
|
|
1: { text: 'weak', cls: 's-weak' },
|
|
2: { text: 'fair', cls: 's-fair' },
|
|
3: { text: 'good', cls: 's-good' },
|
|
4: { text: 'strong', cls: 's-strong' },
|
|
};
|
|
|
|
/// Render the entropy readout as "~10^N guesses to crack" or a friendlier
|
|
/// shorthand for large values. Returns empty string when no data.
|
|
function entropyText(guessesLog10: number): string {
|
|
if (guessesLog10 < 0) return '';
|
|
const rounded = Math.round(guessesLog10);
|
|
if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`;
|
|
if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`;
|
|
if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`;
|
|
if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`;
|
|
if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`;
|
|
return `~10^${rounded} guesses — effectively uncrackable`;
|
|
}
|
|
|
|
/// Update just the meter DOM without a full re-render (so the input keeps
|
|
/// focus and the user's cursor position is preserved). Also updates the
|
|
/// char counter and confirm-match indicator live.
|
|
function updateStrengthUi(): void {
|
|
const bar = document.getElementById('strength-bar');
|
|
const label = document.getElementById('strength-label');
|
|
const entropy = document.getElementById('entropy-line');
|
|
const counter = document.getElementById('passphrase-counter');
|
|
const matchInd = document.getElementById('match-indicator');
|
|
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
|
|
|
|
const score = state.passphraseScore;
|
|
const guessesLog10 = state.passphraseGuessesLog10;
|
|
|
|
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
|
|
|
|
if (label) {
|
|
if (score < 0) {
|
|
label.className = 'strength-label';
|
|
label.innerHTML = ' ';
|
|
} else {
|
|
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
|
|
label.className = `strength-label ${meta.cls}`;
|
|
label.textContent = meta.text;
|
|
}
|
|
}
|
|
|
|
if (entropy) {
|
|
const txt = entropyText(guessesLog10);
|
|
entropy.textContent = txt;
|
|
entropy.style.visibility = txt ? 'visible' : 'hidden';
|
|
}
|
|
|
|
if (counter) {
|
|
const n = state.passphrase.length;
|
|
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
if (matchInd) {
|
|
const p = state.passphrase;
|
|
const c = state.passphraseConfirm;
|
|
if (!p || !c) {
|
|
matchInd.className = 'match-indicator';
|
|
matchInd.textContent = '';
|
|
} else if (p === c) {
|
|
matchInd.className = 'match-indicator ok';
|
|
matchInd.textContent = '✓';
|
|
} else {
|
|
matchInd.className = 'match-indicator bad';
|
|
matchInd.textContent = '✗';
|
|
}
|
|
}
|
|
|
|
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
|
|
if (create) {
|
|
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
|
|
create.disabled = disabled;
|
|
create.title = disabled
|
|
? (score < 3
|
|
? 'passphrase must score "good" or better'
|
|
: !state.passphraseConfirm ? 'confirm your passphrase'
|
|
: !matchOk ? 'passphrases do not match'
|
|
: '')
|
|
: '';
|
|
}
|
|
}
|
|
|
|
// --- Render ---
|
|
|
|
function render(): void {
|
|
const app = document.getElementById('app');
|
|
if (!app) return;
|
|
|
|
const progressHtml = `
|
|
<div class="progress-bar">
|
|
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
|
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
|
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
|
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
|
</div>
|
|
`;
|
|
|
|
let stepHtml = '';
|
|
switch (state.step) {
|
|
case 1: stepHtml = renderStep1(); break;
|
|
case 2: stepHtml = renderStep2(); break;
|
|
case 3: stepHtml = renderStep3(); break;
|
|
case 4: stepHtml = renderStep4(); break;
|
|
}
|
|
|
|
app.innerHTML = `
|
|
<div class="pad" style="padding-top:12px;">
|
|
<img class="brand-logo" src="icons/relicario-logo.svg" alt="" style="margin-bottom:12px;">
|
|
<div class="brand" style="margin-bottom:4px;">relicario vault setup</div>
|
|
${progressHtml}
|
|
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
|
${stepHtml}
|
|
</div>
|
|
`;
|
|
|
|
switch (state.step) {
|
|
case 1: attachStep1(); break;
|
|
case 2: attachStep2(); break;
|
|
case 3: attachStep3(); break;
|
|
case 4: attachStep4(); break;
|
|
}
|
|
}
|
|
|
|
// --- Step 1: Choose Host ---
|
|
|
|
function renderStep1(): string {
|
|
const giteaInstructions = `
|
|
<div class="step-instructions">
|
|
<ol>
|
|
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
|
|
<li>Go to <strong>Settings → Applications</strong></li>
|
|
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
|
|
<li>Copy the token — you will need it in the next step</li>
|
|
</ol>
|
|
</div>
|
|
`;
|
|
|
|
const githubInstructions = `
|
|
<div class="step-instructions">
|
|
<ol>
|
|
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
|
|
<li>Go to <strong>Settings → Developer settings → Personal access tokens → Fine-grained tokens</strong></li>
|
|
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
|
|
<li>Copy the token — you will need it in the next step</li>
|
|
</ol>
|
|
</div>
|
|
`;
|
|
|
|
return `
|
|
<div class="wizard-step">
|
|
<h3>choose host</h3>
|
|
<div class="form-group">
|
|
<label class="label">host type</label>
|
|
<div class="toggle-group">
|
|
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
|
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
|
</div>
|
|
</div>
|
|
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
|
|
<div class="form-actions">
|
|
<button class="btn btn-primary" id="next-btn">next</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachStep1(): void {
|
|
document.querySelectorAll('.toggle-group button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
|
state.connectionTested = false;
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.getElementById('next-btn')?.addEventListener('click', () => {
|
|
state.step = 2;
|
|
state.error = null;
|
|
render();
|
|
});
|
|
}
|
|
|
|
// --- Step 2: Configure Connection ---
|
|
|
|
function renderStep2(): string {
|
|
return `
|
|
<div class="wizard-step">
|
|
<h3>configure connection</h3>
|
|
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
|
<label class="label" for="host-url">host url</label>
|
|
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="repo-path">repository path</label>
|
|
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="label" for="api-token">api token</label>
|
|
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn" id="test-btn">test connection</button>
|
|
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
|
|
</div>
|
|
<div class="form-actions" style="margin-top:12px;">
|
|
<button class="btn" id="back-btn">back</button>
|
|
<button class="btn btn-primary" id="next-btn" ${!state.connectionTested ? 'disabled' : ''}>next</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachStep2(): void {
|
|
document.getElementById('test-btn')?.addEventListener('click', async () => {
|
|
const hostUrl = state.hostType === 'github'
|
|
? 'https://api.github.com'
|
|
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
|
|
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
|
|
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
|
|
|
|
if (!repoPath || !apiToken) {
|
|
state.error = 'Repository path and API token are required';
|
|
render();
|
|
return;
|
|
}
|
|
if (state.hostType === 'gitea' && !hostUrl) {
|
|
state.error = 'Host URL is required for Gitea';
|
|
render();
|
|
return;
|
|
}
|
|
|
|
state.hostUrl = hostUrl;
|
|
state.repoPath = repoPath;
|
|
state.apiToken = apiToken;
|
|
|
|
try {
|
|
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
|
|
await host.listDir('');
|
|
state.connectionTested = true;
|
|
state.error = null;
|
|
} catch (err: unknown) {
|
|
state.connectionTested = false;
|
|
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
}
|
|
render();
|
|
});
|
|
|
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
|
state.step = 1;
|
|
state.error = null;
|
|
render();
|
|
});
|
|
|
|
document.getElementById('next-btn')?.addEventListener('click', () => {
|
|
if (!state.connectionTested) return;
|
|
state.step = 3;
|
|
state.error = null;
|
|
render();
|
|
});
|
|
}
|
|
|
|
// --- Step 3: Create Vault ---
|
|
|
|
function renderStep3(): string {
|
|
const score = state.passphraseScore;
|
|
const guessesLog10 = state.passphraseGuessesLog10;
|
|
const hasScore = score >= 0;
|
|
const meterClass = hasScore ? `s${score}` : '';
|
|
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
|
|
const labelClass = labelMeta?.cls ?? '';
|
|
const labelText = labelMeta?.text ?? ' ';
|
|
const entropy = entropyText(guessesLog10);
|
|
|
|
const p = state.passphrase;
|
|
const c = state.passphraseConfirm;
|
|
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
|
|
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
|
|
|
|
const pType = state.passphraseVisible ? 'text' : 'password';
|
|
const cType = state.confirmVisible ? 'text' : 'password';
|
|
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
|
const cToggle = state.confirmVisible ? 'hide' : 'show';
|
|
|
|
const matchOk = !c || p === c;
|
|
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
|
|
|
|
const nChars = p.length;
|
|
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
|
|
|
|
return `
|
|
<div class="wizard-step">
|
|
<h3>create vault</h3>
|
|
|
|
<div class="form-group">
|
|
<label class="label">carrier image (JPEG)</label>
|
|
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
|
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
|
${state.carrierImageBytes
|
|
? '<p class="secondary">image loaded</p>'
|
|
: '<p class="secondary">click to select a JPEG photo</p>'}
|
|
</div>
|
|
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
|
</div>
|
|
|
|
<div class="pass-help">
|
|
A long phrase of unrelated words is stronger than a short complex password.
|
|
Your vault needs <strong>good</strong> (score ≥ 3) to continue.
|
|
</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 a strong passphrase" autocomplete="new-password">
|
|
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
|
|
</div>
|
|
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
|
|
<div class="seg i0"></div>
|
|
<div class="seg i1"></div>
|
|
<div class="seg i2"></div>
|
|
<div class="seg i3"></div>
|
|
<div class="seg i4"></div>
|
|
</div>
|
|
<div class="strength-row">
|
|
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
|
|
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
|
|
</div>
|
|
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
|
<div class="passphrase-field">
|
|
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
|
|
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
|
|
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button class="btn" id="back-btn">back</button>
|
|
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
|
|
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachStep3(): void {
|
|
const fileDrop = document.getElementById('file-drop')!;
|
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
|
|
|
fileDrop.addEventListener('click', () => fileInput.click());
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
const file = fileInput.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
|
state.error = null;
|
|
render();
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
|
|
// Track passphrase changes inline (no full re-render) so the input keeps focus.
|
|
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
|
|
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
|
passInput?.addEventListener('input', (e) => {
|
|
state.passphrase = (e.target as HTMLInputElement).value;
|
|
// Update char counter + match indicator + button gate immediately on every keystroke.
|
|
updateStrengthUi();
|
|
// Score updates on the 150ms debounce to avoid SW hammering.
|
|
scheduleRate(state.passphrase, (s) => {
|
|
state.passphraseScore = s.score;
|
|
state.passphraseGuessesLog10 = s.guessesLog10;
|
|
updateStrengthUi();
|
|
});
|
|
});
|
|
|
|
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
|
|
confirmInput?.addEventListener('input', (e) => {
|
|
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
|
updateStrengthUi();
|
|
});
|
|
|
|
// Eye toggles — flip the input type and label without a full re-render so
|
|
// focus + cursor position survive the click.
|
|
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('confirm-eye-btn')?.addEventListener('click', () => {
|
|
state.confirmVisible = !state.confirmVisible;
|
|
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
|
|
const btn = document.getElementById('confirm-eye-btn');
|
|
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
|
|
confirmInput?.focus();
|
|
});
|
|
|
|
document.getElementById('back-btn')?.addEventListener('click', () => {
|
|
state.step = 2;
|
|
state.error = null;
|
|
render();
|
|
});
|
|
|
|
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
|
// Read current values from DOM
|
|
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
|
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
|
|
|
|
if (!state.carrierImageBytes) {
|
|
state.error = 'Please select a carrier JPEG image';
|
|
render();
|
|
return;
|
|
}
|
|
if (!state.passphrase) {
|
|
state.error = 'Passphrase is required';
|
|
render();
|
|
return;
|
|
}
|
|
// Re-rate synchronously in case the button was clicked before the
|
|
// debounced rater fired. Defence in depth — the button is already
|
|
// disabled in the UI when score < 3 (audit H3).
|
|
const strength = await ratePassphrase(state.passphrase);
|
|
state.passphraseScore = strength.score;
|
|
state.passphraseGuessesLog10 = strength.guessesLog10;
|
|
if (state.passphraseScore < 3) {
|
|
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
|
|
render();
|
|
return;
|
|
}
|
|
if (state.passphrase !== state.passphraseConfirm) {
|
|
state.error = 'Passphrases do not match';
|
|
render();
|
|
return;
|
|
}
|
|
|
|
state.creating = true;
|
|
state.error = null;
|
|
render();
|
|
|
|
// Structured logging so silent failures become visible in DevTools.
|
|
// eslint-disable-next-line no-console
|
|
const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? '');
|
|
|
|
let stage = 'init';
|
|
try {
|
|
stage = 'load wasm';
|
|
log(stage);
|
|
const w = await loadWasm();
|
|
|
|
stage = 'generate image secret';
|
|
log(stage);
|
|
const imageSecret = new Uint8Array(32);
|
|
crypto.getRandomValues(imageSecret);
|
|
|
|
stage = 'embed image secret';
|
|
log(stage, { carrierBytes: state.carrierImageBytes.byteLength });
|
|
state.referenceImageBytes = new Uint8Array(
|
|
w.embed_image_secret(state.carrierImageBytes, imageSecret),
|
|
);
|
|
log('embedded', { referenceBytes: state.referenceImageBytes.byteLength });
|
|
|
|
stage = 'generate salt';
|
|
const salt = new Uint8Array(32);
|
|
crypto.getRandomValues(salt);
|
|
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
|
|
|
stage = 'derive session handle';
|
|
log(stage);
|
|
// unlock() takes JPEG bytes with embedded secret (it extracts internally),
|
|
// not the raw 32-byte secret.
|
|
const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson);
|
|
log('handle acquired');
|
|
|
|
stage = 'encrypt empty manifest';
|
|
log(stage);
|
|
const manifestJson = '{"schema_version":2,"items":{}}';
|
|
const encryptedManifest = w.manifest_encrypt(handle, manifestJson);
|
|
log('manifest encrypted', { bytes: encryptedManifest.length });
|
|
|
|
stage = 'push vault files';
|
|
log(stage);
|
|
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
|
|
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
|
|
|
log('write .relicario/salt');
|
|
await host.writeFile('.relicario/salt', salt, 'init: vault salt');
|
|
|
|
log('write .relicario/params.json');
|
|
const paramsBytes = new TextEncoder().encode(paramsJson);
|
|
await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters');
|
|
|
|
log('write .relicario/devices.json');
|
|
const devicesJson = '{"devices":[]}';
|
|
const devicesBytes = new TextEncoder().encode(devicesJson);
|
|
await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry');
|
|
|
|
log('write manifest.enc');
|
|
await host.writeFile(
|
|
'manifest.enc',
|
|
new Uint8Array(encryptedManifest),
|
|
'init: encrypted manifest',
|
|
);
|
|
|
|
stage = 'release handle';
|
|
w.lock(handle);
|
|
|
|
log('vault created — advancing to step 4');
|
|
state.creating = false;
|
|
state.step = 4;
|
|
state.error = null;
|
|
detectExtension();
|
|
render();
|
|
} catch (err: unknown) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err);
|
|
state.creating = false;
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
state.error = `Vault creation failed at "${stage}": ${detail}`;
|
|
render();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Step 4: Finish ---
|
|
|
|
function detectExtension(): void {
|
|
try {
|
|
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
|
|
// Try to ping the extension
|
|
chrome.runtime.sendMessage({ type: 'is_unlocked' }, (response) => {
|
|
if (chrome.runtime.lastError) {
|
|
state.extensionDetected = false;
|
|
} else {
|
|
state.extensionDetected = true;
|
|
}
|
|
render();
|
|
});
|
|
}
|
|
} catch {
|
|
state.extensionDetected = false;
|
|
}
|
|
}
|
|
|
|
function renderStep4(): string {
|
|
const config: VaultConfig = {
|
|
hostType: state.hostType,
|
|
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
|
repoPath: state.repoPath,
|
|
apiToken: state.apiToken,
|
|
};
|
|
|
|
const configJson = JSON.stringify(config, null, 2);
|
|
|
|
return `
|
|
<div class="wizard-step">
|
|
<div class="success-box">
|
|
<h3>vault created</h3>
|
|
<p class="secondary">Your vault has been initialized and pushed to the repository.</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="label">reference image</label>
|
|
<p class="muted" style="margin-bottom:8px;">
|
|
Download and store this image securely. It is your second factor for decryption.
|
|
Without it, you cannot unlock the vault.
|
|
</p>
|
|
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
|
|
</div>
|
|
|
|
${state.extensionDetected ? `
|
|
<div class="form-group" style="margin-top:16px;">
|
|
<label class="label">extension configuration</label>
|
|
<button class="btn btn-primary" id="push-config-btn" ${state.configPushed ? 'disabled' : ''}>
|
|
${state.configPushed ? 'config saved to extension' : 'save config to extension'}
|
|
</button>
|
|
${state.configPushed ? '<span class="test-result pass" style="margin-left:8px;">saved</span>' : ''}
|
|
</div>
|
|
` : `
|
|
<div class="form-group" style="margin-top:16px;">
|
|
<label class="label">extension configuration</label>
|
|
<p class="muted" style="margin-bottom:8px;">
|
|
Copy this JSON and paste it into the extension setup, or save it for later.
|
|
</p>
|
|
<div class="config-blob" id="config-blob">${escapeHtml(configJson)}</div>
|
|
<button class="btn" id="copy-config-btn">copy to clipboard</button>
|
|
</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachStep4(): void {
|
|
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
|
if (!state.referenceImageBytes) return;
|
|
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'reference.jpg';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
document.getElementById('push-config-btn')?.addEventListener('click', async () => {
|
|
if (!state.referenceImageBytes) return;
|
|
|
|
const config: VaultConfig = {
|
|
hostType: state.hostType,
|
|
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
|
repoPath: state.repoPath,
|
|
apiToken: state.apiToken,
|
|
};
|
|
|
|
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes);
|
|
|
|
try {
|
|
chrome.runtime.sendMessage(
|
|
{ type: 'save_setup', config, imageBase64 },
|
|
(response: { ok: boolean; error?: string }) => {
|
|
if (response?.ok) {
|
|
state.configPushed = true;
|
|
} else {
|
|
state.error = response?.error ?? 'Failed to save config to extension';
|
|
}
|
|
render();
|
|
},
|
|
);
|
|
} catch (err: unknown) {
|
|
state.error = `Failed to communicate with extension: ${err instanceof Error ? err.message : String(err)}`;
|
|
render();
|
|
}
|
|
});
|
|
|
|
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
|
|
const blob = document.getElementById('config-blob');
|
|
if (!blob) return;
|
|
try {
|
|
await navigator.clipboard.writeText(blob.textContent ?? '');
|
|
const btn = document.getElementById('copy-config-btn')!;
|
|
btn.textContent = 'copied!';
|
|
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
|
|
} catch {
|
|
// Fallback: select the text
|
|
const range = document.createRange();
|
|
range.selectNodeContents(blob);
|
|
const sel = window.getSelection();
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(range);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Boot ---
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
render();
|
|
});
|