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 { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { state, clearWizardState, renderVaultNew } from '../setup-steps';
|
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', () => {
|
describe('renderVaultNew — second-factor container choice', () => {
|
||||||
afterEach(() => clearWizardState());
|
afterEach(() => clearWizardState());
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface WizardState {
|
|||||||
passphraseVisible: boolean;
|
passphraseVisible: boolean;
|
||||||
confirmVisible: boolean;
|
confirmVisible: boolean;
|
||||||
referenceImageBytes: Uint8Array | null;
|
referenceImageBytes: Uint8Array | null;
|
||||||
|
relkeyBytes: Uint8Array | null;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
attaching: boolean;
|
attaching: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -64,7 +65,7 @@ export const state: WizardState = {
|
|||||||
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
|
||||||
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
|
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
|
||||||
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
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: '',
|
creating: false, attaching: false, error: null, deviceName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -563,7 +564,7 @@ function attachVaultNew(ctx: StepContext): () => void {
|
|||||||
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
||||||
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
||||||
state.passphraseConfirm = (document.getElementById('passphrase-confirm') 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';
|
state.error = 'Please select a carrier JPEG image';
|
||||||
ctx.rerender();
|
ctx.rerender();
|
||||||
return;
|
return;
|
||||||
@@ -650,19 +651,36 @@ const deviceStep: SetupStep = {
|
|||||||
} else {
|
} else {
|
||||||
state.creating = true;
|
state.creating = true;
|
||||||
ctx.rerender();
|
ctx.rerender();
|
||||||
const resp = await swSend({
|
let resp;
|
||||||
type: 'create_vault',
|
if (state.secondFactor === 'keyfile') {
|
||||||
config: vaultConfig(),
|
resp = await swSend({
|
||||||
passphrase: state.passphrase,
|
type: 'create_vault',
|
||||||
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
|
config: vaultConfig(),
|
||||||
deviceName: state.deviceName,
|
passphrase: state.passphrase,
|
||||||
});
|
secondFactor: 'keyfile',
|
||||||
state.creating = false;
|
deviceName: state.deviceName,
|
||||||
if (resp.ok) {
|
});
|
||||||
const data = resp.data as { referenceImageBytes: Uint8Array };
|
state.creating = false;
|
||||||
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
|
if (resp.ok) {
|
||||||
ctx.goto('done');
|
const data = resp.data as { relkeyBytes: Uint8Array };
|
||||||
} else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
|
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 () => {};
|
return () => {};
|
||||||
@@ -675,19 +693,25 @@ const doneStep: SetupStep = {
|
|||||||
id: 'done',
|
id: 'done',
|
||||||
render() {
|
render() {
|
||||||
const isAttach = state.mode === 'attach';
|
const isAttach = state.mode === 'attach';
|
||||||
|
const isKeyfile = !isAttach && state.secondFactor === 'keyfile';
|
||||||
const qrBannerHtml = isAttach ? '' : `
|
const qrBannerHtml = isAttach ? '' : `
|
||||||
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
|
||||||
<div class="recovery-qr-banner__header">
|
<div class="recovery-qr-banner__header">
|
||||||
<span style="font-size:20px;">◫</span>
|
<span style="font-size:20px;">◫</span>
|
||||||
<strong>Generate a recovery QR before you go</strong>
|
<strong>Generate a recovery QR before you go</strong>
|
||||||
</div>
|
</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">
|
<div class="recovery-qr-banner__actions">
|
||||||
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
|
<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>
|
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label class="label">reference image</label>
|
<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>
|
<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);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
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 () => {
|
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
|
||||||
const blob = document.getElementById('config-blob');
|
const blob = document.getElementById('config-blob');
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
@@ -783,6 +811,27 @@ export const STEPS: ReadonlyArray<SetupStep> = [
|
|||||||
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
|
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 ---
|
// --- Sensitive-state cleanup ---
|
||||||
|
|
||||||
export function clearWizardState(): void {
|
export function clearWizardState(): void {
|
||||||
@@ -790,6 +839,7 @@ export function clearWizardState(): void {
|
|||||||
state.carrierImageBytes?.fill(0);
|
state.carrierImageBytes?.fill(0);
|
||||||
state.referenceImageBytes?.fill(0);
|
state.referenceImageBytes?.fill(0);
|
||||||
state.referenceImageBytesAttach?.fill(0);
|
state.referenceImageBytesAttach?.fill(0);
|
||||||
|
state.relkeyBytes?.fill(0);
|
||||||
state.mode = null;
|
state.mode = null;
|
||||||
state.hostType = 'gitea';
|
state.hostType = 'gitea';
|
||||||
state.hostUrl = '';
|
state.hostUrl = '';
|
||||||
@@ -807,6 +857,7 @@ export function clearWizardState(): void {
|
|||||||
state.passphraseVisible = false;
|
state.passphraseVisible = false;
|
||||||
state.confirmVisible = false;
|
state.confirmVisible = false;
|
||||||
state.referenceImageBytes = null;
|
state.referenceImageBytes = null;
|
||||||
|
state.relkeyBytes = null;
|
||||||
state.creating = false;
|
state.creating = false;
|
||||||
state.attaching = false;
|
state.attaching = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user