diff --git a/extension/src/setup/__tests__/setup-steps.test.ts b/extension/src/setup/__tests__/setup-steps.test.ts index e8212ad..e574f81 100644 --- a/extension/src/setup/__tests__/setup-steps.test.ts +++ b/extension/src/setup/__tests__/setup-steps.test.ts @@ -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()); diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts index db8984b..e9f858a 100644 --- a/extension/src/setup/setup-steps.ts +++ b/extension/src/setup/setup-steps.ts @@ -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 ? '' : `
Generate a recovery QR before you go
-

If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.

+

If you lose your second factor, this QR lets you recover your vault. Print it and store it safely.

`; - const refSection = isAttach ? '' : ` + const refSection = isAttach ? '' : isKeyfile ? ` +
+ +

Save this key file — it is your second factor. Without it you cannot unlock your vault.

+ +
` : `

Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.

@@ -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 = [ 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;