From d82f72cdbfd5f9b082213d0eb743673c4ef1a1c4 Mon Sep 17 00:00:00 2001 From: aarav1656 Date: Fri, 2 Oct 2026 16:09:03 +0530 Subject: [PATCH] sealed notes: seal with satchel, one passkey prompt per session Notes are sealed under ns trustset/notes/ through satchel-sdk, stored through the same SealedNotes.seal(agentId, kind, bytes) call. Notes already on chain as Mera vault JSON still open; readNotes tells them from satchel bytes by the SAT1 header. Exported names and signatures are unchanged. --- web/src/lib/sealed.ts | 105 ++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 29 deletions(-) diff --git a/web/src/lib/sealed.ts b/web/src/lib/sealed.ts index d836599..efccd38 100644 --- a/web/src/lib/sealed.ts +++ b/web/src/lib/sealed.ts @@ -1,24 +1,33 @@ "use client"; import { ethers } from "ethers"; import { - createSecretVaultWithExistingPasskey, decryptSecretVaultWithPasskey, isMeraError, parseSecretVault, + decryptSecretVaultWithPasskey, isMeraError, parseSecretVault, type PasskeySecretVault, } from "@category-labs/mera"; +import { open, ns, parseSealed, SatchelError, type Sealed as SatchelBlob, type Satchel } from "satchel-sdk"; import type { Conn } from "@/lib/chain"; /* notes only the owner's passkey can read. * * the panic button already asks the owner's passkey for a signature, and the * contract checks it. this asks the same passkey for something else: the - * WebAuthn PRF output, through Mera, which becomes an AES-256-GCM key inside - * the browser and nowhere else. every note is sealed under a fresh 32-byte - * salt, so no two notes share a key and none can be linked to another. the - * key is never stored: the salt, the nonce and the ciphertext go on chain, - * and any device the passkey syncs to recreates the key from the salt. + * WebAuthn PRF output, through Mera and the satchel SDK, which becomes + * AES-256-GCM keys inside the browser and nowhere else. + * + * one passkey prompt opens a session. every note for an agent is sealed under + * that agent's namespace, trustset/notes/, with a fresh nonce and the + * header bound as authenticated data, so sealing three notes or opening six is + * still one prompt. the keys are never stored: the namespace, the nonce and the + * ciphertext go on chain, and any device the passkey syncs to recreates the + * keys from the same passkey. the session is wiped when the page is left. + * + * notes sealed before this, as one Mera vault per note under its own salt, are + * still on chain and still open: those are JSON, new ones start with the four + * bytes SAT1, and readNotes tells them apart by that header. they cost one + * prompt each, as they always did. * * the passkey is not named. the browser offers whichever trustset passkey the - * owner has, which is normally the panic one, and Mera writes the credential - * it used into the vault so opening it asks for that same passkey. */ + * owner has, which is normally the panic one. */ export const NOTES_ABI = [ "function seal(uint256 agentId, uint8 kind, bytes vault)", /* "sealedAt", not "at": ethers hands tuples back as arrays, and every array @@ -28,50 +37,88 @@ export const NOTES_ABI = [ ]; export type Kind = "runbook" | "stop"; const KIND: Record = { runbook: 0, stop: 1 }; -export type Sealed = { kind: Kind; by: string; at: number; vault: PasskeySecretVault | null; index: number }; +/* a satchel note carries the agent it belongs to, because openNote is handed + only the vault and the namespace is per agent. */ +export type Vault = { satchel: SatchelBlob; agentId: bigint } | PasskeySecretVault; +export type Sealed = { kind: Kind; by: string; at: number; vault: Vault | null; index: number }; const enc = new TextEncoder(), dec = new TextDecoder(); +const notesNs = (id: bigint) => ns(`trustset/notes/${id}`); +const isSatchel = (v: Vault): v is { satchel: SatchelBlob; agentId: bigint } => "satchel" in v; + +/* one passkey prompt per session. a prompt that fails is not remembered, so the + next click asks again. */ +let session: Promise | undefined; +function lock() { + const s = session; + session = undefined; + s?.then(k => k.close(), () => {}); +} +function satchel(): Promise { + const s = session ??= open({ rpId: location.hostname }); + s.catch(() => { if (session === s) session = undefined; }); + return s; +} +if (typeof window !== "undefined") window.addEventListener("pagehide", lock); -/* a vault is stored as the JSON Mera produces, as utf-8 bytes. a vault that - will not parse is kept as null rather than dropped, so the list still says a - note exists and that it cannot be read. */ +/* a note is stored as satchel bytes (SAT1 header) or, for older notes, as the + JSON Mera produced, as utf-8 bytes. one that is neither is kept as null + rather than dropped, so the list still says a note exists and that it cannot + be read. */ export async function readNotes(c: Conn, id: bigint): Promise { if (!c.cfg.notes) return []; const n = new ethers.Contract(c.cfg.notes, NOTES_ABI, c.p); const rows = await n.notesOf(id) as { kind: bigint; by: string; sealedAt: bigint; vault: string }[]; return rows.map((r, index) => { - let vault: PasskeySecretVault | null = null; - try { vault = parseSecretVault(JSON.parse(dec.decode(ethers.getBytes(r.vault)))); } catch { /* unreadable, shown as such */ } + let vault: Vault | null = null; + const raw = ethers.getBytes(r.vault); + try { + vault = raw.length >= 4 && dec.decode(raw.subarray(0, 4)) === "SAT1" + ? { satchel: parseSealed(raw), agentId: id } + : parseSecretVault(JSON.parse(dec.decode(raw))); + } catch { /* unreadable, shown as such */ } return { kind: Number(r.kind) === 1 ? "stop" : "runbook", by: r.by, at: Number(r.sealedAt), vault, index }; }); } /* seal in the browser, then send only the sealed bytes. the plaintext never - leaves this function and its buffer is zeroed by Mera once encrypted. */ + leaves this function. */ export async function sealNote(c: Conn, signer: ethers.Signer, id: bigint, kind: Kind, text: string) { if (!c.cfg.notes) throw new Error("This chain has no sealed notes contract"); - const secret = enc.encode(text); - const vault = await createSecretVaultWithExistingPasskey({ rpId: location.hostname, secret }); - const bytes = enc.encode(JSON.stringify(vault)); + const bytes = (await (await satchel()).seal(notesNs(id), text)).toBytes(); const n = new ethers.Contract(c.cfg.notes, NOTES_ABI, signer); const tx = await n.seal(id, KIND[kind], bytes); return tx.wait(1); } -export async function openNote(vault: PasskeySecretVault): Promise { - const out = await decryptSecretVaultWithPasskey({ rpId: location.hostname, vault }); - const text = dec.decode(out as Uint8Array); - (out as Uint8Array).fill(0); +export async function openNote(vault: Vault): Promise { + const out = isSatchel(vault) + ? await (await satchel()).unseal(notesNs(vault.agentId), vault.satchel).catch(e => { + /* the session was made with the wrong passkey: drop it so the next try asks again */ + if (e instanceof SatchelError && e.code === "WRONG_PASSKEY") lock(); + throw e; + }) + : await decryptSecretVaultWithPasskey({ rpId: location.hostname, vault }) as Uint8Array; + const text = dec.decode(out); + out.fill(0); return text; } -/* what went wrong, in words the owner can act on. */ +/* what went wrong, in words the owner can act on. satchel and Mera name the + same failures differently; the owner reads one sentence for each. */ +const WORDS: Record = { + PRF_UNAVAILABLE: "This passkey cannot derive keys. iCloud Keychain and Google Password Manager passkeys can; some security keys cannot.", + WRONG_PASSKEY: "That passkey is not the one this note was sealed with.", + PASSKEY_CANCELLED: "The passkey prompt was closed, or there is no trustset passkey on this device yet.", + RPID_MISMATCH: "This address has no trustset passkey. A passkey only works on the address it was made for.", + NAMESPACE_MISMATCH: "This note belongs to a different agent.", + SEAL_CORRUPT: "This note is damaged on chain and cannot be opened.", + NOT_SEALED: "This note is damaged on chain and cannot be opened.", + SESSION_CLOSED: "The passkey session ended. Try again.", +}; +const MERA: Record = { DECRYPT_FAILED: "WRONG_PASSKEY", PASSKEY_OPERATION_FAILED: "PASSKEY_CANCELLED" }; export function explainSeal(e: unknown): string { - if (isMeraError(e)) { - if (e.code === "PRF_UNAVAILABLE") return "This passkey cannot derive keys. iCloud Keychain and Google Password Manager passkeys can; some security keys cannot."; - if (e.code === "DECRYPT_FAILED") return "That passkey is not the one this note was sealed with."; - if (e.code === "PASSKEY_OPERATION_FAILED") return "The passkey prompt was closed, or there is no trustset passkey on this device yet."; - return e.message; - } + if (e instanceof SatchelError) return WORDS[e.code] ?? `${e.message} ${e.fix}`; + if (isMeraError(e)) return WORDS[MERA[e.code] ?? e.code] ?? e.message; return e instanceof Error ? e.message : String(e); } -- 2.52.0