diff --git a/.changeset/trustless-verify-memory.md b/.changeset/trustless-verify-memory.md new file mode 100644 index 00000000..61e56479 --- /dev/null +++ b/.changeset/trustless-verify-memory.md @@ -0,0 +1,5 @@ +--- +"@mysten-incubation/memwal": minor +--- + +Add trustless `MemWal.verify()` / `verifyMemory()` — confirm a memory's provenance from public inputs only (on-chain `Blob` metadata, delegate-key binding, and public Walrus aggregator retrievability) with no private key and no relayer. `@mysten/sui` is loaded dynamically so the default entry point stays dependency-light. Includes a "verify a memory" guide. diff --git a/docs/docs.json b/docs/docs.json index afc04c89..47043b9d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -87,6 +87,7 @@ }, "sdk/cookbook-multi-tenant", "sdk/cloudflare-workers", + "sdk/verify-a-memory", "sdk/api-reference", "sdk/changelog" ] diff --git a/docs/sdk/verify-a-memory.md b/docs/sdk/verify-a-memory.md new file mode 100644 index 00000000..75df039a --- /dev/null +++ b/docs/sdk/verify-a-memory.md @@ -0,0 +1,107 @@ +# Independently verify a memory + +Walrus Memory's core promise is **verifiable, non-custodial memory**: a memory you +write is provable from on-chain data plus the public Walrus network, with **no trust +in the relayer**. This page shows how to demonstrate that property — both with the +`MemWal.verify()` helper and by hand. + +The check needs **public inputs only**: a `blobId`, the owning `accountId`, a Sui +fullnode, and the public Walrus aggregator. **No private key and no relayer** are +involved. + +## Quick check (SDK) + +```ts +import { MemWal } from "@mysten-incubation/memwal"; + +const report = await MemWal.verify(blobId, { + accountId, // owning MemWalAccount object id + network: "testnet", // or "mainnet" +}); + +console.log(report); +// { +// ok: true, +// onChain: true, // Blob object found on Sui with memwal_* metadata +// agentKeyOk: true, // memwal_agent_id is a registered DelegateKey pubkey +// ownerOk: true, +// walrusReachable: true,// blob served by the public aggregator +// namespace: "…", +// metadata: { memwal_namespace: "…", memwal_owner: "0x…", memwal_agent_id: "0x…", … }, +// blobObjectId: "0x…", +// notes: [], +// } +``` + +`report.ok` is `true` only when the hard checks pass: `onChain && agentKeyOk && +walrusReachable` (and `ownerOk` when you pass an `expectedOwner`). + +## What it proves, and how + +A memory is anchored by a chain of independently checkable links: + +``` +MemWalAccount ──registers──▶ DelegateKey (public_key) + │ │ + │ │ writes + ▼ ▼ + memwal_owner Walrus Blob object on Sui + │ on-chain Metadata (VecMap): + │ memwal_namespace + │ memwal_owner + │ memwal_package_id + │ memwal_agent_id ◀── equals a registered DelegateKey pubkey + ▼ + content-addressed encrypted bytes on Walrus + (retrievable from the public aggregator, SEAL-encrypted at rest) +``` + +`verify()` walks that chain: + +1. **On-chain provenance** — resolves the `…::blob::Blob` object on Sui and reads its + `memwal_*` metadata. +2. **Delegate-key binding** — confirms `memwal_agent_id` is one of the `accountId`'s + registered `DelegateKey` public keys, cryptographically tying the blob to a key the + account authorized. +3. **Relayer-independent availability** — fetches the blob straight from the public + Walrus aggregator (`GET /v1/blobs/{blobId}` → `200`); the relayer is never in the path. + +## On-chain metadata schema + +Each memory's Walrus `Blob` carries this metadata (`VecMap`), readable +by anyone via `sui_getObject`: + +| Key | Meaning | +| -------------------- | ---------------------------------------------------------- | +| `memwal_namespace` | Namespace the memory was written under | +| `memwal_owner` | Owner address | +| `memwal_package_id` | MemWal Move package that wrote it | +| `memwal_agent_id` | Public key of the authorized `DelegateKey` that wrote it | + +> **Privacy note.** Content is SEAL-encrypted at rest, but `memwal_namespace`, +> `memwal_owner`, and `memwal_agent_id` are stored as **public plaintext** on-chain. +> Do not name namespaces after end-users or tenants (e.g. `patient-12345`, `org-acme`) +> if those names are sensitive — they become world-readable. For privacy-sensitive +> integrations, store an opaque/hashed namespace. + +## Doing it by hand (no SDK) + +1. **Fetch the blob without the relayer:** + ``` + curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \ + https://aggregator.walrus-testnet.walrus.space/v1/blobs/ + ``` + A `200` with non-zero size confirms public availability. The bytes are + SEAL-encrypted — no plaintext should be visible. + +2. **Read the on-chain Blob + metadata** with `sui_getObject` (showContent), and read + the account with another `sui_getObject` to list its registered `DelegateKey` + public keys. Confirm `memwal_agent_id` is among them. + +That's the entire trust basis: chain + public Walrus, no relayer. + +## Roadmap + +- **Content-address recompute** (`report.contentAddressed`): recompute the Walrus blob + id from the downloaded bytes to prove byte-for-byte integrity end-to-end. This needs + a Walrus encoding dependency and lands as a follow-up. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0dfdb6ca..39055418 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -22,6 +22,11 @@ export { // Delegate key utilities (no @mysten/sui dependency) export { delegateKeyToSuiAddress, delegateKeyToPublicKey } from "./utils.js"; +// Trustless verification (public-inputs-only; @mysten/sui loaded dynamically, +// so this stays importable from the dependency-light default entry point). +export { verifyMemory } from "./verify.js"; +export type { VerifyOptions, VerifyReport, MemWalNetwork } from "./verify.js"; + // Types for the default client, including its lightweight manual endpoints. export type { MemWalConfig, diff --git a/packages/sdk/src/memwal.ts b/packages/sdk/src/memwal.ts index c9c9cadc..bb5665cf 100644 --- a/packages/sdk/src/memwal.ts +++ b/packages/sdk/src/memwal.ts @@ -68,6 +68,8 @@ import { assertCompatibleRelayer, compatibilityErrorFromStatus, } from "./compatibility.js"; +import { verifyMemory } from "./verify.js"; +import type { VerifyOptions, VerifyReport } from "./verify.js"; // ============================================================ // Ed25519 Signing (lazy-loaded) @@ -208,6 +210,22 @@ export class MemWal { return new MemWal(config); } + /** + * Independently verify a memory's provenance from PUBLIC INPUTS ONLY — + * no private key and no relayer. Resolves the on-chain Walrus `Blob`, + * checks its `memwal_agent_id` against the account's registered + * `DelegateKey`s, and confirms public-aggregator retrievability. + * + * Thin delegate to {@link verifyMemory}; `@mysten/sui` is loaded + * dynamically, so calling this does not pull it into the default entry. + * + * @param blobId - Walrus blob id of the memory to verify + * @param opts.accountId - owning MemWalAccount object id (enables the key-binding check) + */ + static verify(blobId: string, opts?: VerifyOptions): Promise { + return verifyMemory(blobId, opts); + } + /** * Securely wipe the private and public keys from memory. * Prevents key extraction from V8 heap dumps. diff --git a/packages/sdk/src/verify.ts b/packages/sdk/src/verify.ts new file mode 100644 index 00000000..3632e59f --- /dev/null +++ b/packages/sdk/src/verify.ts @@ -0,0 +1,354 @@ +/** + * Trustless, local verification of a Walrus Memory (MemWal) blob. + * + * Confirms a memory's provenance from PUBLIC INPUTS ONLY — no private key, + * no relayer. Given a `blobId` and the owning `accountId`, it: + * + * 1. resolves the on-chain Walrus `Blob` object on Sui and reads its + * `memwal_*` metadata (namespace / owner / package_id / agent_id); + * 2. checks that `memwal_agent_id` is one of the account's registered + * `DelegateKey` public keys (the cryptographic blob <-> account binding); + * 3. confirms the blob is retrievable from the public Walrus aggregator; + * 4. (optional, see ALIGN-Q3) recomputes the Walrus content-address from bytes. + * + * Everything here is reachable by a third party with no MemWal credentials. + * + * `@mysten/sui` is loaded dynamically (mirrors `account.ts`), so this module + * stays importable from the dependency-light default entry point. + * + * NOTE FOR REVIEWERS (ALIGN markers): three spots depend on the exact on-chain + * schema and are flagged ALIGN-Q1 / ALIGN-Q3. They mirror the open questions in + * issue #315 and are written defensively; happy to tighten to the canonical + * layout once confirmed. + */ + +export type MemWalNetwork = "testnet" | "mainnet"; + +const SUI_FULLNODE: Record = { + testnet: "https://fullnode.testnet.sui.io:443", + mainnet: "https://fullnode.mainnet.sui.io:443", +}; + +const ENDPOINTS: Record = { + testnet: { aggregator: "https://aggregator.walrus-testnet.walrus.space" }, + mainnet: { aggregator: "https://aggregator.walrus-mainnet.walrus.space" }, +}; + +const METADATA_KEYS = [ + "memwal_namespace", + "memwal_owner", + "memwal_package_id", + "memwal_agent_id", +] as const; + +export interface VerifyOptions { + /** Owning MemWalAccount object id — used to check the delegate-key binding. */ + accountId?: string; + /** Network preset for default RPC + aggregator endpoints. Default: "testnet". */ + network?: MemWalNetwork; + /** Override the Sui JSON-RPC endpoint. */ + suiRpcUrl?: string; + /** Override the public Walrus aggregator base (e.g. https://aggregator.walrus-testnet.walrus.space). */ + walrusAggregatorUrl?: string; + /** + * ALIGN-Q1: optionally short-circuit on-chain resolution by passing the Sui + * object id of the `Blob` directly. When omitted, we resolve by scanning the + * account's owned `Blob` objects and matching `blob_id === blobId`. + */ + objectId?: string; + /** When supplied, `ownerOk` asserts `memwal_owner === expectedOwner`. */ + expectedOwner?: string; + /** + * Inject a pre-built SuiClient. Required for `@mysten/sui` v2.6.0+, where the + * client constructor moved — same escape hatch the account API uses. + */ + suiClient?: any; +} + +export interface VerifyReport { + /** Hard checks all passed: onChain && agentKeyOk && walrusReachable (&& ownerOk when expectedOwner given). */ + ok: boolean; + /** Blob object resolved on Sui and carries memwal_* metadata. */ + onChain: boolean; + /** memwal_agent_id is one of the account's registered DelegateKey pubkeys. */ + agentKeyOk: boolean; + /** memwal_owner matches expectedOwner (true when no expectedOwner supplied and metadata present). */ + ownerOk: boolean; + /** Blob retrievable from the public Walrus aggregator. */ + walrusReachable: boolean; + /** ALIGN-Q3: recomputed Walrus id matches blobId. `undefined` until the encoding recompute lands. */ + contentAddressed?: boolean; + /** memwal_namespace from on-chain metadata, if present. */ + namespace?: string; + /** Raw memwal_* metadata map read from the Blob object. */ + metadata: Record; + /** Resolved Sui object id of the Blob (useful for re-checks / debugging). */ + blobObjectId?: string; + /** Non-fatal diagnostics (resolution misses, RPC hiccups, etc.). */ + notes: string[]; +} + +/** + * Verify a memory's provenance with no relayer and no private key. + */ +export async function verifyMemory( + blobId: string, + opts: VerifyOptions = {}, +): Promise { + const network = opts.network ?? "testnet"; + const aggregator = ( + opts.walrusAggregatorUrl ?? ENDPOINTS[network].aggregator + ).replace(/\/$/, ""); + + const notes: string[] = []; + const report: VerifyReport = { + ok: false, + onChain: false, + agentKeyOk: false, + ownerOk: false, + walrusReachable: false, + metadata: {}, + notes, + }; + + const client = await buildSuiClient(opts, network, notes); + + if (client) { + // ── 1. resolve the on-chain Blob object + memwal_* metadata ──────────── + const blobObjectId = await resolveBlobObjectId(client, blobId, opts, notes); + if (blobObjectId) { + report.blobObjectId = blobObjectId; + const obj = await client.getObject({ + id: blobObjectId, + options: { showContent: true, showType: true }, + }); + const fields = contentFields(obj.data?.content); + const metadata = extractMemwalMetadata(fields); + report.metadata = metadata; + report.namespace = metadata.memwal_namespace; + report.onChain = METADATA_KEYS.some((k) => k in metadata); + if (!report.onChain) { + notes.push( + `Blob object ${blobObjectId} resolved but carried no memwal_* metadata — ` + + `confirm the metadata VecMap path (ALIGN-Q1).`, + ); + } + } else { + notes.push( + `Could not resolve a Blob object for blobId ${blobId}. Pass opts.objectId ` + + `to short-circuit, or confirm the account's blob-ownership model (ALIGN-Q1).`, + ); + } + + // ── 2. delegate-key binding: memwal_agent_id ∈ account's DelegateKeys ─── + const agentId = report.metadata.memwal_agent_id; + if (opts.accountId && agentId) { + const pubkeys = await registeredDelegateKeys(client, opts.accountId, notes); + report.agentKeyOk = pubkeys.has(normalizeHex(agentId)); + if (!report.agentKeyOk && pubkeys.size === 0) { + notes.push( + `No DelegateKey pubkeys read from account ${opts.accountId} — confirm where ` + + `delegate keys live on MemWalAccount (content vector vs dynamic field) (ALIGN-Q1).`, + ); + } + } else if (!opts.accountId) { + notes.push("agentKeyOk skipped: pass opts.accountId to check the delegate-key binding."); + } + } else { + notes.push( + "On-chain checks skipped: no SuiClient available. Install @mysten/sui, or pass " + + "opts.suiClient for v2.6.0+. Walrus retrievability is still checked below.", + ); + } + + // ── 3. owner check ───────────────────────────────────────────────────── + if (opts.expectedOwner) { + report.ownerOk = + normalizeHex(report.metadata.memwal_owner ?? "") === normalizeHex(opts.expectedOwner); + } else { + report.ownerOk = Boolean(report.metadata.memwal_owner); + } + + // ── 4. relayer-independent retrievability from public Walrus aggregator ─ + const dl = await fetchBlob(aggregator, blobId, notes); + report.walrusReachable = dl.ok; + + // ── (ALIGN-Q3) content-address recompute — deferred until the encoding dep lands. + // report.contentAddressed = recomputeWalrusId(dl.bytes) === blobId; + + report.ok = + report.onChain && + report.agentKeyOk && + report.walrusReachable && + (opts.expectedOwner ? report.ownerOk : true); + + return report; +} + +// ── helpers ────────────────────────────────────────────────────────────── + +/** Build a SuiClient via the dynamic-import convention used across the SDK. */ +async function buildSuiClient( + opts: VerifyOptions, + network: MemWalNetwork, + notes: string[], +): Promise { + if (opts.suiClient) return opts.suiClient; + try { + const mod = await import("@mysten/sui/client"); + const SuiClient = (mod as any).SuiClient; + if (typeof SuiClient !== "function") { + notes.push("SuiClient not found. For @mysten/sui v2.6.0+, pass opts.suiClient."); + return undefined; + } + return new SuiClient({ url: opts.suiRpcUrl ?? SUI_FULLNODE[network] }); + } catch (e) { + notes.push(`Could not load @mysten/sui/client: ${(e as Error).message}`); + return undefined; + } +} + +/** ALIGN-Q1: resolve a Walrus blobId to its Sui Blob object id. */ +async function resolveBlobObjectId( + client: any, + blobId: string, + opts: VerifyOptions, + notes: string[], +): Promise { + if (opts.objectId) return opts.objectId; + if (!opts.accountId) { + notes.push("Blob resolution needs opts.accountId or opts.objectId."); + return undefined; + } + // Scan objects owned by the account address for a Blob whose blob_id matches. + // (The PoC matched against the delegate `owner`; the account may also own the + // Blob, or it may hang off a dynamic field — hence ALIGN-Q1.) + let cursor: string | null = null; + for (let page = 0; page < 10; page++) { + const owned: any = await client.getOwnedObjects({ + owner: opts.accountId, + options: { showType: true, showContent: true }, + cursor, + limit: 50, + }); + for (const o of owned.data) { + const type = o.data?.type ?? ""; + if (!type.includes("::blob::Blob")) continue; + const f = contentFields(o.data?.content); + const onChainBlobId = String(f?.blob_id ?? f?.blobId ?? ""); + if (onChainBlobId && onChainBlobId === blobId) return o.data?.objectId; + } + if (!owned.hasNextPage) break; + cursor = owned.nextCursor ?? null; + } + return undefined; +} + +/** ALIGN-Q1: read the account's registered DelegateKey public keys. */ +async function registeredDelegateKeys( + client: any, + accountId: string, + notes: string[], +): Promise> { + const keys = new Set(); + try { + const acc = await client.getObject({ + id: accountId, + options: { showContent: true }, + }); + const fields = contentFields(acc.data?.content); + // Look for a vector/table of delegate keys in the account content. + collectPubkeys(fields, keys); + + // Also sweep dynamic fields, where registries commonly live. + let cursor: string | null = null; + for (let page = 0; page < 5; page++) { + const dyn: any = await client.getDynamicFields({ parentId: accountId, cursor, limit: 50 }); + for (const d of dyn.data) { + try { + const dfo = await client.getDynamicFieldObject({ parentId: accountId, name: d.name }); + collectPubkeys(contentFields(dfo.data?.content), keys); + } catch { + /* best-effort */ + } + } + if (!dyn.hasNextPage) break; + cursor = dyn.nextCursor ?? null; + } + } catch (e) { + notes.push(`registeredDelegateKeys: ${(e as Error).message}`); + } + return keys; +} + +/** Recursively pull anything that looks like a delegate public key into `out`. */ +function collectPubkeys(node: unknown, out: Set): void { + if (node == null) return; + if (Array.isArray(node)) { + for (const v of node) collectPubkeys(v, out); + return; + } + if (typeof node === "object") { + for (const [k, v] of Object.entries(node as Record)) { + if (/public_key|publicKey|pubkey/i.test(k) && (typeof v === "string" || Array.isArray(v))) { + out.add(normalizeHex(bytesToHex(v))); + } + collectPubkeys(v, out); + } + } +} + +/** Extract memwal_* keys from a Blob object's Metadata VecMap. */ +function extractMemwalMetadata(fields: Record | undefined): Record { + const out: Record = {}; + if (!fields) return out; + // Metadata is nested: blob.metadata -> Metadata -> metadata (VecMap) -> contents[] + const meta = (fields.metadata as any)?.fields?.metadata?.fields?.contents + ?? (fields.metadata as any)?.fields?.contents + ?? (fields.metadata as any)?.contents; + if (Array.isArray(meta)) { + for (const entry of meta) { + const key = entry?.fields?.key ?? entry?.key; + const value = entry?.fields?.value ?? entry?.value; + if (typeof key === "string" && key.startsWith("memwal_")) out[key] = String(value); + } + } + return out; +} + +async function fetchBlob( + aggregator: string, + blobId: string, + notes: string[], +): Promise<{ ok: boolean; status?: number; bytes?: Uint8Array }> { + // The aggregator has used both /v1/blobs/{id} and /v1/{id}; try both. + for (const path of [`/v1/blobs/${blobId}`, `/v1/${blobId}`]) { + try { + const res = await fetch(aggregator + path); + if (res.ok) { + return { ok: true, status: res.status, bytes: new Uint8Array(await res.arrayBuffer()) }; + } + notes.push(`aggregator ${path} -> ${res.status}`); + } catch (e) { + notes.push(`aggregator ${path} -> ${(e as Error).message}`); + } + } + return { ok: false }; +} + +function contentFields(content: unknown): Record | undefined { + if (content && typeof content === "object" && "fields" in (content as any)) { + return (content as any).fields as Record; + } + return undefined; +} + +function bytesToHex(v: unknown): string { + if (typeof v === "string") return v; + if (Array.isArray(v)) return "0x" + v.map((n) => Number(n).toString(16).padStart(2, "0")).join(""); + return ""; +} + +function normalizeHex(s: string): string { + return s.toLowerCase().replace(/^0x/, ""); +}