feat(ext/sw): org-vault read core (openOrg, grant-filtered list, collection-scoped get, offline)

- Add extension/src/service-worker/org-vault.ts: openOrg (members.json fingerprint
  match → org_unwrap_key → org_manifest_decrypt_filtered), listOrgItems, getOrgItem
  (collection-scoped path: items/<collection>/<id>.enc), listOrgCollections (grants filter).
- Add OrgMember, OrgMembers, CollectionDef, OrgCollections, Collection types to shared/types.ts
  (mirrors relicario-core org.rs shapes).
- Add 8-test suite covering: fingerprint match, not_an_org_member, listOrgItems passthrough,
  collection-scoped item path, listOrgCollections grant filter, storage.set never called,
  offline fallback (TypeError → cached manifest), offline with no cache → rethrow.
- Fix plan doc line ~326: wasm.manifest_decrypt → wasm.org_manifest_decrypt_filtered.

Gate results: 158/158 SW tests pass; npm run build:all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-26 22:30:39 -04:00
parent 288956c089
commit 2eb49ac217
4 changed files with 560 additions and 1 deletions

View File

@@ -0,0 +1,373 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';
import * as sshFingerprintMod from '../../shared/ssh-fingerprint';
// --- Mocks ---
// Mock createGitHost so we can inject a fake GitHost per test.
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
return {
...actual,
createGitHost: vi.fn(),
};
});
// Mock sshFingerprint so fingerprint matching is fully controllable.
vi.mock('../../shared/ssh-fingerprint', () => ({
sshFingerprint: vi.fn(),
}));
// --- Test data ---
const MY_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIdevice testdevice';
const OTHER_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIother otherdevice';
const MY_FP = 'SHA256:myDeviceFingerprint';
const OTHER_FP = 'SHA256:otherDeviceFingerprint';
const MEMBER_ID = 'aabbccdd11223344';
const OTHER_MEMBER_ID = 'deadbeefcafe0001';
const MEMBERS_JSON = {
schema_version: 1,
members: [
{
member_id: OTHER_MEMBER_ID,
display_name: 'Other User',
role: 'member',
ed25519_pubkey: OTHER_PK,
collections: ['other-col'],
added_at: 1000,
added_by: OTHER_MEMBER_ID,
},
{
member_id: MEMBER_ID,
display_name: 'Test User',
role: 'member',
ed25519_pubkey: MY_PK,
collections: ['prod-infra', 'staging'],
added_at: 1001,
added_by: MEMBER_ID,
},
],
};
const FILTERED_MANIFEST = {
schema_version: 1,
entries: [
{ id: 'item001', type: 'login', title: 'My Login', tags: [], modified: 1000, collection: 'prod-infra' },
{ id: 'item002', type: 'secure_note', title: 'A Note', tags: [], modified: 1001, collection: 'staging' },
],
};
const ORG_CFG = {
orgId: 'org-test',
displayName: 'Test Org',
hostType: 'gitea' as const,
hostUrl: 'https://git.example',
repoPath: 'org/vault',
apiToken: 'tok-abc',
memberId: MEMBER_ID,
};
// --- Helpers ---
function enc(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
/** Build a fake GitHost whose readFile returns different payloads per path. */
function makeOrgHost(
files: Record<string, Uint8Array | (() => Promise<Uint8Array>)>,
extraMethods: Partial<GitHost> = {},
): GitHost {
return {
readFile: vi.fn().mockImplementation(async (path: string) => {
const v = files[path];
if (v === undefined) throw new Error(`404: ${path}`);
if (typeof v === 'function') return v();
return v;
}),
writeFile: vi.fn().mockResolvedValue(undefined),
writeFileCreateOnly: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn().mockResolvedValue(''),
getBlob: vi.fn().mockResolvedValue(new Uint8Array()),
deleteBlob: vi.fn().mockResolvedValue(undefined),
lastSyncAt: null,
ahead: 0,
behind: 0,
...extraMethods,
};
}
const WRAPPED_KEY_BYTES = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const MANIFEST_ENC_BYTES = new Uint8Array([0x01, 0x02, 0x03]);
function makeOrgHostDefault(): GitHost {
return makeOrgHost({
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
'manifest.enc': MANIFEST_ENC_BYTES,
'collections.json': enc(JSON.stringify({
schema_version: 1,
collections: [
{ slug: 'prod-infra', display_name: 'Production Infrastructure', created_by: MEMBER_ID, created_at: 100 },
{ slug: 'staging', display_name: 'Staging', created_by: MEMBER_ID, created_at: 101 },
{ slug: 'secret-ops', display_name: 'Secret Ops', created_by: OTHER_MEMBER_ID, created_at: 102 },
],
})),
});
}
function makeFakeHandle() {
return { value: 42, free: vi.fn() };
}
/** Build a minimal wasm mock for org tests. */
function makeOrgWasm(overrides: Record<string, unknown> = {}) {
const fakeHandle = makeFakeHandle();
return {
_handle: fakeHandle,
get_device_info: vi.fn(() => ({
name: 'Test Device',
signing_public_key: MY_PK,
deploy_public_key: 'ssh-ed25519 AAAAB3Nzdeploy',
})),
org_unwrap_key: vi.fn(() => fakeHandle),
org_manifest_decrypt_filtered: vi.fn(() => FILTERED_MANIFEST),
item_decrypt: vi.fn(() => ({
id: 'item001',
title: 'My Login',
type: 'login',
tags: [],
favorite: false,
created: 100,
modified: 1000,
core: { type: 'login', username: 'user', password: 'pass' },
sections: [],
attachments: [],
field_history: {},
})),
...overrides,
};
}
/** Setup the sshFingerprint mock to return fingerprints by pubkey. */
function mockFingerprints(map: Record<string, string | null>) {
vi.mocked(sshFingerprintMod.sshFingerprint).mockImplementation(async (key: string) => {
return key in map ? map[key] : null;
});
}
// Import under test (after mocks are declared).
import * as orgVault from '../org-vault';
// --- Chrome storage mock ---
function mockChromeStorage() {
const store: Record<string, unknown> = {};
const setSpy = vi.fn().mockImplementation((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
});
const getSpy = vi.fn().mockResolvedValue({});
(global as { chrome: unknown }).chrome = {
storage: { local: { get: getSpy, set: setSpy } },
} as never;
return { setSpy, getSpy, store };
}
// --- Tests ---
describe('org-vault', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default fingerprint mapping: MY_PK → MY_FP, OTHER_PK → OTHER_FP
mockFingerprints({ [MY_PK]: MY_FP, [OTHER_PK]: OTHER_FP });
});
// Test 1: openOrg — happy path: fingerprint match → grants, unwrap, filtered manifest
it('openOrg: matches device by ed25519 fingerprint → grants; calls org_unwrap_key + org_manifest_decrypt_filtered', async () => {
const host = makeOrgHostDefault();
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
const w = makeOrgWasm();
const state = await orgVault.openOrg(ORG_CFG, w);
// Fingerprint called for both: device key + each member pubkey
expect(sshFingerprintMod.sshFingerprint).toHaveBeenCalledWith(MY_PK);
// Correct member matched: MEMBER_ID (has MY_PK)
expect(state.grants).toEqual(['prod-infra', 'staging']);
// org_unwrap_key called with the wrapped key bytes
expect(w.org_unwrap_key).toHaveBeenCalledWith(WRAPPED_KEY_BYTES);
// org_manifest_decrypt_filtered called with handle, enc, grants
expect(w.org_manifest_decrypt_filtered).toHaveBeenCalledWith(
w._handle,
MANIFEST_ENC_BYTES,
['prod-infra', 'staging'],
);
// Returned state
expect(state.orgId).toBe('org-test');
expect(state.offline).toBe(false);
expect(state.manifest).toBe(FILTERED_MANIFEST);
expect(state.handle).toBe(w._handle);
});
// Test 2: openOrg — device fingerprint absent → throws not_an_org_member
it('openOrg: device not in members.json → throws not_an_org_member', async () => {
// No member matches MY_PK
const membersNoMatch = {
schema_version: 1,
members: [
{
member_id: OTHER_MEMBER_ID,
display_name: 'Other',
role: 'member',
ed25519_pubkey: OTHER_PK,
collections: ['other-col'],
added_at: 1000,
added_by: OTHER_MEMBER_ID,
},
],
};
const host = makeOrgHost({
'members.json': enc(JSON.stringify(membersNoMatch)),
[`keys/${OTHER_MEMBER_ID}.enc`]: new Uint8Array([0x00]),
'manifest.enc': MANIFEST_ENC_BYTES,
});
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
const w = makeOrgWasm();
await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('not_an_org_member');
// Should never reach org_unwrap_key
expect(w.org_unwrap_key).not.toHaveBeenCalled();
});
// Test 3: listOrgItems — returns cached filtered entries directly (no re-filter)
it('listOrgItems: returns state.manifest.entries unchanged', () => {
const state: orgVault.OrgHandleState = {
orgId: 'org-test',
host: makeOrgHostDefault(),
handle: makeFakeHandle() as never,
grants: ['prod-infra'],
manifest: FILTERED_MANIFEST as never,
offline: false,
};
const items = orgVault.listOrgItems(state);
// Returns ALL entries from the manifest — filtering was done in core
expect(items).toBe(FILTERED_MANIFEST.entries);
expect(items).toHaveLength(2);
});
// Test 4: getOrgItem — reads items/<collection>/<id>.enc (collection-scoped path)
it('getOrgItem: reads items/<collection>/<id>.enc and returns decrypted item', async () => {
const ITEM_ENC = new Uint8Array([0xaa, 0xbb]);
const host = makeOrgHost({
'items/prod-infra/item001.enc': ITEM_ENC,
});
const w = makeOrgWasm();
const state: orgVault.OrgHandleState = {
orgId: 'org-test',
host,
handle: w._handle as never,
grants: ['prod-infra'],
manifest: FILTERED_MANIFEST as never,
offline: false,
};
const item = await orgVault.getOrgItem(state, 'item001', w);
// Path must be collection-scoped
expect(host.readFile).toHaveBeenCalledWith('items/prod-infra/item001.enc');
// item_decrypt called with the correct handle and ciphertext
expect(w.item_decrypt).toHaveBeenCalledWith(w._handle, ITEM_ENC);
// Returns the decrypted item
expect(item.id).toBe('item001');
});
// Test 5: listOrgCollections — returns ONLY granted collections (ungranted excluded)
it('listOrgCollections: filters to grants, maps to { slug, display_name }', async () => {
const host = makeOrgHostDefault();
const state: orgVault.OrgHandleState = {
orgId: 'org-test',
host,
handle: makeFakeHandle() as never,
grants: ['prod-infra', 'staging'],
manifest: FILTERED_MANIFEST as never,
offline: false,
};
const collections = await orgVault.listOrgCollections(state);
// secret-ops must be excluded (not in grants)
expect(collections).toHaveLength(2);
expect(collections.map((c) => c.slug)).toContain('prod-infra');
expect(collections.map((c) => c.slug)).toContain('staging');
expect(collections.map((c) => c.slug)).not.toContain('secret-ops');
// Shape: { slug, display_name } only
expect(collections[0]).toHaveProperty('slug');
expect(collections[0]).toHaveProperty('display_name');
expect(Object.keys(collections[0])).toEqual(expect.arrayContaining(['slug', 'display_name']));
});
// Test 6: openOrg NEVER writes the org key to storage
it('openOrg: never writes org key or handle to chrome.storage.local', async () => {
const { setSpy } = mockChromeStorage();
const host = makeOrgHostDefault();
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
const w = makeOrgWasm();
await orgVault.openOrg(ORG_CFG, w);
// chrome.storage.local.set must never be called at all by openOrg
expect(setSpy).not.toHaveBeenCalled();
});
// Test 7a: offline fallback — network error + cachedManifest → offline:true, no throw
it('openOrg: manifest.enc network error + cachedManifest → offline:true, returns cached manifest', async () => {
const networkError = new TypeError('Failed to fetch');
const host = makeOrgHost({
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
// manifest.enc throws network error
'manifest.enc': () => Promise.reject(networkError),
});
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
const w = makeOrgWasm();
const cached = { schema_version: 1, entries: [{ id: 'cached-item', type: 'login' as const, title: 'Cached', tags: [], modified: 999, collection: 'prod-infra' }] };
const state = await orgVault.openOrg(ORG_CFG, w, cached);
expect(state.offline).toBe(true);
expect(state.manifest).toBe(cached);
// org_manifest_decrypt_filtered must not be called (we used the cache)
expect(w.org_manifest_decrypt_filtered).not.toHaveBeenCalled();
});
// Test 7b: offline fallback — network error WITHOUT cachedManifest → rethrows
it('openOrg: manifest.enc network error without cachedManifest → rethrows', async () => {
const networkError = new TypeError('Failed to fetch');
const host = makeOrgHost({
'members.json': enc(JSON.stringify(MEMBERS_JSON)),
[`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES,
'manifest.enc': () => Promise.reject(networkError),
});
vi.mocked(gitHostMod.createGitHost).mockReturnValue(host);
const w = makeOrgWasm();
// No cachedManifest → must rethrow
await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('Failed to fetch');
});
});

View File

@@ -0,0 +1,147 @@
/// Org vault read core — mirrors vault.ts but for an org vault.
/// Pure functions over a GitHost + the WASM org bridge.
/// NO SW message handlers (Task 6 wires those), NO UI.
///
/// Security invariants:
/// - The org master key lives ONLY inside the WASM session (org_unwrap_key).
/// This module NEVER writes the key or any SessionHandle to chrome.storage.local,
/// IndexedDB, or any other persistent store.
/// - Grant filtering is done in core (org_manifest_decrypt_filtered); listOrgItems
/// returns the already-filtered manifest.entries without any TS-side re-filter.
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import { createGitHost } from './git-host';
import { sshFingerprint } from '../shared/ssh-fingerprint';
import type {
OrgConfig,
OrgMembers,
OrgMember,
OrgManifest,
OrgManifestEntry,
OrgCollections,
Collection,
Item,
} from '../shared/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type WasmModule = any;
export interface OrgHandleState {
orgId: string;
host: GitHost;
handle: SessionHandle; // from org_unwrap_key; NOT persisted; Task 6 calls session.setOrg
grants: string[]; // member.collections
manifest: OrgManifest; // cached, grant-filtered
offline: boolean;
}
/// Detect a network-level failure (fetch() rejects with TypeError; no connectivity).
/// Does NOT classify HTTP non-2xx errors (those are application-level, not network).
function isNetworkError(e: unknown): boolean {
// fetch() rejects with TypeError when the request cannot be made at all
// (DNS failure, no connectivity, CORS preflight failure, etc.).
if (e instanceof TypeError) return true;
// Some environments produce an Error with a recognisable message instead.
if (e instanceof Error) {
const msg = e.message.toLowerCase();
return (
msg.includes('failed to fetch') ||
msg.includes('networkerror') ||
msg.includes('network error')
);
}
return false;
}
/// Open an org vault:
/// 1. Read public members.json
/// 2. Match THIS device by ed25519 fingerprint
/// 3. Derive grants from the member's collections
/// 4. Read + unwrap the member's encrypted org key (keys/<member_id>.enc)
/// 5. Fetch + grant-filter the manifest (offline fallback if cachedManifest supplied)
///
/// The returned handle is NOT persisted here. Task 6 hands it to session.setOrg.
export async function openOrg(
cfg: OrgConfig,
w: WasmModule,
cachedManifest?: OrgManifest,
): Promise<OrgHandleState> {
// 1. Build the GitHost for the org repo.
const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken);
// 2. Read public members.json.
const membersRaw = await host.readFile('members.json');
const members = JSON.parse(new TextDecoder().decode(membersRaw)) as OrgMembers;
// 3. Get this device's ed25519 signing key and compute its fingerprint.
const info = w.get_device_info() as { name: string; signing_public_key: string; deploy_public_key: string } | null;
if (!info) throw new Error('device_key_unavailable');
const myFp = await sshFingerprint(info.signing_public_key);
// 4. Find this device's member entry — await in a for-loop (not .find, which can't await).
let me: OrgMember | undefined;
for (const m of members.members) {
if (await sshFingerprint(m.ed25519_pubkey) === myFp) {
me = m;
break;
}
}
if (!me) throw new Error('not_an_org_member');
// 5. Member's grants.
const grants = me.collections;
// 6. Read this member's wrapped org key.
const wrapped = await host.readFile(`keys/${me.member_id}.enc`);
// 7. Unwrap the org key inside WASM (reads DEVICE_STATE internally — no key param crosses JS).
const handle = w.org_unwrap_key(wrapped) as SessionHandle;
// 8. Fetch + grant-filter the manifest; fall back to the cached copy on network failure.
let manifest: OrgManifest;
let offline = false;
try {
const enc = await host.readFile('manifest.enc');
manifest = w.org_manifest_decrypt_filtered(handle, enc, grants) as OrgManifest;
} catch (e) {
if (cachedManifest && isNetworkError(e)) {
manifest = cachedManifest;
offline = true;
} else {
throw e;
}
}
return { orgId: cfg.orgId, host, handle, grants, manifest, offline };
}
/// Return the cached, already-grant-filtered manifest entries.
/// Grant filtering happened in core (org_manifest_decrypt_filtered) — no TS-side filter.
export function listOrgItems(state: OrgHandleState): OrgManifestEntry[] {
return state.manifest.entries;
}
/// Fetch and decrypt a single org item.
/// Org items are collection-scoped: path = items/<collection>/<id>.enc
/// (differs from personal flat items/<id>.enc).
export async function getOrgItem(
state: OrgHandleState,
id: string,
w: WasmModule,
): Promise<Item> {
const entry = state.manifest.entries.find((e) => e.id === id);
if (!entry) throw new Error('item_not_found');
const ct = await state.host.readFile(`items/${entry.collection}/${id}.enc`);
return w.item_decrypt(state.handle, ct) as Item;
}
/// Return the list of collections granted to this member.
/// Reads public collections.json, filters to state.grants, maps to { slug, display_name }.
export async function listOrgCollections(state: OrgHandleState): Promise<Collection[]> {
const raw = await state.host.readFile('collections.json');
const orgCollections = JSON.parse(new TextDecoder().decode(raw)) as OrgCollections;
return orgCollections.collections
.filter((c) => state.grants.includes(c.slug))
.map((c) => ({ slug: c.slug, display_name: c.display_name }));
}

View File

@@ -225,6 +225,45 @@ export interface OrgConfigSummary {
displayName: string;
}
// --- Org members + collections (mirror Rust org.rs shapes) ---
export type OrgRole = 'owner' | 'admin' | 'member';
export interface OrgMember {
member_id: string;
display_name: string;
role: OrgRole;
/** SSH public key string (openssh format: "ssh-ed25519 AAAA...") */
ed25519_pubkey: string;
/** Collection slugs this member can access. */
collections: string[];
added_at: number;
added_by: string;
}
export interface OrgMembers {
schema_version: number;
members: OrgMember[];
}
export interface CollectionDef {
slug: string;
display_name: string;
created_by: string;
created_at: number;
}
export interface OrgCollections {
schema_version: number;
collections: CollectionDef[];
}
/** Public shape returned by listOrgCollections — slug + display_name only. */
export interface Collection {
slug: string;
display_name: string;
}
// --- Org manifest ---
// Org manifest — a dedicated, leaner shape than the personal ManifestEntry.
// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED.