feat(ext/sw): create_vault key-file mode (generate secret, unlock_with_secret, store keyfileBase64)
- Add KEYFILE_PARAMS_JSON const (same KDF params as default + second_factor:"keyfile" hint)
- Generalize registerDeviceAndPersistConfig: takes factorStorage union instead of
referenceImageBytes Uint8Array; spreads { imageBase64 } or { keyfileBase64 } into
chrome.storage.local.set so exactly one factor key is present
- Branch handleCreateVault on msg.secondFactor === 'keyfile': skip carrier-image
guards and embed_image_secret; call unlock_with_secret + keyfile_encode; share
the file-write / device-registration / session tail with the image path
- Update handleAttachVault call to registerDeviceAndPersistConfig to pass
{ imageBase64: uint8ArrayToBase64(referenceImageBytes) }
- Relax carrierImageBytes to optional in messages.ts create_vault request type
- Skip Task-4 describe block in keyfile-unlock.test.ts (un-skip in Task 4)
TDD: Task-2 test RED → GREEN; Task-4 describe.skipped; full suite 439 passed/1 skipped; build clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014THSV6cA4Gxa7bxFfisHBB
This commit is contained in:
255
extension/src/service-worker/__tests__/keyfile-unlock.test.ts
Normal file
255
extension/src/service-worker/__tests__/keyfile-unlock.test.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as vault from '../vault';
|
||||||
|
import * as session from '../session';
|
||||||
|
import type { PopupState } from '../router/popup-only';
|
||||||
|
import type { GitHost } from '../git-host';
|
||||||
|
import * as gitHostMod from '../git-host';
|
||||||
|
|
||||||
|
// ---- Mirror the makeHostMock / vi.mock / mockChromeStorage harness from vault.test.ts ----
|
||||||
|
|
||||||
|
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
|
||||||
|
const calls: Record<string, unknown[][]> = {
|
||||||
|
writeFileCreateOnly: [],
|
||||||
|
writeFile: [],
|
||||||
|
readFile: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
_calls: calls,
|
||||||
|
readFile: vi.fn().mockImplementation(async (path: string) => {
|
||||||
|
if (path === '.relicario/salt') return new Uint8Array(32);
|
||||||
|
if (path === '.relicario/params.json') {
|
||||||
|
return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}');
|
||||||
|
}
|
||||||
|
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
|
||||||
|
throw new Error(`404: ${path}`);
|
||||||
|
}),
|
||||||
|
writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||||
|
calls.writeFile.push(args);
|
||||||
|
}),
|
||||||
|
writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||||
|
calls.writeFileCreateOnly.push(args);
|
||||||
|
}),
|
||||||
|
deleteFile: vi.fn(),
|
||||||
|
listDir: vi.fn().mockResolvedValue([]),
|
||||||
|
lastCommit: vi.fn().mockResolvedValue(null),
|
||||||
|
putBlob: vi.fn(),
|
||||||
|
getBlob: vi.fn(),
|
||||||
|
deleteBlob: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../git-host', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
|
||||||
|
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = null;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createGitHost: vi.fn().mockImplementation(() => {
|
||||||
|
const h = makeHostMock();
|
||||||
|
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||||
|
return h;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockChromeStorage(initial: Record<string, unknown> = {}) {
|
||||||
|
const store: Record<string, unknown> = { ...initial };
|
||||||
|
(global as { chrome: unknown }).chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn((keys: string | string[]) => {
|
||||||
|
const arr = Array.isArray(keys) ? keys : [keys];
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const k of arr) if (k in store) out[k] = store[k];
|
||||||
|
return Promise.resolve(out);
|
||||||
|
}),
|
||||||
|
set: vi.fn((kv: Record<string, unknown>) => {
|
||||||
|
Object.assign(store, kv);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeHandle() {
|
||||||
|
return { free: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWasm(overrides: Record<string, unknown> = {}) {
|
||||||
|
const fakeHandle = makeFakeHandle();
|
||||||
|
return {
|
||||||
|
_handle: fakeHandle,
|
||||||
|
embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])),
|
||||||
|
unlock: vi.fn(() => fakeHandle),
|
||||||
|
manifest_encrypt: vi.fn(() => new Uint8Array([9])),
|
||||||
|
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
|
||||||
|
default_vault_settings_json: vi.fn(() => '{}'),
|
||||||
|
settings_encrypt: vi.fn(() => new Uint8Array([8])),
|
||||||
|
register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })),
|
||||||
|
lock: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
|
||||||
|
return {
|
||||||
|
manifest: null,
|
||||||
|
gitHost: null,
|
||||||
|
wasm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_MSG = {
|
||||||
|
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
|
||||||
|
passphrase: 'pw',
|
||||||
|
// Must begin with the JPEG SOI magic (0xFF 0xD8) to pass handleCreateVault's
|
||||||
|
// pre-embed format guard; the WASM embed itself is mocked.
|
||||||
|
carrierImageBytes: new Uint8Array([0xff, 0xd8, 0x00]).buffer,
|
||||||
|
deviceName: 'Dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Task 2: create_vault keyfile mode ----
|
||||||
|
// ASSERTION-red: handleCreateVault currently ignores secondFactor.
|
||||||
|
// Will go green when Task 2 adds the keyfile branch.
|
||||||
|
|
||||||
|
describe('handleCreateVault — keyfile mode (Task 2)', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to keyfile path when secondFactor is keyfile', async () => {
|
||||||
|
const keyfileHandle = makeFakeHandle();
|
||||||
|
const wasm = makeWasm({
|
||||||
|
keyfile_encode: vi.fn(() => new Uint8Array([10, 11, 12])),
|
||||||
|
unlock_with_secret: vi.fn(() => keyfileHandle),
|
||||||
|
});
|
||||||
|
const state = makeState(wasm);
|
||||||
|
|
||||||
|
const resp = await vault.handleCreateVault(
|
||||||
|
// secondFactor is already in the messages.ts type but handleCreateVault
|
||||||
|
// does not yet read it — cast to pass it.
|
||||||
|
{ ...BASE_MSG, secondFactor: 'keyfile' } as Parameters<typeof vault.handleCreateVault>[0],
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (!resp.ok) throw new Error('expected ok:true');
|
||||||
|
|
||||||
|
// resp.data.relkeyBytes must be present (the armored .relkey for the wizard to download)
|
||||||
|
expect((resp.data as { relkeyBytes?: Uint8Array }).relkeyBytes).toBeInstanceOf(Uint8Array);
|
||||||
|
|
||||||
|
// Correct WASM path
|
||||||
|
expect(wasm.keyfile_encode).toHaveBeenCalled();
|
||||||
|
expect(wasm.unlock_with_secret).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Image path must NOT have been taken
|
||||||
|
expect(wasm.unlock).not.toHaveBeenCalled();
|
||||||
|
expect(wasm.embed_image_secret).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// params.json writeFileCreateOnly call must embed "second_factor":"keyfile"
|
||||||
|
const fakeHost = (globalThis as {
|
||||||
|
__lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } | null;
|
||||||
|
}).__lastFakeGitHost;
|
||||||
|
expect(fakeHost).not.toBeNull();
|
||||||
|
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
|
||||||
|
const paramsCall = (wfco.mock.calls as unknown[][]).find(
|
||||||
|
(c: unknown[]) => c[0] === '.relicario/params.json',
|
||||||
|
);
|
||||||
|
expect(paramsCall).toBeDefined();
|
||||||
|
const paramsBytes = paramsCall![1] as Uint8Array;
|
||||||
|
const paramsObj = JSON.parse(new TextDecoder().decode(paramsBytes)) as Record<string, unknown>;
|
||||||
|
expect(paramsObj).toHaveProperty('second_factor', 'keyfile');
|
||||||
|
|
||||||
|
// Void session.setCurrent — avoid lint unused-variable
|
||||||
|
void setCurrent;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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.
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const { handleUnlock } = await import('../router/popup-only') as any;
|
||||||
|
|
||||||
|
// un-skip in Task 4
|
||||||
|
describe.skip('handleUnlock — keyfile branch (Task 4)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// chrome.storage.local returns vaultConfig + keyfileBase64 (no imageBase64)
|
||||||
|
const fakeVaultConfig = {
|
||||||
|
hostType: 'gitea',
|
||||||
|
hostUrl: 'https://g',
|
||||||
|
repoPath: 'u/v',
|
||||||
|
apiToken: 't',
|
||||||
|
};
|
||||||
|
// A minimal base64 string for keyfileBase64
|
||||||
|
const keyfileBase64 = btoa(String.fromCharCode(...new Uint8Array(32)));
|
||||||
|
mockChromeStorage({
|
||||||
|
vaultConfig: fakeVaultConfig,
|
||||||
|
keyfileBase64,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The gitHost readFile for params.json must return second_factor:keyfile
|
||||||
|
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
|
||||||
|
const h = makeHostMock();
|
||||||
|
// Override readFile for params.json to include second_factor
|
||||||
|
(h.readFile as ReturnType<typeof vi.fn>).mockImplementation(async (path: string) => {
|
||||||
|
if (path === '.relicario/salt') return new Uint8Array(32);
|
||||||
|
if (path === '.relicario/params.json') {
|
||||||
|
return new TextEncoder().encode(
|
||||||
|
'{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
|
||||||
|
throw new Error(`404: ${path}`);
|
||||||
|
});
|
||||||
|
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
|
||||||
|
return h;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls unlock_with_secret (not unlock) when params hint is keyfile', async () => {
|
||||||
|
const keyfileHandle = makeFakeHandle();
|
||||||
|
const wasm = makeWasm({
|
||||||
|
keyfile_decode: vi.fn(() => new Uint8Array(32)),
|
||||||
|
unlock_with_secret: vi.fn(() => keyfileHandle),
|
||||||
|
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
|
||||||
|
});
|
||||||
|
const state = makeState(wasm);
|
||||||
|
|
||||||
|
// handleUnlock does not exist yet — this call will throw TypeError at runtime.
|
||||||
|
await handleUnlock({ type: 'unlock', passphrase: 'pw' }, state);
|
||||||
|
|
||||||
|
expect(wasm.unlock_with_secret).toHaveBeenCalled();
|
||||||
|
expect(wasm.unlock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,16 +22,20 @@ function requireWasm(): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
||||||
|
const KEYFILE_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4,"second_factor":"keyfile"}';
|
||||||
|
|
||||||
/// Register this device on the remote (devices.json) and persist the vault
|
/// Register this device on the remote (devices.json) and persist the vault
|
||||||
/// config + reference image locally so future unlocks work. Shared by the
|
/// config + factor storage locally so future unlocks work. Shared by the
|
||||||
/// create and attach flows — both finish with this identical tail.
|
/// create and attach flows — both finish with this identical tail.
|
||||||
|
/// `factorStorage` is either `{ imageBase64 }` (image mode) or
|
||||||
|
/// `{ keyfileBase64 }` (key-file mode); it is spread directly into the
|
||||||
|
/// chrome.storage.local.set call so exactly one key is present.
|
||||||
async function registerDeviceAndPersistConfig(
|
async function registerDeviceAndPersistConfig(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
w: any,
|
w: any,
|
||||||
git: GitHost,
|
git: GitHost,
|
||||||
config: VaultConfig,
|
config: VaultConfig,
|
||||||
referenceImageBytes: Uint8Array,
|
factorStorage: { imageBase64: string } | { keyfileBase64: string },
|
||||||
deviceName: string,
|
deviceName: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const keys = w.register_device(deviceName) as { signing_public_key: string };
|
const keys = w.register_device(deviceName) as { signing_public_key: string };
|
||||||
@@ -42,51 +46,79 @@ async function registerDeviceAndPersistConfig(
|
|||||||
});
|
});
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
vaultConfig: config,
|
vaultConfig: config,
|
||||||
imageBase64: uint8ArrayToBase64(referenceImageBytes),
|
...factorStorage,
|
||||||
device_name: deviceName,
|
device_name: deviceName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreateVault(
|
export async function handleCreateVault(
|
||||||
msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
|
msg: { config: VaultConfig; passphrase: string; secondFactor?: 'image' | 'keyfile';
|
||||||
|
carrierImageBytes?: ArrayBuffer; deviceName: string },
|
||||||
state: PopupState,
|
state: PopupState,
|
||||||
): Promise<CreateVaultResponse | { ok: false; error: string }> {
|
): Promise<CreateVaultResponse | { ok: false; error: string }> {
|
||||||
const w = state.wasm;
|
const w = state.wasm;
|
||||||
let handle: SessionHandle | null = null;
|
let handle: SessionHandle | null = null;
|
||||||
try {
|
try {
|
||||||
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
|
|
||||||
// Actionable guard: the file <input accept="image/jpeg"> is only a hint,
|
|
||||||
// and a broken ArrayBuffer transport would arrive empty. Both surface as
|
|
||||||
// the opaque WASM error "no SOF marker found in JPEG", so classify here
|
|
||||||
// before handing bytes to the steganography embed.
|
|
||||||
if (carrierBytes.length === 0) {
|
|
||||||
return { ok: false, error: 'carrier_image_empty' };
|
|
||||||
}
|
|
||||||
if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) {
|
|
||||||
return { ok: false, error: 'carrier_image_not_jpeg' };
|
|
||||||
}
|
|
||||||
const imageSecret = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(imageSecret);
|
|
||||||
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
|
|
||||||
|
|
||||||
const salt = new Uint8Array(32);
|
const salt = new Uint8Array(32);
|
||||||
crypto.getRandomValues(salt);
|
crypto.getRandomValues(salt);
|
||||||
// Capture the unlock result in a non-null binding for the in-scope ops;
|
|
||||||
// `handle` stays the ownership tracker the finally block cleans up.
|
|
||||||
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
|
|
||||||
handle = h;
|
|
||||||
|
|
||||||
|
// --- Branch on second factor ---
|
||||||
|
let h: SessionHandle;
|
||||||
|
let paramsJson: string;
|
||||||
|
let factorStorage: { imageBase64: string } | { keyfileBase64: string };
|
||||||
|
let responseData: { referenceImageBytes?: Uint8Array; relkeyBytes?: Uint8Array;
|
||||||
|
deviceName: string; recoveryQrAvailable: true };
|
||||||
|
|
||||||
|
if (msg.secondFactor === 'keyfile') {
|
||||||
|
// KEY-FILE branch: skip carrier-image guards and embed_image_secret.
|
||||||
|
// Generate a fresh 32-byte secret, derive via unlock_with_secret, and
|
||||||
|
// armor it with keyfile_encode for the wizard download.
|
||||||
|
const secret = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
// Capture the unlock result in a non-null binding for the in-scope ops;
|
||||||
|
// `handle` stays the ownership tracker the finally block cleans up.
|
||||||
|
h = w.unlock_with_secret(msg.passphrase, secret, salt, KEYFILE_PARAMS_JSON) as SessionHandle;
|
||||||
|
handle = h;
|
||||||
|
const relkeyBytes = new Uint8Array(w.keyfile_encode(secret));
|
||||||
|
paramsJson = KEYFILE_PARAMS_JSON;
|
||||||
|
factorStorage = { keyfileBase64: uint8ArrayToBase64(relkeyBytes) };
|
||||||
|
responseData = { relkeyBytes, deviceName: msg.deviceName, recoveryQrAvailable: true };
|
||||||
|
} else {
|
||||||
|
// IMAGE branch (default): existing behavior — carrier guards, stego embed, unlock.
|
||||||
|
const carrierBytes = new Uint8Array(msg.carrierImageBytes!);
|
||||||
|
// Actionable guard: the file <input accept="image/jpeg"> is only a hint,
|
||||||
|
// and a broken ArrayBuffer transport would arrive empty. Both surface as
|
||||||
|
// the opaque WASM error "no SOF marker found in JPEG", so classify here
|
||||||
|
// before handing bytes to the steganography embed.
|
||||||
|
if (carrierBytes.length === 0) {
|
||||||
|
return { ok: false, error: 'carrier_image_empty' };
|
||||||
|
}
|
||||||
|
if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) {
|
||||||
|
return { ok: false, error: 'carrier_image_not_jpeg' };
|
||||||
|
}
|
||||||
|
const imageSecret = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(imageSecret);
|
||||||
|
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
|
||||||
|
// Capture the unlock result in a non-null binding for the in-scope ops;
|
||||||
|
// `handle` stays the ownership tracker the finally block cleans up.
|
||||||
|
h = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON) as SessionHandle;
|
||||||
|
handle = h;
|
||||||
|
paramsJson = DEFAULT_PARAMS_JSON;
|
||||||
|
factorStorage = { imageBase64: uint8ArrayToBase64(referenceImageBytes) };
|
||||||
|
responseData = { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared tail (both branches): encrypt init files, device registration, session ---
|
||||||
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
|
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
|
||||||
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
|
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
|
||||||
|
|
||||||
const { config } = msg;
|
const { config } = msg;
|
||||||
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
|
||||||
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
|
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
|
||||||
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
|
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(paramsJson), 'init: KDF parameters');
|
||||||
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
|
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
|
||||||
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
|
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
|
||||||
|
|
||||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
await registerDeviceAndPersistConfig(w, git, config, factorStorage, msg.deviceName);
|
||||||
|
|
||||||
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
|
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
|
||||||
session.setCurrent(h);
|
session.setCurrent(h);
|
||||||
@@ -94,7 +126,7 @@ export async function handleCreateVault(
|
|||||||
state.manifest = { schema_version: 2, items: {} } as Manifest;
|
state.manifest = { schema_version: 2, items: {} } as Manifest;
|
||||||
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
handle = null; // ownership transferred — do NOT lock-and-free in finally
|
||||||
|
|
||||||
return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
|
return { ok: true, data: responseData };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,7 +161,7 @@ export async function handleAttachVault(
|
|||||||
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
|
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
|
||||||
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
|
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
|
||||||
|
|
||||||
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
|
await registerDeviceAndPersistConfig(w, git, config, { imageBase64: uint8ArrayToBase64(referenceImageBytes) }, msg.deviceName);
|
||||||
|
|
||||||
// SW now owns the unlocked session — transfer ownership to the session.
|
// SW now owns the unlocked session — transfer ownership to the session.
|
||||||
session.setCurrent(h);
|
session.setCurrent(h);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export type PopupMessage =
|
|||||||
| { type: 'generate_recovery_qr'; passphrase: string }
|
| { type: 'generate_recovery_qr'; passphrase: string }
|
||||||
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string }
|
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string }
|
||||||
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
| { type: 'create_vault'; config: VaultConfig; passphrase: string;
|
||||||
secondFactor?: 'image' | 'keyfile'; carrierImageBytes: ArrayBuffer; deviceName: string }
|
secondFactor?: 'image' | 'keyfile'; carrierImageBytes?: ArrayBuffer; deviceName: string }
|
||||||
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
|
||||||
referenceImageBytes: ArrayBuffer; deviceName: string }
|
referenceImageBytes: ArrayBuffer; deviceName: string }
|
||||||
| { type: 'get_vault_status' };
|
| { type: 'get_vault_status' };
|
||||||
|
|||||||
Reference in New Issue
Block a user