feat(ext/setup): download the generated .relkey at finish + skip carrier gate in keyfile mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB
This commit is contained in:
adlee-was-taken
2026-06-26 22:07:14 -04:00
parent d3f46389e4
commit 2f7a034a1b
2 changed files with 80 additions and 19 deletions

View File

@@ -1,5 +1,15 @@
import { describe, it, expect, afterEach } from 'vitest';
import { state, clearWizardState, renderVaultNew } from '../setup-steps';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { state, clearWizardState, renderVaultNew, triggerRelkeyDownload } from '../setup-steps';
describe('triggerRelkeyDownload', () => {
afterEach(() => clearWizardState());
it('triggers a vault.relkey download from the relkey bytes', () => {
const dl = vi.fn();
triggerRelkeyDownload(new Uint8Array([1, 2, 3]), dl);
expect(dl).toHaveBeenCalledWith('vault.relkey', expect.any(Blob));
});
});
describe('renderVaultNew — second-factor container choice', () => {
afterEach(() => clearWizardState());

View File

@@ -54,6 +54,7 @@ export interface WizardState {
passphraseVisible: boolean;
confirmVisible: boolean;
referenceImageBytes: Uint8Array | null;
relkeyBytes: Uint8Array | null;
creating: boolean;
attaching: boolean;
error: string | null;
@@ -64,7 +65,7 @@ export const state: WizardState = {
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, relkeyBytes: null,
creating: false, attaching: false, error: null, deviceName: '',
};
@@ -563,7 +564,7 @@ function attachVaultNew(ctx: StepContext): () => void {
document.getElementById('create-btn')?.addEventListener('click', async () => {
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
if (!state.carrierImageBytes) {
if (state.secondFactor === 'image' && !state.carrierImageBytes) {
state.error = 'Please select a carrier JPEG image';
ctx.rerender();
return;
@@ -650,19 +651,36 @@ const deviceStep: SetupStep = {
} else {
state.creating = true;
ctx.rerender();
const resp = await swSend({
type: 'create_vault',
config: vaultConfig(),
passphrase: state.passphrase,
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.creating = false;
if (resp.ok) {
const data = resp.data as { referenceImageBytes: Uint8Array };
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
ctx.goto('done');
} else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
let resp;
if (state.secondFactor === 'keyfile') {
resp = await swSend({
type: 'create_vault',
config: vaultConfig(),
passphrase: state.passphrase,
secondFactor: 'keyfile',
deviceName: state.deviceName,
});
state.creating = false;
if (resp.ok) {
const data = resp.data as { relkeyBytes: Uint8Array };
state.relkeyBytes = new Uint8Array(data.relkeyBytes);
ctx.goto('done');
} else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
} else {
resp = await swSend({
type: 'create_vault',
config: vaultConfig(),
passphrase: state.passphrase,
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.creating = false;
if (resp.ok) {
const data = resp.data as { referenceImageBytes: Uint8Array };
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
ctx.goto('done');
} else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
}
}
});
return () => {};
@@ -675,19 +693,25 @@ const doneStep: SetupStep = {
id: 'done',
render() {
const isAttach = state.mode === 'attach';
const isKeyfile = !isAttach && state.secondFactor === 'keyfile';
const qrBannerHtml = isAttach ? '' : `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.</p>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your second factor, this QR lets you recover your vault. Print it and store it safely.</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>`;
const refSection = isAttach ? '' : `
const refSection = isAttach ? '' : isKeyfile ? `
<div class="form-group">
<label class="label">key file</label>
<p class="muted" style="margin-bottom:8px;">Save this key file — it is your second factor. Without it you cannot unlock your vault.</p>
<button class="btn btn-primary" id="download-relkey-btn">download vault.relkey</button>
</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>
@@ -756,6 +780,10 @@ const doneStep: SetupStep = {
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('download-relkey-btn')?.addEventListener('click', () => {
if (!state.relkeyBytes) return;
triggerRelkeyDownload(state.relkeyBytes);
});
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
const blob = document.getElementById('config-blob');
if (!blob) return;
@@ -783,6 +811,27 @@ export const STEPS: ReadonlyArray<SetupStep> = [
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
];
// --- Relkey download helper ---
function defaultBlobDownload(name: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function triggerRelkeyDownload(
relkeyBytes: Uint8Array,
trigger: (name: string, blob: Blob) => void = defaultBlobDownload
): void {
const blob = new Blob([relkeyBytes.buffer as ArrayBuffer], { type: 'application/octet-stream' });
trigger('vault.relkey', blob);
}
// --- Sensitive-state cleanup ---
export function clearWizardState(): void {
@@ -790,6 +839,7 @@ export function clearWizardState(): void {
state.carrierImageBytes?.fill(0);
state.referenceImageBytes?.fill(0);
state.referenceImageBytesAttach?.fill(0);
state.relkeyBytes?.fill(0);
state.mode = null;
state.hostType = 'gitea';
state.hostUrl = '';
@@ -807,6 +857,7 @@ export function clearWizardState(): void {
state.passphraseVisible = false;
state.confirmVisible = false;
state.referenceImageBytes = null;
state.relkeyBytes = null;
state.creating = false;
state.attaching = false;
state.error = null;