From 8931c9de31099491968a8cacbdcbfed3f92f19a7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 27 Sep 2026 00:38:09 +0900 Subject: [PATCH] Sign portable compound activities with one proof Fedify's activity signer created one Object Integrity Proof for each Ed25519 key, so a sender with several Ed25519 keys emitted a proof set. The map-local compound-proof profile that Fedify's inbox applies to documents containing FEP-ef61 portable maps accepts exactly one direct proof per map, so Fedify rejected such activities from Fedify itself. The three places that sign outgoing activities now share one helper with an explicit rule for documents that contain portable maps: - An activity that already carries a proof is kept as is, including when it is sent by actor identifier. - A single Ed25519 key signs the activity, as before. - With several Ed25519 keys, a portable activity is signed only by the key whose ID is a DID URL for the activity's DID, the only proof FEP-ef61 accepts. If no key or several keys qualify, or a non-portable activity embeds portable maps, sendActivity() rejects with an actionable TypeError before delivering or enqueueing. A final check also refuses to deliver or enqueue a document with portable maps in which any map carries a proof set, for example after signObject() has been called twice. The producer-side scans are unbounded, so neither check depends on the inbox's traversal limits. Activities without portable maps are signed exactly as before. Fixes https://github.com/fedify-dev/fedify/issues/1045 https://github.com/fedify-dev/fedify/issues/288 https://github.com/fedify-dev/fedify/pull/1041 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6-astra --- CHANGES.md | 22 +- .../fedify/compound-proof-key-selection.md | 26 + docs/manual/send.md | 73 ++- packages/fedify/src/federation/middleware.ts | 126 ++-- .../src/federation/outgoing-proof.test.ts | 596 ++++++++++++++++++ .../fedify/src/federation/outgoing-proof.ts | 186 ++++++ .../src/sig/compound-proof-producer.test.ts | 99 +++ packages/fedify/src/sig/compound-proof.ts | 95 +++ 8 files changed, 1154 insertions(+), 69 deletions(-) create mode 100644 changes.d/fedify/compound-proof-key-selection.md create mode 100644 packages/fedify/src/federation/outgoing-proof.test.ts create mode 100644 packages/fedify/src/federation/outgoing-proof.ts create mode 100644 packages/fedify/src/sig/compound-proof-producer.test.ts diff --git a/CHANGES.md b/CHANGES.md index 59213aff4..ce714b6cf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,24 @@ To be released. ### @fedify/fedify + - Changed `Context.sendActivity()` so that an activity containing + [FEP-ef61] portable objects gets at most one Object Integrity Proof. + Previously Fedify signed every outgoing activity once for each Ed25519 + key, which produced a proof set that Fedify's own inbox rejects in a + compound portable document. [[#288], [#1041], [#1045], [#1073]] + + - An activity that already carries a proof is sent as is. + - With several Ed25519 keys, a portable activity is signed only by the + key whose ID is a DID URL for the activity's DID. + - If no single key qualifies, or a non-portable activity that embeds + portable objects has several Ed25519 keys, `sendActivity()` rejects + with a `TypeError` before anything is delivered or queued. Pass + explicit sender keys with exactly one Ed25519 key, or sign the + activity with `signObject()` beforehand. + - An activity with portable objects in which any map carries a proof + set is rejected the same way. + - Activities without portable objects are signed as before. + - Changed cached actor public keys and remembered per-origin HTTP Message Signatures specs to expire, so a `KvStore` that never sees an explicit clear no longer accumulates entries for actors and origins that have @@ -158,8 +176,8 @@ To be released. traversal limits are rejected as unsupported. [[#288], [#938], [#1041]] -[key–value store guide]: https://fedify.dev/manual/kv [FEP-ef61]: https://w3id.org/fep/ef61 +[key–value store guide]: https://fedify.dev/manual/kv [FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 [ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload @@ -190,7 +208,9 @@ To be released. [#1027]: https://github.com/fedify-dev/fedify/pull/1027 [#1041]: https://github.com/fedify-dev/fedify/pull/1041 [#1044]: https://github.com/fedify-dev/fedify/issues/1044 +[#1045]: https://github.com/fedify-dev/fedify/issues/1045 [#1051]: https://github.com/fedify-dev/fedify/pull/1051 +[#1073]: https://github.com/fedify-dev/fedify/pull/1073 ### @fedify/adonisjs diff --git a/changes.d/fedify/compound-proof-key-selection.md b/changes.d/fedify/compound-proof-key-selection.md new file mode 100644 index 000000000..a3f48584e --- /dev/null +++ b/changes.d/fedify/compound-proof-key-selection.md @@ -0,0 +1,26 @@ +--- +links: + '#1041': https://github.com/fedify-dev/fedify/pull/1041 + '#1045': https://github.com/fedify-dev/fedify/issues/1045 + '#1073': https://github.com/fedify-dev/fedify/pull/1073 + '#288': https://github.com/fedify-dev/fedify/issues/288 +--- + - Changed `Context.sendActivity()` so that an activity containing + [FEP-ef61] portable objects gets at most one Object Integrity Proof. + Previously Fedify signed every outgoing activity once for each Ed25519 + key, which produced a proof set that Fedify's own inbox rejects in a + compound portable document. [[#288], [#1041], [#1045], [#1073]] + + - An activity that already carries a proof is sent as is. + - With several Ed25519 keys, a portable activity is signed only by the + key whose ID is a DID URL for the activity's DID. + - If no single key qualifies, or a non-portable activity that embeds + portable objects has several Ed25519 keys, `sendActivity()` rejects + with a `TypeError` before anything is delivered or queued. Pass + explicit sender keys with exactly one Ed25519 key, or sign the + activity with `signObject()` beforehand. + - An activity with portable objects in which any map carries a proof + set is rejected the same way. + - Activities without portable objects are signed as before. + +[FEP-ef61]: https://w3id.org/fep/ef61 diff --git a/docs/manual/send.md b/docs/manual/send.md index 5c2eec7e9..230b657d5 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1082,7 +1082,9 @@ Fedify automatically includes the integrity proof of activities by signing them with the sender's private key if the [actor keys dispatcher is set](./actor.md#public-keys-of-an-actor) and the actor has any Ed25519 key pair. If there are multiple key pairs, Fedify creates the number of integrity proofs -equal to the number of Ed25519 key pairs. +equal to the number of Ed25519 key pairs. An activity containing [FEP-ef61] +portable objects is the exception: it gets at most one proof, as described in +[*Choosing the proof key*](#choosing-the-proof-key). When verifying incoming Object Integrity Proofs, Fedify can resolve Ed25519 `did:key` verification methods locally. A proof whose `verificationMethod` @@ -1228,6 +1230,69 @@ the returned object afterwards, and `clone()` never carries it, because a clone may differ from the document the proof covers. Sign the clone again when it has to be embedded as a secured child. +#### Choosing the proof key + +Outside the compound profile, `sendActivity()` signs an activity once for each +Ed25519 key it is given, which yields a proof set when there are several keys. +Fedify inboxes reject a proof set in a document that contains a portable +object, so an activity whose JSON contains a map identified by an `ap:` or +`ap+ef61:` URI, whether the activity itself or anything embedded in it, gets at +most one proof: + + - An activity that already carries a proof is sent as is. Fedify does not + add another proof to it, not even with the keys from the [actor key pairs + dispatcher](./actor.md#public-keys-of-an-actor). + - With a single Ed25519 key, that key signs the activity. + - With several Ed25519 keys, a portable activity is signed only by the key + whose ID is a DID URL for the activity's own DID, such as + `did:key:z6Mk…#z6Mk…` for `ap://did:key:z6Mk…/activities/1`. This is the + only proof [FEP-ef61] accepts for it. + +When no key or more than one key qualifies, or when a non-portable activity +embeds portable objects and several Ed25519 keys are available, +`sendActivity()` rejects with a `TypeError` instead of guessing. Nothing is +delivered or queued in that case. To choose the key yourself, pass explicit +sender keys that contain exactly one Ed25519 key, or sign the activity with +`signObject()` before sending it. RSA keys in the same list keep signing the +HTTP request and the Linked Data Signature as usual: + +~~~~ typescript twoslash +import type { Context } from "@fedify/fedify"; +import type { Create, Recipient } from "@fedify/vocab"; +const ctx = null as unknown as Context; +const rsaPrivateKey = null as unknown as CryptoKey; +const ed25519PrivateKey = null as unknown as CryptoKey; +const recipient = null as unknown as Recipient; +const activity = null as unknown as Create; +// ---cut-before--- +await ctx.sendActivity( + [ + { + keyId: new URL("https://example.com/users/alice#main-key"), + privateKey: rsaPrivateKey, + }, + { + keyId: new URL("did:key:z6Mkabc#z6Mkabc"), + privateKey: ed25519PrivateKey, + }, + ], + recipient, + activity, +); +~~~~ + +The Multikey IDs Fedify derives for the actor key pairs dispatcher are +fragments of the actor URI, such as `…/actor#multikey-1`, not DID URLs. With +several dispatched Ed25519 keys, an unsigned portable activity sent by actor +identifier is therefore always rejected; send it with explicit sender keys or +pre-sign it instead. + +Fedify also refuses to send an activity with portable objects if any map in it +already carries a proof set, which happens, for example, when `signObject()` +is called twice on the same object. The error names the JSON Pointer of the +offending `proof`. These checks only prevent unsupported proof shapes. They +do not otherwise validate a proof created with a single key. + > [!WARNING] > Several things take a signed child outside this supported path, and each > one falls back to ordinary serialization, which rebuilds the child under the @@ -1239,9 +1304,9 @@ when it has to be embedded as a secured child. > [`forwardActivity()`](./outbox.md#federating-posted-activities) to avoid > a vocabulary-object round trip. > - An object that already carried a proof. The profile accepts exactly one -> direct proof per map, while Fedify's ordinary activity signer creates one -> proof for each Ed25519 key, so a sender producing a portable compound -> document must arrange for exactly one direct proof on each map. +> direct proof per map, so sign each object with exactly one key. Fedify +> refuses to send an activity whose portable content carries a proof set; +> see [*Choosing the proof key*](#choosing-the-proof-key). > - A `toJsonLd()` call whose `context` option could hide the marker Fedify > uses to place the captured document, for example a context that aliases > `@id` under a term other than `id`, declares `@nest`, uses an `@id` or diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index fbccf9dfb..69c0d5a9c 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -78,7 +78,7 @@ import { wrapContextLoaderForJsonLd, } from "../sig/ld.ts"; import { getKeyOwner, type GetKeyOwnerOptions } from "../sig/owner.ts"; -import { hasProofLike, signObject, verifyObject } from "../sig/proof.ts"; +import { hasProofLike, verifyObject } from "../sig/proof.ts"; import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { kvCache } from "../utils/kv-cache.ts"; import { @@ -153,6 +153,10 @@ import { } from "./metrics.ts"; import type { MessageQueue } from "./mq.ts"; import { acceptsJsonLd } from "./negotiation.ts"; +import { + assertSupportedCompoundProofShape, + signOutgoingActivity, +} from "./outgoing-proof.ts"; import type { FanoutMessage, InboxMessage, @@ -2339,8 +2343,6 @@ export class FederationImpl this.#getLoaderOptions(ctx.origin), ); const activityId = activity.id.href; - let hasProof = false; - let proofCreated = false; let rsaKey: { keyId: URL; privateKey: CryptoKey } | null = null; for (const { keyId, privateKey } of keys) { validateCryptoKey(privateKey, "private"); @@ -2350,22 +2352,20 @@ export class FederationImpl } // If Object Integrity Proofs were already created before fanout (e.g., in // sendActivityInternal()), skip signing to avoid duplicates. - for await (const _ of activity.getProofs({ contextLoader })) { - hasProof = true; - break; - } - if (!hasProof) { - for (const { keyId, privateKey } of keys) { - if (privateKey.algorithm.name === "Ed25519") { - activity = await signObject(activity, privateKey, keyId, { - contextLoader, - tracerProvider: this.tracerProvider, - }); - hasProof = true; - proofCreated = true; - } - } - } + const signed = await signOutgoingActivity( + activity, + keys.map(({ keyId, privateKey }) => ({ + verificationMethod: keyId, + privateKey, + })), + { + contextLoader, + tracerProvider: this.tracerProvider, + appendToExistingProofs: false, + }, + ); + activity = signed.activity; + const { hasProof, proofCreated } = signed; let jsonLd = !proofCreated && options.activityJsonLd != null ? options.activityJsonLd : await activity.toJsonLd({ @@ -2381,6 +2381,7 @@ export class FederationImpl preserveNestedSecuredDocuments: true, }); } + assertSupportedCompoundProofShape(jsonLd, activityId); if (rsaKey == null) { logger.warn( "No supported key found to create a Linked Data signature for " + @@ -3919,48 +3920,41 @@ export class ContextImpl implements Context { // Pre-sign with Object Integrity Proofs before fanout so that all // recipients receive the same signed activity. Uses Multikey IDs so that // verifiers can look up the correct key type in the actor document. - let proofCreated = false; - if (actorKeyPairs != null) { - const contextLoader = this.contextLoader; - for (const kp of actorKeyPairs) { - if ( - kp.privateKey.algorithm.name !== "Ed25519" || - kp.multikey.id == null - ) continue; - activity = await signObject(activity, kp.privateKey, kp.multikey.id, { - contextLoader, + // + // Explicit sender keys carry no Multikey, so they sign with the key ID + // the caller supplied, which is exactly what the delivery worker would do + // after reparsing the activity. Signing here instead keeps a signed + // child's retained representation intact: the reparsed activity no + // longer carries one, so a worker-side proof would cover a rebuilt child + // whose own proof no longer verifies. + // + // An activity the caller already signed keeps its own proof when sent + // with explicit keys: appending another would turn a single-proof + // document into a proof set, which the map-local compound-proof profile + // does not accept. This mirrors the guard `FederationImpl.sendActivity()` + // applies before signing. Actor key pairs keep appending outside that + // profile, as they always have. + const { activity: signedActivity, proofCreated } = + await signOutgoingActivity( + activity, + actorKeyPairs == null + ? keys.map(({ keyId, privateKey }) => ({ + verificationMethod: keyId, + privateKey, + })) + : actorKeyPairs.flatMap((kp) => + kp.multikey.id == null ? [] : [{ + verificationMethod: kp.multikey.id, + privateKey: kp.privateKey, + }] + ), + { + contextLoader: this.contextLoader, tracerProvider: this.tracerProvider, - }); - proofCreated = true; - } - } else { - // Explicit sender keys carry no Multikey, so sign with the key ID the - // caller supplied, which is exactly what the delivery worker would do - // after reparsing the activity. Signing here instead keeps a signed - // child's retained representation intact: the reparsed activity no - // longer carries one, so a worker-side proof would cover a rebuilt - // child whose own proof no longer verifies. - const contextLoader = this.contextLoader; - // An activity the caller already signed keeps its own proof: appending - // another would turn a single-proof document into a proof set, which - // the map-local compound-proof profile does not accept. This mirrors - // the guard `FederationImpl.sendActivity()` applies before signing. - let hasProof = false; - for await (const _ of activity.getProofs({ contextLoader })) { - hasProof = true; - break; - } - if (!hasProof) { - for (const { keyId, privateKey } of keys) { - if (privateKey.algorithm.name !== "Ed25519") continue; - activity = await signObject(activity, privateKey, keyId, { - contextLoader, - tracerProvider: this.tracerProvider, - }); - proofCreated = true; - } - } - } + appendToExistingProofs: actorKeyPairs != null, + }, + ); + activity = signedActivity; const inboxes = extractInboxes({ recipients: expandedRecipients, preferSharedInbox: options.preferSharedInbox, @@ -3990,6 +3984,13 @@ export class ContextImpl implements Context { }); return true; } + const activityJsonLd = await activity.toJsonLd({ + format: "compact", + contextLoader: this.contextLoader, + }); + // Reject before anything is enqueued, so the caller learns about it + // instead of the fanout worker. + assertSupportedCompoundProofShape(activityJsonLd, activity.id?.href); const keyJwkPairs = await Promise.all( keys.map(async ({ keyId, privateKey }) => ({ keyId: keyId.href, @@ -4008,10 +4009,7 @@ export class ContextImpl implements Context { [k, { actorIds, sharedInbox }], ) => [k, { actorIds: [...actorIds], sharedInbox }]), ), - activity: await activity.toJsonLd({ - format: "compact", - contextLoader: this.contextLoader, - }), + activity: activityJsonLd, activityId: activity.id?.href, activityType: getTypeId(activity).href, collectionSync: opts.collectionSync, diff --git a/packages/fedify/src/federation/outgoing-proof.test.ts b/packages/fedify/src/federation/outgoing-proof.test.ts new file mode 100644 index 000000000..906e0fce4 --- /dev/null +++ b/packages/fedify/src/federation/outgoing-proof.test.ts @@ -0,0 +1,596 @@ +import { mockDocumentLoader, test } from "@fedify/fixture"; +import { Create, Note, Person } from "@fedify/vocab"; +import { exportDidKey, parseIri } from "@fedify/vocab-runtime"; +import { assert, assertEquals, assertRejects } from "@std/assert"; +import fetchMock from "fetch-mock"; +import { + ed25519PrivateKey, + ed25519PublicKey, + rsaPrivateKey2, + rsaPublicKey2, +} from "../testing/keys.ts"; +import { verifyCompoundPortableObjectProofs } from "../sig/compound-proof.ts"; +import { detachSignature } from "../sig/ld.ts"; +import { signObject } from "../sig/proof.ts"; +import { exportJwk } from "../sig/key.ts"; +import { MemoryKvStore } from "./kv.ts"; +import { createFederation, FederationImpl } from "./middleware.ts"; +import type { MessageQueue } from "./mq.ts"; +import type { FanoutMessage, Message } from "./queue.ts"; +import type { SenderKeyPair } from "./send.ts"; + +const options = { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, +}; +const created = Temporal.Instant.from("2023-02-24T23:36:38Z"); +const childContext = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + { ex: "https://example.com/ns#" }, +]; +const limits = { + maxDepth: 64, + maxMaps: 10_000, + maxProofs: 32, + maxBytes: 10 * 1024 * 1024, +}; +const recipient = { + id: new URL("https://example.com/users/bob"), + inboxId: new URL("https://example.com/inbox"), +}; +const rsaKey: SenderKeyPair = { + keyId: rsaPublicKey2.id!, + privateKey: rsaPrivateKey2, +}; + +interface DidKey { + readonly did: string; + readonly keyId: URL; + readonly privateKey: CryptoKey; + readonly publicKey: CryptoKey; +} + +async function didKey( + pair?: { privateKey: CryptoKey; publicKey: CryptoKey }, +): Promise { + const { privateKey, publicKey } = pair ?? + await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]) as CryptoKeyPair; + const did = await exportDidKey(publicKey); + return { + did, + keyId: new URL(`${did}#${did.substring("did:key:".length)}`), + privateKey, + publicKey, + }; +} + +function sender(key: DidKey | SenderKeyPair): SenderKeyPair { + return { keyId: key.keyId, privateKey: key.privateKey }; +} + +async function signedChild(owner: DidKey): Promise { + return await signObject( + new Note({ + id: parseIri(`ap+ef61://${owner.did}/objects/${crypto.randomUUID()}`), + attribution: parseIri(`ap+ef61://${owner.did}/actor`), + content: "A portable note", + }), + owner.privateKey, + owner.keyId, + { ...options, context: childContext, created }, + ); +} + +function portableCreate(owner: DidKey, object: Note): Create { + return new Create({ + id: parseIri(`ap+ef61://${owner.did}/activities/${crypto.randomUUID()}`), + actor: parseIri(`ap+ef61://${owner.did}/actor`), + object, + }); +} + +function httpCreate(object?: Note): Create { + return new Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/users/alice"), + object, + }); +} + +function createQueue(): { queue: MessageQueue; queued: Message[] } { + const queued: Message[] = []; + return { + queued, + queue: { + enqueue(message) { + queued.push(message as Message); + return Promise.resolve(); + }, + listen() { + return Promise.resolve(); + }, + }, + }; +} + +function createTestFederation(queue?: MessageQueue): FederationImpl { + const federation = new FederationImpl({ + kv: new MemoryKvStore(), + queue, + manuallyStartQueue: true, + contextLoaderFactory: () => mockDocumentLoader, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); + return federation; +} + +/** + * Captures what is POSTed to the recipient's inbox while `run` executes. + */ +async function capture( + run: () => Promise, +): Promise<{ bodies: Record[]; requests: Request[] }> { + const bodies: Record[] = []; + const requests: Request[] = []; + fetchMock.spyGlobal(); + try { + fetchMock.post(recipient.inboxId.href, async (cl) => { + const body = await cl.request!.text(); + requests.push( + new Request(cl.request!.url, { + method: cl.request!.method, + headers: cl.request!.headers, + body, + }), + ); + bodies.push(JSON.parse(body)); + return new Response(null, { status: 202 }); + }); + await run(); + } finally { + fetchMock.hardReset(); + } + return { bodies, requests }; +} + +function proofOf(document: Record): Record { + const proof = document.proof; + assert( + proof != null && typeof proof === "object" && !Array.isArray(proof), + `expected exactly one direct proof, got ${JSON.stringify(proof)}`, + ); + return proof as Record; +} + +async function assertCompoundVerifies( + body: Record, +): Promise { + // The Linked Data Signature Fedify adds for its RSA key is not part of the + // Object Integrity Proof's input. + const result = await verifyCompoundPortableObjectProofs( + detachSignature(body), + limits, + options, + ); + assertEquals(result.status, "ok"); + assert(result.status === "ok" && result.verified); +} + +test("a portable activity with one Ed25519 key gets one direct proof", async () => { + const owner = await didKey(); + const child = await signedChild(await didKey()); + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const { bodies, requests } = await capture(() => + ctx.sendActivity( + [rsaKey, sender(owner)], + recipient, + portableCreate(owner, child), + ) + ); + assertEquals(bodies.length, 1); + assertEquals(proofOf(bodies[0]).verificationMethod, owner.keyId.href); + // The RSA key still signs the request and the document. + assert(requests[0].headers.has("Signature")); + assert(bodies[0].signature != null); + await assertCompoundVerifies(bodies[0]); +}); + +test("a portable activity is signed only by the key matching its DID", async () => { + const owner = await didKey(); + const other = await didKey(); + const childOwner = await didKey(); + const child = await signedChild(childOwner); + for (const keys of [[other, owner], [owner, other]]) { + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const { bodies, requests } = await capture(() => + ctx.sendActivity( + // The child owner's key does not match the activity's DID either. + [rsaKey, ...keys.map(sender), sender(childOwner)], + recipient, + portableCreate(owner, child), + ) + ); + assertEquals(bodies.length, 1); + assertEquals(proofOf(bodies[0]).verificationMethod, owner.keyId.href); + assert(requests[0].headers.has("Signature")); + const embedded = bodies[0].object as Record; + assertEquals(embedded["@context"], childContext); + assertEquals(proofOf(embedded).verificationMethod, childOwner.keyId.href); + await assertCompoundVerifies(bodies[0]); + } +}); + +test("a portable activity without exactly one DID-matching key is rejected", async () => { + const owner = await didKey(); + const child = await signedChild(await didKey()); + const cases: [string, SenderKeyPair[], string][] = [ + [ + "no key matches", + [sender(await didKey()), sender(await didKey())], + "none of its 2 Ed25519 keys", + ], + [ + // A portable URL shares the DID origin but is not a DID URL. + "a same-origin key ID is not a DID URL", + [ + { + keyId: parseIri(`ap+ef61://${owner.did}/actor#main-key`), + privateKey: owner.privateKey, + }, + sender(await didKey()), + ], + "none of its 2 Ed25519 keys", + ], + [ + "several keys match", + [ + sender(owner), + { keyId: new URL(`${owner.did}#second`), privateKey: owner.privateKey }, + ], + "2 of its Ed25519 keys", + ], + ]; + for (const [name, keys, message] of cases) { + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const { bodies } = await capture(() => + assertRejects( + () => + ctx.sendActivity( + [rsaKey, ...keys], + recipient, + portableCreate(owner, child), + ), + TypeError, + message, + ) + ); + assertEquals(bodies.length, 0, name); + } +}); + +test("a non-portable activity embedding portable objects needs one key", async () => { + const child = await signedChild(await didKey()); + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const keys = [sender(await didKey()), sender(await didKey())]; + const rejected = await capture(() => + assertRejects( + () => + ctx.sendActivity( + [rsaKey, ...keys], + recipient, + httpCreate(child), + ), + TypeError, + "exactly one Ed25519 key", + ) + ); + assertEquals(rejected.bodies.length, 0); + + const key = { keyId: ed25519PublicKey.id!, privateKey: ed25519PrivateKey }; + const accepted = await capture(() => + ctx.sendActivity([rsaKey, key], recipient, httpCreate(child)) + ); + assertEquals(accepted.bodies.length, 1); + assertEquals( + proofOf(accepted.bodies[0]).verificationMethod, + ed25519PublicKey.id!.href, + ); +}); + +test("an activity without portable objects is still signed by every key", async () => { + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const keys = [await didKey(), await didKey()]; + const { bodies } = await capture(() => + ctx.sendActivity( + [rsaKey, ...keys.map(sender)], + recipient, + httpCreate(new Note({ content: "Hello" })), + ) + ); + assertEquals(bodies.length, 1); + const proofs = bodies[0].proof as Record[]; + assert(Array.isArray(proofs)); + assertEquals( + proofs.map((proof) => proof.verificationMethod), + keys.map((key) => key.keyId.href), + ); +}); + +test("a pre-signed portable activity is not re-signed", async () => { + const owner = await didKey(); + const activity = await signObject( + portableCreate(owner, await signedChild(await didKey())), + owner.privateKey, + owner.keyId, + { ...options, created }, + ); + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + // Neither key matches the activity's DID, but no key needs choosing. + const keys = [sender(await didKey()), sender(await didKey())]; + const { bodies } = await capture(() => + ctx.sendActivity( + [rsaKey, ...keys], + recipient, + activity, + ) + ); + assertEquals(bodies.length, 1); + assertEquals(proofOf(bodies[0]).verificationMethod, owner.keyId.href); + await assertCompoundVerifies(bodies[0]); +}); + +test("a portable activity carrying a proof set is not delivered", async () => { + const owner = await didKey(); + const second = { + ...owner, + keyId: new URL(`${owner.did}#second`), + }; + let activity: Create = portableCreate(owner, await signedChild(owner)); + for (const key of [owner, second]) { + activity = await signObject(activity, key.privateKey, key.keyId, { + ...options, + created, + }); + } + + // Immediate delivery. + const immediate = await capture(() => + assertRejects( + () => + createTestFederation().createContext( + new URL("https://example.com/"), + ).sendActivity(rsaKey, recipient, activity), + TypeError, + 'JSON Pointer "/proof"', + ) + ); + assertEquals(immediate.bodies.length, 0); + + // The ordinary outbox queue. + const outbox = createQueue(); + await assertRejects( + () => + createTestFederation(outbox.queue).createContext( + new URL("https://example.com/"), + ).sendActivity(rsaKey, recipient, activity, { fanout: "skip" }), + TypeError, + 'JSON Pointer "/proof"', + ); + assertEquals(outbox.queued.length, 0); + + // The fanout queue. + const fanout = createQueue(); + await assertRejects( + () => + createTestFederation(fanout.queue).createContext( + new URL("https://example.com/"), + ).sendActivity(rsaKey, recipient, activity, { fanout: "force" }), + TypeError, + 'JSON Pointer "/proof"', + ); + assertEquals(fanout.queued.length, 0); +}); + +test("a proof set on an embedded map is not delivered either", async () => { + const owner = await didKey(); + let child: Note = await signedChild(owner); + child = await signObject(child, owner.privateKey, new URL(`${owner.did}#b`), { + ...options, + created, + }); + const federation = createTestFederation(); + const ctx = federation.createContext(new URL("https://example.com/")); + const { bodies } = await capture(() => + assertRejects( + // No Ed25519 key at all, so the guard is the only thing in the way. + () => ctx.sendActivity(rsaKey, recipient, httpCreate(child)), + TypeError, + 'JSON Pointer "/object/proof"', + ) + ); + assertEquals(bodies.length, 0); +}); + +test("forced fanout selects the DID-matching key before enqueueing", async () => { + const owner = await didKey(); + const childOwner = await didKey(); + const child = await signedChild(childOwner); + const { queue, queued } = createQueue(); + const federation = createTestFederation(queue); + const ctx = federation.createContext(new URL("https://example.com/")); + const { bodies } = await capture(async () => { + await ctx.sendActivity( + [rsaKey, sender(await didKey()), sender(owner)], + recipient, + portableCreate(owner, child), + { fanout: "force" }, + ); + assertEquals(queued.length, 1); + const message = queued[0] as FanoutMessage; + assertEquals(message.type, "fanout"); + // Every transport key still travels with the message. + assertEquals(message.keys.length, 3); + assertEquals(message.keys[0].keyId, rsaKey.keyId.href); + for (let i = 0; i < queued.length; i++) { + await federation.processQueuedTask(undefined, queued[i]); + } + }); + assertEquals(bodies.length, 1); + assertEquals(proofOf(bodies[0]).verificationMethod, owner.keyId.href); + // The worker did not rebuild the signed child under the parent's context. + const embedded = bodies[0].object as Record; + assertEquals(embedded["@context"], childContext); + assertEquals(proofOf(embedded).verificationMethod, childOwner.keyId.href); + await assertCompoundVerifies(bodies[0]); +}); + +test("the fanout worker selects one key for an unsigned portable activity", async () => { + const owner = await didKey(); + const child = await signedChild(await didKey()); + const activity = portableCreate(owner, child); + const federation = createTestFederation(); + const message: FanoutMessage = { + type: "fanout", + id: crypto.randomUUID(), + baseUrl: "https://example.com", + keys: await Promise.all( + [rsaKey, sender(await didKey()), sender(owner)].map(async (key) => ({ + keyId: key.keyId.href, + privateKey: await exportJwk(key.privateKey), + })), + ), + inboxes: { + [recipient.inboxId.href]: { + actorIds: [recipient.id.href], + sharedInbox: false, + }, + }, + activity: await activity.toJsonLd({ format: "compact", ...options }), + activityId: activity.id!.href, + activityType: "https://www.w3.org/ns/activitystreams#Create", + traceContext: {}, + }; + const { bodies } = await capture(() => + federation.processQueuedTask(undefined, message) + ); + assertEquals(bodies.length, 1); + assertEquals(proofOf(bodies[0]).verificationMethod, owner.keyId.href); +}); + +test("actor key pairs follow the compound-proof key selection too", async () => { + const dispatched: { privateKey: CryptoKey; publicKey: CryptoKey }[] = []; + const federation = createTestFederation(); + federation + .setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => + new Person({ + id: ctx.getActorUri(identifier), + inbox: ctx.getInboxUri(identifier), + }), + ) + .setKeyPairsDispatcher(() => dispatched); + const ctx = federation.createContext(new URL("https://example.com/")); + const owner = await didKey(); + const child = await signedChild(await didKey()); + + // Dispatched Multikey IDs are not DID URLs, so none of several keys can + // sign a portable activity. + const other = await didKey(); + dispatched.push( + { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey! }, + { privateKey: owner.privateKey, publicKey: owner.publicKey }, + { privateKey: other.privateKey, publicKey: other.publicKey }, + ); + const rejected = await capture(() => + assertRejects( + () => + ctx.sendActivity( + { identifier: "alice" }, + recipient, + portableCreate(owner, child), + ), + TypeError, + "none of its 2 Ed25519 keys", + ) + ); + assertEquals(rejected.bodies.length, 0); + + // A portable activity that already carries a proof keeps just that one. + dispatched.splice(2); + const presigned = await signObject( + portableCreate(owner, child), + owner.privateKey, + owner.keyId, + { ...options, created }, + ); + const kept = await capture(() => + ctx.sendActivity({ identifier: "alice" }, recipient, presigned) + ); + assertEquals(kept.bodies.length, 1); + assertEquals(proofOf(kept.bodies[0]).verificationMethod, owner.keyId.href); + + // Outside the profile, dispatched keys still append to an existing proof. + const ordinary = await signObject( + httpCreate(new Note({ content: "Hello" })), + owner.privateKey, + owner.keyId, + { ...options, created }, + ); + const appended = await capture(() => + ctx.sendActivity({ identifier: "alice" }, recipient, ordinary) + ); + assertEquals(appended.bodies.length, 1); + const proofs = appended.bodies[0].proof as Record[]; + assert(Array.isArray(proofs)); + assertEquals(proofs.map((proof) => proof.verificationMethod), [ + owner.keyId.href, + "https://example.com/users/alice#multikey-2", + ]); +}); + +test("a Fedify inbox accepts a portable activity Fedify produced", async () => { + const owner = await didKey(); + const child = await signedChild(await didKey()); + const sending = createTestFederation(); + const ctx = sending.createContext(new URL("https://example.com/")); + // Without an RSA key, no Linked Data Signature is attached, so the delivered + // body is exactly what the Object Integrity Proofs cover. + const other = await didKey(); + const { requests } = await capture(() => + ctx.sendActivity( + [sender(other), sender(owner)], + recipient, + portableCreate(owner, child), + ) + ); + assertEquals(requests.length, 1); + + let received = 0; + const receiving = createFederation({ + kv: new MemoryKvStore(), + contextLoaderFactory: () => mockDocumentLoader, + documentLoaderFactory: () => mockDocumentLoader, + }); + receiving.setActorDispatcher("/users/{identifier}", () => null); + receiving + .setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(Create, () => { + received++; + }); + const response = await receiving.fetch(requests[0], { + contextData: undefined, + }); + assertEquals([response.status, received], [202, 1]); +}); diff --git a/packages/fedify/src/federation/outgoing-proof.ts b/packages/fedify/src/federation/outgoing-proof.ts new file mode 100644 index 000000000..c47904e58 --- /dev/null +++ b/packages/fedify/src/federation/outgoing-proof.ts @@ -0,0 +1,186 @@ +import type { Activity } from "@fedify/vocab"; +import { + type DocumentLoader, + getFe34Origin, + haveSameFe34Origin, +} from "@fedify/vocab-runtime"; +import { getLogger } from "@logtape/logtape"; +import type { TracerProvider } from "@opentelemetry/api"; +import { + containsCompoundPortableObject, + findUnsupportedCompoundProofShape, +} from "../sig/compound-proof.ts"; +import { signObject } from "../sig/proof.ts"; + +/** + * A key that could create an Object Integrity Proof for an outgoing activity. + * @internal + */ +export interface ProofSigningCandidate { + /** The verification method ID the proof will name. */ + readonly verificationMethod: URL; + /** The private key. Keys other than Ed25519 are ignored. */ + readonly privateKey: CryptoKey; +} + +/** @internal */ +export interface SignOutgoingActivityOptions { + readonly contextLoader: DocumentLoader; + readonly tracerProvider?: TracerProvider; + /** + * Whether an activity that already carries a proof gets one more proof per + * key when it is outside the map-local compound-proof profile. Inside the + * profile, an existing proof is always kept as is. + */ + readonly appendToExistingProofs: boolean; +} + +/** @internal */ +export interface SignOutgoingActivityResult { + readonly activity: Activity; + /** Whether the resulting activity carries at least one proof. */ + readonly hasProof: boolean; + /** Whether this call created a proof. */ + readonly proofCreated: boolean; +} + +function isPortableUrl(url: URL): boolean { + return url.protocol === "ap:" || url.protocol === "ap+ef61:"; +} + +function formatKeyIds(candidates: readonly ProofSigningCandidate[]): string { + return candidates.map((c) => c.verificationMethod.href).join(", "); +} + +/** + * Creates the Object Integrity Proofs Fedify attaches to an outgoing activity. + * + * Outside the map-local compound-proof profile, i.e., when the activity + * embeds no portable object, every Ed25519 key signs the activity, as it + * always has. Inside the profile, which accepts exactly one direct proof per + * map, Fedify creates at most one proof: + * + * - An activity that already carries a proof is kept as is. + * - A single Ed25519 key signs the activity. + * - With several Ed25519 keys, a portable activity is signed by the one key + * whose verification method is a DID URL for the activity's DID, the only + * proof the FEP-ef61 policy can accept. If no key or more than one key + * qualifies, or the activity is not portable itself, the choice is + * ambiguous and this function throws. + * + * @throws {TypeError} If a single proof key cannot be chosen. + * @internal + */ +export async function signOutgoingActivity( + activity: Activity, + candidates: readonly ProofSigningCandidate[], + options: SignOutgoingActivityOptions, +): Promise { + const { contextLoader, tracerProvider } = options; + let hasProof = false; + for await (const _ of activity.getProofs({ contextLoader })) { + hasProof = true; + break; + } + const keys = candidates.filter((c) => + c.privateKey.algorithm.name === "Ed25519" + ); + if (keys.length < 1 || hasProof && !options.appendToExistingProofs) { + return { activity, hasProof, proofCreated: false }; + } + const sign = async ( + selected: readonly ProofSigningCandidate[], + ): Promise => { + for (const { verificationMethod, privateKey } of selected) { + activity = await signObject(activity, privateKey, verificationMethod, { + contextLoader, + tracerProvider, + }); + } + return { activity, hasProof: true, proofCreated: true }; + }; + // A single key produces a single proof whatever the document contains, so + // the activity need not be serialized to find out. + if (keys.length < 2 && !hasProof) return await sign(keys); + const compound = containsCompoundPortableObject( + await activity.toJsonLd({ format: "compact", contextLoader }), + ); + if (!compound) return await sign(keys); + const logger = getLogger(["fedify", "federation", "outbox"]); + const activityId = activity.id?.href; + if (hasProof) { + logger.debug( + "The activity {activityId} embeds portable objects and already carries " + + "a proof, so no further proof is added; the map-local compound-proof " + + "profile accepts exactly one direct proof per map.", + { activityId }, + ); + return { activity, hasProof, proofCreated: false }; + } + if (activity.id == null || !isPortableUrl(activity.id)) { + throw new TypeError( + `Cannot choose an Object Integrity Proof key for the activity ` + + `${activityId}: it embeds portable objects, so it falls under the ` + + `map-local compound-proof profile, which accepts exactly one direct ` + + `proof per map, but ${keys.length} Ed25519 keys were supplied ` + + `(${formatKeyIds(keys)}). Send it with explicit sender keys that ` + + `contain exactly one Ed25519 key (RSA keys for HTTP Signatures may ` + + `stay), or sign it with signObject() before sending it.`, + ); + } + const activityDid = getFe34Origin(activity.id); + const eligible = keys.filter((c) => + c.verificationMethod.protocol === "did:" && + haveSameFe34Origin(activity.id!, c.verificationMethod) + ); + if (eligible.length !== 1) { + throw new TypeError( + `Cannot choose an Object Integrity Proof key for the portable activity ` + + `${activityId}: ` + + (eligible.length < 1 + ? `none of its ${keys.length} Ed25519 keys (${formatKeyIds(keys)}) ` + : `${eligible.length} of its Ed25519 keys ` + + `(${formatKeyIds(eligible)}) `) + + `${eligible.length < 1 ? "is" : "are"} a DID URL verification ` + + `method for ${activityDid}. The map-local compound-proof profile ` + + `accepts exactly one direct proof per map, and FEP-ef61 requires ` + + `that proof's verification method to be a DID URL for the ` + + `activity's DID. Supply exactly one Ed25519 key whose key ID is a ` + + `DID URL for ${activityDid}, or sign the activity with signObject() ` + + `before sending it.`, + ); + } + logger.debug( + "The portable activity {activityId} is signed only by {keyId}, the one " + + "Ed25519 key whose verification method matches its DID; the map-local " + + "compound-proof profile accepts exactly one direct proof per map.", + { activityId, keyId: eligible[0].verificationMethod.href }, + ); + return await sign(eligible); +} + +/** + * Rejects an outgoing activity whose proofs Fedify's own inbox would reject + * as unsupported: a document with portable objects in which some map carries + * a proof set or another value that is not a single proof map. + * + * @param jsonLd The compact JSON-LD document about to be delivered. + * @param activityId The activity ID, for the error message. + * @throws {TypeError} If the document has an unsupported proof shape. + * @internal + */ +export function assertSupportedCompoundProofShape( + jsonLd: unknown, + activityId: string | undefined, +): void { + if (!containsCompoundPortableObject(jsonLd)) return; + const path = findUnsupportedCompoundProofShape(jsonLd); + if (path == null) return; + throw new TypeError( + `Cannot send the activity ${activityId}: it embeds portable objects, ` + + `but its proof at the JSON Pointer ${JSON.stringify(path)} is not a ` + + `single proof map. The map-local compound-proof profile accepts ` + + `exactly one direct proof per map, so Fedify inboxes would reject the ` + + `activity. Sign each object with exactly one key.`, + ); +} diff --git a/packages/fedify/src/sig/compound-proof-producer.test.ts b/packages/fedify/src/sig/compound-proof-producer.test.ts new file mode 100644 index 000000000..0e423a20c --- /dev/null +++ b/packages/fedify/src/sig/compound-proof-producer.test.ts @@ -0,0 +1,99 @@ +import { test } from "@fedify/fixture"; +import { assertEquals } from "@std/assert"; +import { + containsCompoundPortableObject, + findUnsupportedCompoundProofShape, + inspectCompoundPortableObjectApplicability, +} from "./compound-proof.ts"; + +const portableId = "ap+ef61://did:key:z6MkAlice/objects/1"; +const proof = { type: "DataIntegrityProof", proofValue: "z" }; + +function nest(depth: number, leaf: Record): unknown { + let value: unknown = leaf; + for (let i = 0; i < depth; i++) value = { type: "Note", content: value }; + return value; +} + +test("containsCompoundPortableObject() finds portable maps", () => { + const cases: [unknown, boolean][] = [ + [{ id: portableId }, true], + [{ "@id": "AP://did:key:z6MkAlice/actor" }, true], + [{ id: "https://example.com/1", object: [{}, { id: portableId }] }, true], + [{ id: "https://example.com/1", actor: portableId }, false], + [{ id: "https://example.com/1", proof: { id: portableId } }, false], + [{ "@context": [{ id: portableId }], id: "https://example.com/1" }, false], + [{ id: "https://example.com/1", object: { id: "ap-like" } }, false], + [[{ id: portableId }], true], + [null, false], + ]; + for (const [json, expected] of cases) { + assertEquals(containsCompoundPortableObject(json), expected, `${json}`); + } +}); + +test("containsCompoundPortableObject() ignores the inbox limits", () => { + const deep = nest(100, { id: portableId }); + const limits = { + maxDepth: 64, + maxMaps: 10_000, + maxProofs: 32, + maxBytes: 10 * 1024 * 1024, + }; + assertEquals( + inspectCompoundPortableObjectApplicability(deep, limits), + "indeterminate", + ); + assertEquals(containsCompoundPortableObject(deep), true); + // An oversized document without portable maps stays outside the profile. + const large = { + id: "https://example.com/1", + content: "x".repeat(11 * 1024 * 1024), + proof: [proof, proof], + }; + assertEquals( + inspectCompoundPortableObjectApplicability(large, limits), + "indeterminate", + ); + assertEquals(containsCompoundPortableObject(large), false); +}); + +test("containsCompoundPortableObject() terminates on repeated maps", () => { + const shared: Record = { type: "Note" }; + const cyclic: Record = { a: shared, b: shared }; + shared.parent = cyclic; + assertEquals(containsCompoundPortableObject(cyclic), false); + shared.id = portableId; + assertEquals(containsCompoundPortableObject(cyclic), true); +}); + +test("findUnsupportedCompoundProofShape() reports non-map proofs", () => { + const cases: [unknown, string | null][] = [ + [{ proof }, null], + [{ proof: [proof, proof] }, "/proof"], + [{ proof: [proof] }, "/proof"], + [{ proof: [] }, "/proof"], + [{ proof: null }, "/proof"], + [{ proof: "z" }, "/proof"], + [{ proof: 1 }, "/proof"], + [{ proof: true }, "/proof"], + [{ proof, object: { proof: [proof, proof] } }, "/object/proof"], + [{ items: [{ proof }, { proof: [proof] }] }, "/items/1/proof"], + [{ "a/b~c": { proof: [proof] } }, "/a~1b~0c/proof"], + // Values inside a proof or a context are not secured maps. + [{ proof: { ...proof, nested: { proof: [proof] } } }, null], + [{ "@context": [{ proof: [proof] }] }, null], + [{ object: { id: portableId } }, null], + ]; + for (const [json, expected] of cases) { + assertEquals( + findUnsupportedCompoundProofShape(json), + expected, + JSON.stringify(json), + ); + } + assertEquals( + findUnsupportedCompoundProofShape(nest(100, { proof: [proof, proof] })), + "/content".repeat(100) + "/proof", + ); +}); diff --git a/packages/fedify/src/sig/compound-proof.ts b/packages/fedify/src/sig/compound-proof.ts index 1341f1bf6..cc512a3d9 100644 --- a/packages/fedify/src/sig/compound-proof.ts +++ b/packages/fedify/src/sig/compound-proof.ts @@ -693,6 +693,101 @@ export function inspectCompoundPortableObjectApplicability( return "absent"; } +/** + * Walks every JSON map in a finite, already-materialized JSON tree, depth + * first in insertion order, skipping the values of `proof` and `@context` + * members. A map reached a second time through a repeated reference is not + * visited again, so a cyclic input terminates. The walk stops as soon as + * the visitor returns a value other than `undefined`. + */ +function findInJsonMaps( + json: unknown, + visit: (map: Record, path: string) => T | undefined, +): T | undefined { + const seen = new Set(); + const pending: Array<{ value: unknown; path: string }> = [ + { value: json, path: "" }, + ]; + while (pending.length > 0) { + const { value, path } = pending.pop()!; + if (typeof value !== "object" || value == null || seen.has(value)) { + continue; + } + seen.add(value); + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index--) { + pending.push({ + value: value[index], + path: childPath(path, String(index)), + }); + } + continue; + } + const map = value as Record; + const result = visit(map, path); + if (result !== undefined) return result; + const keys = Object.keys(map); + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index]; + if (key === "proof" || key === "@context") continue; + pending.push({ value: map[key], path: childPath(path, key) }); + } + } + return undefined; +} + +/** + * Determines whether an outgoing JSON tree contains portable content that + * requires producer-side proof-shape checks: a map, outside proof and context + * values, whose direct `id` or `@id` is a portable ActivityPub URI. + * + * This deliberately differs from + * {@link inspectCompoundPortableObjectApplicability}: it applies no resource + * limits, so a portable map or an unsupported proof beyond the inbox limits + * is still found, and a large document without portable maps stays outside + * the compound-proof profile. The input must be a finite JSON tree that has + * already been materialized, such as a compact JSON-LD document about to be + * sent. + * + * @internal + */ +export function containsCompoundPortableObject(json: unknown): boolean { + return findInJsonMaps( + json, + (map) => + [map.id, map["@id"]].some((id) => + typeof id === "string" && PORTABLE_OBJECT_ID_PATTERN.test(id) + ) + ? true + : undefined, + ) ?? false; +} + +/** + * Finds the first direct `proof` member whose value the map-local + * compound-proof profile does not accept, i.e., anything other than a single + * JSON map: a proof set, `null`, or a scalar. Like the inbox discovery, it + * does not look inside proof or context values. + * + * The input must be a finite, already-materialized JSON tree; see + * {@link containsCompoundPortableObject}. + * + * @returns The RFC 6901 JSON Pointer to the unsupported `proof` member, or + * `null` if every direct proof is a single JSON map. + * @internal + */ +export function findUnsupportedCompoundProofShape( + json: unknown, +): string | null { + return findInJsonMaps( + json, + (map, path) => + Object.hasOwn(map, "proof") && !isJsonMap(map.proof) + ? childPath(path, "proof") + : undefined, + ) ?? null; +} + /** * Discovers direct literal proof-bearing maps in an immutable JSON snapshot. *