feat(ext/setup): second-factor container choice (image | key file)

This commit is contained in:
adlee-was-taken
2026-06-25 21:06:58 -04:00
parent 79284979c1
commit a25f8e8a30
2 changed files with 60 additions and 15 deletions

View File

@@ -0,0 +1,24 @@
import { describe, it, expect, afterEach } from 'vitest';
import { state, clearWizardState, renderVaultNew } from '../setup-steps';
describe('renderVaultNew — second-factor container choice', () => {
afterEach(() => clearWizardState());
it('default (image) mode shows the stego note', () => {
const html = renderVaultNew();
expect(html).toContain('A 256-bit secret will be steganographically embedded');
});
it('keyfile mode shows "Key File" and hides the carrier drop / stego note', () => {
state.secondFactor = 'keyfile';
const html = renderVaultNew();
expect(html).toContain('Key File');
expect(html).not.toContain('A 256-bit secret will be steganographically embedded');
});
it('keyfile mode shows the key-file note copy', () => {
state.secondFactor = 'keyfile';
const html = renderVaultNew();
expect(html).toContain('A 32-byte key file will be generated');
});
});

View File

@@ -45,6 +45,7 @@ export interface WizardState {
connectionTested: boolean;
vaultProbe: VaultProbe | null;
carrierImageBytes: Uint8Array | null;
secondFactor: 'image' | 'keyfile';
referenceImageBytesAttach: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
@@ -61,7 +62,7 @@ export interface WizardState {
export const state: WizardState = {
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
connectionTested: false, vaultProbe: null, carrierImageBytes: null, secondFactor: 'image', referenceImageBytesAttach: null,
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
creating: false, attaching: false, error: null, deviceName: '',
@@ -375,7 +376,7 @@ function renderVaultAttach(): string {
</div>`;
}
function renderVaultNew(): string {
export function renderVaultNew(): string {
const score = state.passphraseScore;
const hasScore = score >= 0;
const meterClass = hasScore ? `s${score}` : '';
@@ -397,6 +398,14 @@ function renderVaultNew(): string {
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>create vault</h3>
<div class="form-group">
<label class="label">second factor</label>
<div class="toggle-group">
<button class="${state.secondFactor === 'image' ? 'active' : ''}" data-factor="image">Reference Image</button>
<button class="${state.secondFactor === 'keyfile' ? 'active' : ''}" data-factor="keyfile">Key File</button>
</div>
</div>
${state.secondFactor === 'image' ? `
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
@@ -404,7 +413,10 @@ function renderVaultNew(): string {
${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>` : `
<div class="form-group">
<p class="muted" style="margin-top:4px;">A 32-byte key file will be generated for you to save — it is your second factor.</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&nbsp;≥&nbsp;3) to continue.</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
@@ -492,20 +504,28 @@ function attachVaultAttach(ctx: StepContext): () => void {
}
function attachVaultNew(ctx: StepContext): () => 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;
document.querySelectorAll('[data-factor]').forEach((btn) => {
btn.addEventListener('click', () => {
state.secondFactor = (btn as HTMLElement).dataset.factor as 'image' | 'keyfile';
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
});
if (state.secondFactor === 'image') {
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;
ctx.rerender();
};
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;
@@ -778,6 +798,7 @@ export function clearWizardState(): void {
state.connectionTested = false;
state.vaultProbe = null;
state.carrierImageBytes = null;
state.secondFactor = 'image';
state.referenceImageBytesAttach = null;
state.passphrase = '';
state.passphraseConfirm = '';