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