feat(ext/sw): typed-item vault ops via SessionHandle

This commit is contained in:
adlee-was-taken
2026-04-20 19:53:28 -04:00
parent 7781a51848
commit bd9dd206ac

View File

@@ -1,34 +1,26 @@
/// Vault operations module. /// Typed-item vault operations. All calls are handle-keyed — the master key
/// /// never crosses the WASM boundary.
/// Bridges the WASM crypto functions with the git host API to provide
/// high-level vault operations: fetch/decrypt manifest, fetch/decrypt entries,
/// encrypt/write entries, search, and URL matching.
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host'; import type { GitHost } from './git-host';
import type { Entry, Manifest, ManifestEntry } from '../shared/types'; import type { Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
// WASM module reference — set once during init.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null; let wasm: any = null;
/// Store the WASM module reference after initialization.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { export function setWasm(w: any): void { wasm = w; }
wasm = w;
}
function requireWasm(): any { function requireWasm(): any {
if (!wasm) throw new Error('WASM module not initialized'); if (!wasm) throw new Error('WASM module not initialized');
return wasm; return wasm;
} }
/// Vault metadata: salt and KDF params stored unencrypted in the repo.
export interface VaultMeta { export interface VaultMeta {
salt: Uint8Array; salt: Uint8Array;
paramsJson: string; paramsJson: string;
} }
/// Read the vault salt and KDF params from the git repo.
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> { export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
const saltBytes = await git.readFile('.relicario/salt'); const saltBytes = await git.readFile('.relicario/salt');
const paramsRaw = await git.readFile('.relicario/params.json'); const paramsRaw = await git.readFile('.relicario/params.json');
@@ -36,102 +28,110 @@ export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
return { salt: saltBytes, paramsJson }; return { salt: saltBytes, paramsJson };
} }
/// Fetch and decrypt the manifest from the git repo. // --- Manifest ---
export async function fetchAndDecryptManifest( export async function fetchAndDecryptManifest(
git: GitHost, git: GitHost,
masterKey: Uint8Array, handle: SessionHandle,
): Promise<Manifest> { ): Promise<Manifest> {
const w = requireWasm(); const w = requireWasm();
const ciphertext = await git.readFile('manifest.enc'); const ciphertext = await git.readFile('manifest.enc');
const json = w.decrypt_manifest(ciphertext, masterKey); return w.manifest_decrypt(handle, ciphertext) as Manifest;
return JSON.parse(json) as Manifest;
} }
/// Fetch and decrypt a single entry from the git repo.
export async function fetchAndDecryptEntry(
git: GitHost,
masterKey: Uint8Array,
id: string,
): Promise<Entry> {
const w = requireWasm();
const ciphertext = await git.readFile(`entries/${id}.enc`);
const json = w.decrypt_entry(ciphertext, masterKey);
return JSON.parse(json) as Entry;
}
/// Encrypt an entry and write it to the git repo.
export async function encryptAndWriteEntry(
git: GitHost,
masterKey: Uint8Array,
id: string,
entry: Entry,
message: string,
): Promise<void> {
const w = requireWasm();
const entryJson = JSON.stringify(entry);
const ciphertext = w.encrypt_entry(entryJson, masterKey);
await git.writeFile(`entries/${id}.enc`, ciphertext, message);
}
/// Encrypt the manifest and write it to the git repo.
export async function encryptAndWriteManifest( export async function encryptAndWriteManifest(
git: GitHost, git: GitHost,
masterKey: Uint8Array, handle: SessionHandle,
manifest: Manifest, manifest: Manifest,
message: string, message: string,
): Promise<void> { ): Promise<void> {
const w = requireWasm(); const w = requireWasm();
const manifestJson = JSON.stringify(manifest); const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
const ciphertext = w.encrypt_manifest(manifestJson, masterKey);
await git.writeFile('manifest.enc', ciphertext, message); await git.writeFile('manifest.enc', ciphertext, message);
} }
/// Filter manifest entries by group (case-insensitive). If no group given, returns all. // --- Items ---
export function listEntries(
export async function fetchAndDecryptItem(
git: GitHost,
handle: SessionHandle,
id: ItemId,
): Promise<Item> {
const w = requireWasm();
const ciphertext = await git.readFile(`items/${id}.enc`);
return w.item_decrypt(handle, ciphertext) as Item;
}
export async function encryptAndWriteItem(
git: GitHost,
handle: SessionHandle,
id: ItemId,
item: Item,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
await git.writeFile(`items/${id}.enc`, ciphertext, message);
}
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
export async function fetchAndDecryptSettings(
git: GitHost,
handle: SessionHandle,
): Promise<VaultSettings> {
const w = requireWasm();
const ciphertext = await git.readFile('settings.enc');
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}
export async function encryptAndWriteSettings(
git: GitHost,
handle: SessionHandle,
settings: VaultSettings,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
await git.writeFile('settings.enc', ciphertext, message);
}
// --- In-memory manifest helpers ---
export function listItems(
manifest: Manifest, manifest: Manifest,
group?: string, group?: string,
): Array<[string, ManifestEntry]> { ): Array<[ItemId, ManifestEntry]> {
const entries = Object.entries(manifest.entries); const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
if (!group) return entries; // Hide trashed items from the default list view.
const live = entries.filter(([, e]) => e.trashed_at === undefined);
if (!group) return live;
const g = group.toLowerCase(); const g = group.toLowerCase();
return entries.filter(([, e]) => return live.filter(([, e]) => e.group?.toLowerCase() === g);
e.group?.toLowerCase() === g
);
} }
/// Case-insensitive substring search on name, url, and username. export function searchItems(
export function searchEntries(
manifest: Manifest, manifest: Manifest,
query: string, query: string,
): Array<[string, ManifestEntry]> { ): Array<[ItemId, ManifestEntry]> {
const q = query.toLowerCase(); const q = query.toLowerCase();
return Object.entries(manifest.entries).filter(([, e]) => { return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
if (e.name.toLowerCase().includes(q)) return true; .filter(([, e]) => e.trashed_at === undefined)
if (e.url?.toLowerCase().includes(q)) return true; .filter(([, e]) => {
if (e.username?.toLowerCase().includes(q)) return true; if (e.title.toLowerCase().includes(q)) return true;
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
return false; return false;
}); });
} }
/// Find entries whose URL matches the given page URL by hostname. export function findByHostname(
export function findByUrl(
manifest: Manifest, manifest: Manifest,
url: string, hostname: string,
): Array<[string, ManifestEntry]> { ): Array<[ItemId, ManifestEntry]> {
let hostname: string; const h = hostname.toLowerCase();
try { return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
hostname = new URL(url).hostname; .filter(([, e]) => e.trashed_at === undefined)
} catch { .filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
return []; // icon_hint is derived by Rust core from LoginCore.url's hostname,
} // so hostname equality on icon_hint is the cheapest match.
return Object.entries(manifest.entries).filter(([, e]) => {
if (!e.url) return false;
try {
const entryHost = new URL(e.url).hostname;
return entryHost === hostname;
} catch {
return false;
}
});
} }