feat(ext/setup+sw): attach via key file when the vault uses one (probe second_factor, .relkey picker, handleAttachVault keyfile branch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// HELD: Task 2 adds keyfile branch to handleCreateVault + Task 4 extracts handleUnlock.
|
||||
// Both tests are intentionally RED until the respective tasks land after Dev-D merges.
|
||||
// Do NOT commit this file while it is red.
|
||||
// Keyfile-mode SW coverage: handleCreateVault keyfile branch (Task 2), handleUnlock
|
||||
// second-factor resolution (Task 4), and handleAttachVault keyfile branch (Task 5).
|
||||
// All green now that Tasks 2/4 have landed and Task 5 wires the attach path.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vault from '../vault';
|
||||
@@ -190,6 +190,91 @@ describe('handleCreateVault — keyfile mode (Task 2)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Task 5: attach_vault keyfile mode ----
|
||||
// handleAttachVault must branch on secondFactor:'keyfile' — decode the uploaded
|
||||
// .relkey, derive via unlock_with_secret (NOT unlock), and persist keyfileBase64.
|
||||
|
||||
describe('handleAttachVault — keyfile mode (Task 5)', () => {
|
||||
let setCurrent: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChromeStorage();
|
||||
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
|
||||
const h = makeHostMock();
|
||||
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||
return h;
|
||||
});
|
||||
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const KF_ATTACH_MSG = {
|
||||
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||
passphrase: 'pw',
|
||||
secondFactor: 'keyfile' as const,
|
||||
keyfileBytes: new Uint8Array([10, 11, 12]).buffer,
|
||||
deviceName: 'Dev2',
|
||||
};
|
||||
|
||||
it('decodes the .relkey and unlocks with the secret (not unlock), storing keyfileBase64', async () => {
|
||||
const keyfileHandle = makeFakeHandle();
|
||||
const wasm = makeWasm({
|
||||
keyfile_decode: vi.fn(() => new Uint8Array(32)),
|
||||
unlock_with_secret: vi.fn(() => keyfileHandle),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(
|
||||
KF_ATTACH_MSG as Parameters<typeof vault.handleAttachVault>[0],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
if (!resp.ok) throw new Error('expected ok:true');
|
||||
expect(resp.data.deviceName).toBe('Dev2');
|
||||
|
||||
// Keyfile path: keyfile_decode + unlock_with_secret; the image unlock must NOT run.
|
||||
expect(wasm.keyfile_decode).toHaveBeenCalled();
|
||||
expect(wasm.unlock_with_secret).toHaveBeenCalled();
|
||||
expect(wasm.unlock).not.toHaveBeenCalled();
|
||||
|
||||
// The existing manifest_decrypt verification still runs; device registered.
|
||||
expect(wasm.manifest_decrypt).toHaveBeenCalled();
|
||||
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
|
||||
|
||||
// chrome.storage.local.set: keyfileBase64 present, imageBase64 absent.
|
||||
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
|
||||
.chrome.storage.local.set.mock.calls;
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [kv] of chromeSets) Object.assign(merged, kv);
|
||||
expect(merged).toHaveProperty('keyfileBase64');
|
||||
expect(merged).not.toHaveProperty('imageBase64');
|
||||
|
||||
expect(setCurrent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns invalid_key_file when keyfile_decode throws (and never unlocks)', async () => {
|
||||
const wasm = makeWasm({
|
||||
keyfile_decode: vi.fn(() => { throw new Error('bad armor'); }),
|
||||
unlock_with_secret: vi.fn(),
|
||||
});
|
||||
const state = makeState(wasm);
|
||||
|
||||
const resp = await vault.handleAttachVault(
|
||||
KF_ATTACH_MSG as Parameters<typeof vault.handleAttachVault>[0],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(resp).toEqual({ ok: false, error: 'invalid_key_file' });
|
||||
expect(wasm.unlock_with_secret).not.toHaveBeenCalled();
|
||||
expect(wasm.register_device).not.toHaveBeenCalled();
|
||||
expect(setCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Task 4: unlock resolves second factor from params hint ----
|
||||
// HELD: Task 4 extracts handleUnlock + adds the keyfile branch; green after Dev-D merges.
|
||||
// IMPORT-red: handleUnlock does not exist yet.
|
||||
|
||||
@@ -140,13 +140,13 @@ export async function handleCreateVault(
|
||||
}
|
||||
|
||||
export async function handleAttachVault(
|
||||
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
|
||||
msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile';
|
||||
referenceImageBytes?: ArrayBuffer; keyfileBytes?: ArrayBuffer; deviceName: string },
|
||||
state: PopupState,
|
||||
): Promise<AttachVaultResponse | { ok: false; error: string }> {
|
||||
const w = state.wasm;
|
||||
let handle: SessionHandle | null = null;
|
||||
try {
|
||||
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
|
||||
const { config } = msg;
|
||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||
|
||||
@@ -156,12 +156,39 @@ export async function handleAttachVault(
|
||||
git.readFile('manifest.enc'),
|
||||
]);
|
||||
|
||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
|
||||
handle = h;
|
||||
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
|
||||
// --- Branch on second factor: derive the unlock handle + factor storage ---
|
||||
// Only the secret derivation and the stored artifact differ; the verify
|
||||
// (manifest_decrypt), device registration, and session wiring are shared.
|
||||
let h: SessionHandle;
|
||||
let factorStorage: { imageBase64: string } | { keyfileBase64: string };
|
||||
|
||||
if (msg.secondFactor === 'keyfile') {
|
||||
// KEY-FILE branch: decode the uploaded .relkey to its 32-byte secret and
|
||||
// derive via unlock_with_secret. We persist the armored .relkey verbatim
|
||||
// (same artifact create stores) so future unlocks can re-derive.
|
||||
const relkeyBytes = new Uint8Array(msg.keyfileBytes!);
|
||||
let secret: Uint8Array;
|
||||
try {
|
||||
secret = w.keyfile_decode(relkeyBytes);
|
||||
} catch {
|
||||
return { ok: false, error: 'invalid_key_file' };
|
||||
}
|
||||
h = w.unlock_with_secret(msg.passphrase, secret, meta.salt, meta.paramsJson) as SessionHandle;
|
||||
handle = h;
|
||||
factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) };
|
||||
} else {
|
||||
// IMAGE branch (default): unlock with the reference JPEG (the secret is
|
||||
// recovered from the embedded steganography inside the WASM unlock).
|
||||
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes!);
|
||||
h = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson) as SessionHandle;
|
||||
handle = h;
|
||||
factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) };
|
||||
}
|
||||
|
||||
// manifest_decrypt verifies the passphrase + second factor — throws on AEAD failure.
|
||||
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
|
||||
|
||||
await registerDeviceAndPersistConfig(w, git, config, { imageBase64: uint8ArrayToBase64(referenceImageBytes) }, msg.deviceName);
|
||||
await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName);
|
||||
|
||||
// SW now owns the unlocked session — transfer ownership to the session.
|
||||
session.setCurrent(h);
|
||||
|
||||
@@ -6,6 +6,7 @@ function fakeHost(opts: {
|
||||
relicarioFiles?: string[];
|
||||
rootFiles?: string[];
|
||||
commit?: { sha: string; author: string; date: string } | null;
|
||||
paramsJson?: string;
|
||||
} = {}): GitHost {
|
||||
return {
|
||||
listDir: vi.fn().mockImplementation(async (p: string) => {
|
||||
@@ -14,7 +15,14 @@ function fakeHost(opts: {
|
||||
return [];
|
||||
}),
|
||||
lastCommit: vi.fn().mockResolvedValue(opts.commit ?? null),
|
||||
readFile: vi.fn(), writeFile: vi.fn(), writeFileCreateOnly: vi.fn(),
|
||||
readFile: vi.fn().mockImplementation(async (p: string) => {
|
||||
if (p === '.relicario/params.json') {
|
||||
if (opts.paramsJson === undefined) throw new Error(`404: ${p}`);
|
||||
return new TextEncoder().encode(opts.paramsJson);
|
||||
}
|
||||
throw new Error(`404: ${p}`);
|
||||
}),
|
||||
writeFile: vi.fn(), writeFileCreateOnly: vi.fn(),
|
||||
deleteFile: vi.fn(), putBlob: vi.fn(), getBlob: vi.fn(), deleteBlob: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -46,4 +54,24 @@ describe('probeVault', () => {
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.lastCommit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports secondFactor=keyfile when params.json declares it', async () => {
|
||||
const host = fakeHost({
|
||||
relicarioFiles: ['salt', 'params.json'],
|
||||
paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}',
|
||||
});
|
||||
const result = await probeVault(host);
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.secondFactor).toBe('keyfile');
|
||||
});
|
||||
|
||||
it('defaults secondFactor=image when params.json omits second_factor (back-compat)', async () => {
|
||||
const host = fakeHost({
|
||||
relicarioFiles: ['salt', 'params.json'],
|
||||
paramsJson: '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}',
|
||||
});
|
||||
const result = await probeVault(host);
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.secondFactor).toBe('image');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { state, clearWizardState, renderVaultNew, triggerRelkeyDownload } from '../setup-steps';
|
||||
import { state, clearWizardState, renderVaultNew, renderVaultAttach, triggerRelkeyDownload } from '../setup-steps';
|
||||
|
||||
describe('triggerRelkeyDownload', () => {
|
||||
afterEach(() => clearWizardState());
|
||||
@@ -32,3 +32,21 @@ describe('renderVaultNew — second-factor container choice', () => {
|
||||
expect(html).toContain('A 32-byte key file will be generated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderVaultAttach — second-factor picker follows the probed vault', () => {
|
||||
afterEach(() => clearWizardState());
|
||||
|
||||
it('shows the .relkey picker (and hides the JPEG picker) for a keyfile vault', () => {
|
||||
state.vaultProbe = { exists: true, secondFactor: 'keyfile' };
|
||||
const html = renderVaultAttach();
|
||||
expect(html).toContain('key file (.relkey)');
|
||||
expect(html).not.toContain('reference image (JPEG)');
|
||||
});
|
||||
|
||||
it('shows the reference-image picker for an image vault (default)', () => {
|
||||
state.vaultProbe = { exists: true, secondFactor: 'image' };
|
||||
const html = renderVaultAttach();
|
||||
expect(html).toContain('reference image (JPEG)');
|
||||
expect(html).not.toContain('key file (.relkey)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,17 @@ import type { GitHost } from '../service-worker/git-host';
|
||||
export interface VaultProbe {
|
||||
exists: boolean;
|
||||
lastCommit?: { sha: string; author: string; date: string };
|
||||
// The vault's second factor, read from params.json's top-level `second_factor`
|
||||
// (absent ⇒ image). Only populated when `exists` is true. Drives the attach
|
||||
// wizard's picker (reference JPEG vs .relkey).
|
||||
secondFactor?: 'image' | 'keyfile';
|
||||
}
|
||||
|
||||
/// Detect whether the configured remote already contains a relicario vault.
|
||||
/// Considered present if any of: .relicario/salt, .relicario/params.json,
|
||||
/// manifest.enc exists. Best-effort metadata fetch via lastCommit().
|
||||
/// manifest.enc exists. Best-effort metadata fetch via lastCommit(). When a
|
||||
/// vault is present, also resolves its second factor from params.json so the
|
||||
/// attach flow can prompt for the right artifact.
|
||||
export async function probeVault(host: GitHost): Promise<VaultProbe> {
|
||||
const [relicarioFiles, rootFiles] = await Promise.all([
|
||||
host.listDir('.relicario'),
|
||||
@@ -18,6 +24,17 @@ export async function probeVault(host: GitHost): Promise<VaultProbe> {
|
||||
relicarioFiles.includes('params.json') ||
|
||||
rootFiles.includes('manifest.enc');
|
||||
if (!exists) return { exists: false };
|
||||
|
||||
// Resolve the second factor from params.json (top-level `second_factor`).
|
||||
// Tolerate absence or malformed JSON — older vaults predate the field and an
|
||||
// unreadable params.json should not break the probe; default to image.
|
||||
let secondFactor: 'image' | 'keyfile' = 'image';
|
||||
try {
|
||||
const raw = await host.readFile('.relicario/params.json');
|
||||
const sf = (JSON.parse(new TextDecoder().decode(raw)) as { second_factor?: unknown }).second_factor;
|
||||
secondFactor = sf === 'keyfile' ? 'keyfile' : 'image';
|
||||
} catch { /* params.json absent or unparsable — default to image */ }
|
||||
|
||||
const lastCommit = await host.lastCommit('manifest.enc');
|
||||
return lastCommit ? { exists, lastCommit } : { exists };
|
||||
return lastCommit ? { exists, lastCommit, secondFactor } : { exists, secondFactor };
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface WizardState {
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
secondFactor: 'image' | 'keyfile';
|
||||
referenceImageBytesAttach: Uint8Array | null;
|
||||
keyfileBytesAttach: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
passphraseScore: number;
|
||||
@@ -63,7 +64,8 @@ export interface WizardState {
|
||||
|
||||
export const state: WizardState = {
|
||||
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, keyfileBytesAttach: null,
|
||||
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
|
||||
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null, relkeyBytes: null,
|
||||
creating: false, attaching: false, error: null, deviceName: '',
|
||||
@@ -345,24 +347,40 @@ const connectionStep: SetupStep = {
|
||||
|
||||
// --- vault ---
|
||||
|
||||
function renderVaultAttach(): string {
|
||||
export function renderVaultAttach(): string {
|
||||
const p = state.passphrase;
|
||||
const pType = state.passphraseVisible ? 'text' : 'password';
|
||||
const pToggle = state.passphraseVisible ? 'hide' : 'show';
|
||||
const hasImage = !!state.referenceImageBytesAttach;
|
||||
const gateDisabled = !p || !hasImage;
|
||||
// Mirror the probed vault's second factor — a keyfile vault needs the .relkey,
|
||||
// an image vault needs the reference JPEG. Default to image (back-compat).
|
||||
const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile';
|
||||
const hasFactor = isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach;
|
||||
const gateDisabled = !p || !hasFactor;
|
||||
const intro = isKeyfile
|
||||
? 'Use your existing passphrase and key file (.relkey) to attach this browser to your vault. We\'ll verify both when you register this device.'
|
||||
: 'Use your existing passphrase and reference image to attach this browser to your vault. We\'ll verify both when you register this device.';
|
||||
const factorGroup = isKeyfile ? `
|
||||
<div class="form-group">
|
||||
<label class="label">key file (.relkey)</label>
|
||||
<div class="file-drop ${hasFactor ? 'has-file' : ''}" id="kf-drop">
|
||||
<input type="file" id="kf-input" accept=".relkey,text/plain" style="display:none;">
|
||||
${hasFactor ? '<p class="secondary">key file loaded</p>' : '<p class="secondary">click to select your .relkey file</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">The key file is the <code>.relkey</code> you saved when you first created this vault. It holds the 256-bit secret.</p>
|
||||
</div>` : `
|
||||
<div class="form-group">
|
||||
<label class="label">reference image (JPEG)</label>
|
||||
<div class="file-drop ${hasFactor ? 'has-file' : ''}" id="ref-drop">
|
||||
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
|
||||
${hasFactor ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
|
||||
<h3>attach this device</h3>
|
||||
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
|
||||
<div class="form-group">
|
||||
<label class="label">reference image (JPEG)</label>
|
||||
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
|
||||
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
|
||||
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom:12px;">${intro}</p>
|
||||
${factorGroup}
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<div class="passphrase-field">
|
||||
@@ -460,25 +478,47 @@ const vaultStep: SetupStep = {
|
||||
};
|
||||
|
||||
function attachVaultAttach(ctx: StepContext): () => void {
|
||||
const refDrop = document.getElementById('ref-drop')!;
|
||||
const refInput = document.getElementById('ref-input') as HTMLInputElement;
|
||||
refDrop.addEventListener('click', () => refInput.click());
|
||||
refInput.addEventListener('change', () => {
|
||||
const file = refInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
// Only one picker is rendered (renderVaultAttach branches on the probed second
|
||||
// factor); wire whichever exists. Mirrors attachVaultNew's guard pattern — the
|
||||
// inactive factor's elements are simply not in the DOM.
|
||||
const isKeyfile = state.vaultProbe?.secondFactor === 'keyfile';
|
||||
const hasFactor = () => (isKeyfile ? !!state.keyfileBytesAttach : !!state.referenceImageBytesAttach);
|
||||
if (isKeyfile) {
|
||||
const kfDrop = document.getElementById('kf-drop')!;
|
||||
const kfInput = document.getElementById('kf-input') as HTMLInputElement;
|
||||
kfDrop.addEventListener('click', () => kfInput.click());
|
||||
kfInput.addEventListener('change', () => {
|
||||
const file = kfInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.keyfileBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
} else {
|
||||
const refDrop = document.getElementById('ref-drop')!;
|
||||
const refInput = document.getElementById('ref-input') as HTMLInputElement;
|
||||
refDrop.addEventListener('click', () => refInput.click());
|
||||
refInput.addEventListener('change', () => {
|
||||
const file = refInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
ctx.rerender();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
|
||||
passInput?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
|
||||
if (btn) btn.disabled = !state.passphrase || !hasFactor();
|
||||
});
|
||||
document.getElementById('eye-btn')?.addEventListener('click', () => {
|
||||
state.passphraseVisible = !state.passphraseVisible;
|
||||
@@ -489,8 +529,8 @@ function attachVaultAttach(ctx: StepContext): () => void {
|
||||
});
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
|
||||
document.getElementById('attach-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytesAttach) {
|
||||
state.error = 'Please select your reference JPEG image';
|
||||
if (!hasFactor()) {
|
||||
state.error = isKeyfile ? 'Please select your .relkey key file' : 'Please select your reference JPEG image';
|
||||
ctx.rerender();
|
||||
return;
|
||||
}
|
||||
@@ -638,13 +678,24 @@ const deviceStep: SetupStep = {
|
||||
if (state.mode === 'attach') {
|
||||
state.attaching = true;
|
||||
ctx.rerender();
|
||||
const resp = await swSend({
|
||||
type: 'attach_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
// Send whichever factor the probed vault uses. keyfileBytes/referenceImageBytes
|
||||
// are ArrayBuffers; message-binary.ts base64-envelopes them across the channel.
|
||||
const resp = state.vaultProbe?.secondFactor === 'keyfile'
|
||||
? await swSend({
|
||||
type: 'attach_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
secondFactor: 'keyfile',
|
||||
keyfileBytes: state.keyfileBytesAttach!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
})
|
||||
: await swSend({
|
||||
type: 'attach_vault',
|
||||
config: vaultConfig(),
|
||||
passphrase: state.passphrase,
|
||||
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
|
||||
deviceName: state.deviceName,
|
||||
});
|
||||
state.attaching = false;
|
||||
if (resp.ok) ctx.goto('done');
|
||||
else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
|
||||
@@ -839,6 +890,7 @@ export function clearWizardState(): void {
|
||||
state.carrierImageBytes?.fill(0);
|
||||
state.referenceImageBytes?.fill(0);
|
||||
state.referenceImageBytesAttach?.fill(0);
|
||||
state.keyfileBytesAttach?.fill(0);
|
||||
state.relkeyBytes?.fill(0);
|
||||
state.mode = null;
|
||||
state.hostType = 'gitea';
|
||||
@@ -850,6 +902,7 @@ export function clearWizardState(): void {
|
||||
state.carrierImageBytes = null;
|
||||
state.secondFactor = 'image';
|
||||
state.referenceImageBytesAttach = null;
|
||||
state.keyfileBytesAttach = null;
|
||||
state.passphrase = '';
|
||||
state.passphraseConfirm = '';
|
||||
state.passphraseScore = -1;
|
||||
|
||||
@@ -67,7 +67,8 @@ export type PopupMessage =
|
||||
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
||||
secondFactor?: 'image' | 'keyfile'; carrierImageBytes?: ArrayBuffer; deviceName: string }
|
||||
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
||||
referenceImageBytes: ArrayBuffer; deviceName: string }
|
||||
secondFactor?: 'image' | 'keyfile'; referenceImageBytes?: ArrayBuffer;
|
||||
keyfileBytes?: ArrayBuffer; deviceName: string }
|
||||
| { type: 'get_vault_status' };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
Reference in New Issue
Block a user