From a94557207ec144eda06fea8983e6e374c65bdfc3 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 02:20:42 +0900 Subject: [PATCH 01/12] Verify portable object proofs Add a policy-aware verifier for FEP-ef61 portable objects. It applies FEP-2277 core-type classification, requires every proof to use a DID from the portable object's cryptographic origin, and verifies the full proof set while reusing the canonicalized document digest. Document malformed-input handling, collection and nested-object trust boundaries, and telemetry behavior. Cover URI variants, proof policy failures, multiple proofs, JSON-LD aliases, and malformed inputs. Fixes https://github.com/fedify-dev/fedify/issues/832 https://github.com/fedify-dev/fedify/issues/288 https://github.com/fedify-dev/fedify/pull/968 Assisted-by: Codex:gpt-5.6-sol Assisted-by: Claude Code:claude-fable-5 --- CHANGES.md | 11 +- docs/manual/opentelemetry.md | 8 +- docs/manual/send.md | 39 ++ packages/fedify/src/sig/proof.test.ts | 600 ++++++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 440 +++++++++++++++++-- 5 files changed, 1056 insertions(+), 42 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4a3a5fc69..76b9bd584 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,13 @@ To be released. ### @fedify/fedify + - Added `verifyPortableObjectProof()` to enforce the [FEP-ef61] proof policy + for portable actors, activities, objects, and signed collections. Its + detailed result distinguishes documents outside the policy, unsecured + collections, missing or invalid [FEP-8b32] proofs, unsupported verification + methods, DID authority mismatches, and successful verification. [[#832], + [#968]] + - Updated `verifyObject()` so [FEP-8b32] proofs signed by `did:key` verification methods can authenticate portable objects whose owner is an `ap:` or `ap+ef61:` URI with the same [FEP-fe34] cryptographic origin. @@ -92,9 +99,9 @@ To be released. bundles `temporal-polyfill`, while type declarations rely on the standard `esnext.temporal` lib reference. [[#823], [#925]] +[FEP-ef61]: https://w3id.org/fep/ef61 [FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 -[FEP-ef61]: https://w3id.org/fep/ef61 [ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload [Standard Schema]: https://standardschema.dev/ [#206]: https://github.com/fedify-dev/fedify/issues/206 @@ -108,6 +115,7 @@ To be released. [#823]: https://github.com/fedify-dev/fedify/issues/823 [#827]: https://github.com/fedify-dev/fedify/issues/827 [#829]: https://github.com/fedify-dev/fedify/issues/829 +[#832]: https://github.com/fedify-dev/fedify/issues/832 [#915]: https://github.com/fedify-dev/fedify/pull/915 [#923]: https://github.com/fedify-dev/fedify/pull/923 [#925]: https://github.com/fedify-dev/fedify/pull/925 @@ -115,6 +123,7 @@ To be released. [#927]: https://github.com/fedify-dev/fedify/pull/927 [#930]: https://github.com/fedify-dev/fedify/issues/930 [#934]: https://github.com/fedify-dev/fedify/pull/934 +[#968]: https://github.com/fedify-dev/fedify/pull/968 ### @fedify/astro diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index a21356ffe..8c1581538 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -524,9 +524,11 @@ Fedify records the following OpenTelemetry metrics: `verifyRequest()` / `verifyRequestDetailed()`, `verifyJsonLd()`, and `verifyProof()` each emit exactly one measurement, even when the implementation retries internally after a cache mismatch. Wrappers - such as `verifyObject()` emit one measurement per inner `verifyProof()` - call (and none when the object has no proofs); higher-level inbox - handling can perform several verification attempts in series. + such as `verifyObject()` and `verifyPortableObjectProof()` emit one + measurement per inner `verifyProof()` call. They emit none when there + are no proofs or portable proof policy rejects the document before + cryptographic verification; higher-level inbox handling can perform + several verification attempts in series. Kind-specific optional attributes are recorded only when the value matches a small, spec-bounded set, to keep cardinality safe even when diff --git a/docs/manual/send.md b/docs/manual/send.md index 3c4d195a2..46f9fbb08 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1094,6 +1094,45 @@ When `verifyObject()` checks whether a proof authenticates an object's actor or attribution, it treats matching portable `ap:`/`ap+ef61:` IDs and `did:key` controllers as the same [FEP-fe34] cryptographic origin. +For received portable objects, use `verifyPortableObjectProof()`. It keeps +`verifyProof()` focused on cryptographic verification while also enforcing the +[FEP-ef61] policy: a portable actor, activity, or object must have proofs, every +proof must use a DID URL, and that DID must match the authority of the portable +object ID. The detailed result distinguishes documents outside the policy, +missing or invalid proofs, unsupported verification methods, DID mismatches, +and successful verification: + +~~~~ typescript +import { verifyPortableObjectProof } from "@fedify/fedify"; + +async function verifyPortableResponse(response: Response): Promise { + const jsonLd: unknown = await response.json(); + let result; + try { + result = await verifyPortableObjectProof(jsonLd); + } catch (error) { + if (error instanceof TypeError) { + throw new Error("Malformed portable object.", { cause: error }); + } + throw error; + } + if (result.verified) { + console.log("Verified with", result.keys); + } else if (result.reason.type === "unsecuredCollection") { + // Apply the gateway trust policy for an unsigned portable collection. + } else if (result.reason.type !== "notPortableObject") { + throw new Error(`Portable proof rejected: ${result.reason.type}`); + } +} +~~~~ + +A portable collection that has proofs is verified in the same way. A portable +collection without a proof produces the `unsecuredCollection` result so the +caller can apply the separate gateway trust policy allowed by [FEP-ef61]. +`verifyPortableObjectProof()` examines only the top-level JSON-LD node. An +embedded portable object needs its own verification; success for an outer +activity does not authenticate portable objects nested inside it. + > [!TIP] > HTTPS Signatures, Linked Data Signatures, and Object Integrity Proofs can > coexist in an application and be used together for maximum compatibility. diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 8afbcbe99..860247f90 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -16,6 +16,7 @@ import { } from "@fedify/vocab"; import { decodeMultibase, + encodeMultibase, exportDidKey, exportMultibaseKey, importMultibaseKey, @@ -30,6 +31,7 @@ import { assertRejects, } from "@std/assert"; import { decodeHex } from "byte-encodings/hex"; +import serialize from "json-canon"; import { ed25519Multikey, ed25519PrivateKey, @@ -44,6 +46,7 @@ import { signObject, verifyObject, type VerifyObjectOptions, + verifyPortableObjectProof, verifyProof, type VerifyProofOptions, } from "./proof.ts"; @@ -68,6 +71,59 @@ const fep8b32TestVectorPrivateKey = await crypto.subtle.importKey( const fep8b32TestVectorKeyId = new URL( "https://server.example/users/alice#ed25519-key", ); + +const portableDid = await exportDidKey(ed25519PublicKey.publicKey); +const portableDidMethod = portableDid.substring("did:key:".length); +const portableKeyId = new URL(`${portableDid}#${portableDidMethod}`); +const portableContext = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", +]; +const portableProofCreated = "2023-02-24T23:36:38Z"; + +async function signPortableJsonLd( + document: Record, + { + privateKey = ed25519PrivateKey, + verificationMethod = portableKeyId, + }: { + privateKey?: CryptoKey; + verificationMethod?: URL; + } = {}, +): Promise> { + const proofConfig = { + "@context": document["@context"], + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: verificationMethod.href, + proofPurpose: "assertionMethod", + created: portableProofCreated, + }; + const encoder = new TextEncoder(); + const proofDigest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(serialize(proofConfig)), + ); + const messageDigest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(serialize(document)), + ); + const digest = new Uint8Array( + proofDigest.byteLength + messageDigest.byteLength, + ); + digest.set(new Uint8Array(proofDigest), 0); + digest.set(new Uint8Array(messageDigest), proofDigest.byteLength); + const signature = await crypto.subtle.sign("Ed25519", privateKey, digest); + return { + ...document, + proof: { + ...proofConfig, + proofValue: new TextDecoder().decode( + encodeMultibase("base58btc", new Uint8Array(signature)), + ), + }, + }; +} const fep8b32TestVectorActivity = new Create({ id: new URL("https://server.example/activities/1"), actor: new URL("https://server.example/users/alice"), @@ -964,6 +1020,550 @@ test("verifyProof() records verification duration metric", async (t) => { ); }); +test("verifyPortableObjectProof()", async (t) => { + const options: VerifyProofOptions = { + documentLoader() { + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + }; + const objectId = `ap://did:key:${portableDidMethod}/objects/1`; + const unsignedObject = { + "@context": portableContext, + id: objectId, + type: "Note", + attributedTo: `ap://did:key:${portableDidMethod}/actor`, + content: "Portable note", + }; + + await t.step( + "verifies a portable object with a matching DID proof", + async () => { + const result = await verifyPortableObjectProof( + await signPortableJsonLd(unsignedObject), + options, + ); + assert(result.verified); + assertEquals(result.keys.length, 1); + assertEquals(result.keys[0].id, portableKeyId); + }, + ); + + await t.step("verifies portable actors and activities by shape", async () => { + for ( + const document of [ + { + "@context": portableContext, + id: `ap+ef61://did:key:${portableDidMethod}/actor`, + type: "UnknownActorType", + inbox: "https://gateway.example/users/alice/inbox", + outbox: "https://gateway.example/users/alice/outbox", + }, + { + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/activities/1`, + type: "UnknownActivityType", + actor: `ap://did:key:${portableDidMethod}/actor`, + object: objectId, + }, + ] + ) { + const result = await verifyPortableObjectProof( + await signPortableJsonLd(document), + options, + ); + assert(result.verified); + assertEquals(result.keys.length, 1); + } + }); + + await t.step("uses the normative FEP-2277 precedence order", async () => { + for ( + const document of [ + { + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/actor`, + type: "Collection", + inbox: "https://gateway.example/users/alice/inbox", + outbox: "https://gateway.example/users/alice/outbox", + totalItems: 0, + }, + { + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/activities/1`, + type: "Collection", + actor: `ap://did:key:${portableDidMethod}/actor`, + totalItems: 0, + }, + ] + ) { + assertEquals( + await verifyPortableObjectProof(document, options), + { verified: false, reason: { type: "missingProof" } }, + ); + } + }); + + await t.step( + "accepts portable URI spelling and location-hint variants", + async () => { + const variants = [ + `ap+ef61://did%3Akey%3A${portableDidMethod}/objects/1`, + `AP://did:key:${portableDidMethod}/objects/1`, + `ap://did:key:${portableDidMethod}/objects/1?%40gateway=${ + encodeURIComponent("https://gateway.example") + }`, + `ap://did:key:${portableDidMethod}/objects/1#content`, + ]; + for (const id of variants) { + const result = await verifyPortableObjectProof( + await signPortableJsonLd({ ...unsignedObject, id }), + options, + ); + assert(result.verified, id); + } + }, + ); + + await t.step( + "supports matching DID methods resolved by a caller", + async () => { + const verificationMethod = new URL("did:web:example.com#key"); + const document = { + ...unsignedObject, + id: "ap://did:web:example.com/objects/1", + }; + let fetches = 0; + const result = await verifyPortableObjectProof( + await signPortableJsonLd(document, { verificationMethod }), + { + contextLoader: mockDocumentLoader, + documentLoader: async (url) => { + fetches++; + assertEquals(url, verificationMethod.href); + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": "https://w3id.org/security/multikey/v1", + id: verificationMethod.href, + type: "Multikey", + controller: "did:web:example.com", + publicKeyMultibase: await exportMultibaseKey( + ed25519PublicKey.publicKey, + ), + }, + }; + }, + }, + ); + assert(result.verified); + assertEquals(fetches, 1); + }, + ); + + await t.step("reports a missing proof on portable objects", async () => { + assertEquals( + await verifyPortableObjectProof(unsignedObject, options), + { verified: false, reason: { type: "missingProof" } }, + ); + }); + + await t.step("keeps non-portable documents outside the policy", async () => { + for ( + const document of [ + { ...unsignedObject, id: "https://social.example/objects/1" }, + { + ...unsignedObject, + id: + "https://gateway.example/.well-known/apgateway/did:key:z6MkAlice/objects/1", + }, + { + "@context": portableContext, + type: "Note", + content: "Object without an ID", + }, + ] + ) { + assertEquals( + await verifyPortableObjectProof(document, options), + { verified: false, reason: { type: "notPortableObject" } }, + ); + } + }); + + await t.step("distinguishes unsecured and signed collections", async () => { + const collection = { + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/collections/1`, + type: "Note", + totalItems: 0, + }; + assertEquals( + await verifyPortableObjectProof(collection, options), + { verified: false, reason: { type: "unsecuredCollection" } }, + ); + const result = await verifyPortableObjectProof( + await signPortableJsonLd(collection), + options, + ); + assert(result.verified); + }); + + await t.step( + "classifies unsupported core types without using type", + async () => { + const cases = [ + { + document: { ...unsignedObject, href: "https://example.com/" }, + objectType: "link", + }, + { + document: { + ...unsignedObject, + "https://w3id.org/security#publicKeyMultibase": + "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2", + }, + objectType: "verificationMethod", + }, + { + document: { + ...unsignedObject, + "https://w3id.org/security#publicKeyPem": "not-a-real-key", + }, + objectType: "publicKey", + }, + ] as const; + for (const { document, objectType } of cases) { + assertEquals( + await verifyPortableObjectProof(document, options), + { + verified: false, + reason: { type: "unsupportedObjectType", objectType }, + }, + ); + } + }, + ); + + await t.step( + "rejects non-DID verification methods before fetching", + async () => { + let fetched = false; + const verificationMethod = new URL( + "https://attacker.example/keys/ed25519", + ); + const result = await verifyPortableObjectProof( + await signPortableJsonLd(unsignedObject, { verificationMethod }), + { + contextLoader: mockDocumentLoader, + documentLoader() { + fetched = true; + throw new TypeError("unexpected fetch"); + }, + }, + ); + assertFalse(result.verified); + assertEquals(result.reason, { + type: "unsupportedVerificationMethod", + proofIndex: 0, + verificationMethod, + }); + assertFalse(fetched); + }, + ); + + await t.step("rejects malformed DID verification methods", async () => { + const verificationMethod = new URL("did:invalid"); + const result = await verifyPortableObjectProof( + await signPortableJsonLd(unsignedObject, { verificationMethod }), + options, + ); + assertFalse(result.verified); + assertEquals(result.reason, { + type: "unsupportedVerificationMethod", + proofIndex: 0, + verificationMethod, + }); + }); + + await t.step("rejects a proof from another DID before fetching", async () => { + let fetched = false; + const verificationMethod = new URL( + "did:key:z6MkMallory#z6MkMallory", + ); + const result = await verifyPortableObjectProof( + await signPortableJsonLd(unsignedObject, { verificationMethod }), + { + contextLoader: mockDocumentLoader, + documentLoader() { + fetched = true; + throw new TypeError("unexpected fetch"); + }, + }, + ); + assertFalse(result.verified); + assertEquals(result.reason, { + type: "verificationMethodMismatch", + proofIndex: 0, + objectId: parseIri(objectId), + verificationMethod, + }); + assertFalse(fetched); + }); + + await t.step("reports cryptographic failures separately", async () => { + const signed = await signPortableJsonLd(unsignedObject); + const tampered = { ...signed, content: "Tampered" }; + assertEquals( + await verifyPortableObjectProof(tampered, options), + { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }, + ); + }); + + await t.step("reports malformed proof structures as invalid", async () => { + for ( + const proof of [ + { type: "DataIntegrityProof" }, + { + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: portableKeyId.href, + proofPurpose: "assertionMethod", + proofValue: "zInvalid", + created: "not-an-instant", + }, + ] + ) { + assertEquals( + await verifyPortableObjectProof({ + ...unsignedObject, + proof, + }, options), + { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }, + ); + } + }); + + await t.step("accepts multiple valid proofs in input order", async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const result = await verifyPortableObjectProof({ + ...signed, + proof: [proof, structuredClone(proof)], + }, options); + assert(result.verified); + assertEquals(result.keys.length, 2); + assertEquals(result.keys.map((key) => key.id), [ + portableKeyId, + portableKeyId, + ]); + }); + + await t.step( + "canonicalizes the document only once for multiple proofs", + async () => { + async function countContentReads(proofCount: number): Promise { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const document = { + ...signed, + proof: Array.from( + { length: proofCount }, + () => structuredClone(proof), + ), + }; + let contentReads = 0; + Object.defineProperty(document, "content", { + configurable: true, + enumerable: true, + get() { + contentReads++; + return unsignedObject.content; + }, + }); + const result = await verifyPortableObjectProof(document, options); + assert(result.verified); + assertEquals(result.keys.length, proofCount); + return contentReads; + } + + assertEquals(await countContentReads(3), await countContentReads(1)); + }, + ); + + await t.step("requires every proof to verify", async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const result = await verifyPortableObjectProof({ + ...signed, + proof: [ + proof, + { ...proof, proofValue: "zInvalid" }, + ], + }, options); + assertEquals(result, { + verified: false, + reason: { type: "invalidProof", proofIndex: 1 }, + }); + }); + + await t.step("checks every proof policy before resolving keys", async () => { + const didWebId = "ap://did:web:example.com/objects/1"; + const didWebMethod = new URL("did:web:example.com#key"); + const didWebSigned = await signPortableJsonLd( + { ...unsignedObject, id: didWebId }, + { verificationMethod: didWebMethod }, + ); + const httpMethod = new URL("https://attacker.example/key"); + const httpSigned = await signPortableJsonLd( + { ...unsignedObject, id: didWebId }, + { verificationMethod: httpMethod }, + ); + let fetched = false; + const result = await verifyPortableObjectProof({ + ...didWebSigned, + proof: [didWebSigned.proof, httpSigned.proof], + }, { + contextLoader: mockDocumentLoader, + documentLoader() { + fetched = true; + throw new TypeError("unexpected fetch"); + }, + }); + assertFalse(result.verified); + assertEquals(result.reason, { + type: "unsupportedVerificationMethod", + proofIndex: 1, + verificationMethod: httpMethod, + }); + assertFalse(fetched); + }); + + await t.step("accepts the expanded proof property", async () => { + const signed = await signPortableJsonLd(unsignedObject); + const { proof, ...document } = signed; + const parsedProof = await DataIntegrityProof.fromJsonLd(proof, { + contextLoader: mockDocumentLoader, + documentLoader: mockDocumentLoader, + }); + const expandedProof = await parsedProof.toJsonLd({ + format: "expand", + contextLoader: mockDocumentLoader, + }); + const result = await verifyPortableObjectProof({ + ...document, + "https://w3id.org/security#proof": expandedProof, + }, options); + assert(result.verified); + }); + + await t.step( + "classifies JSON-LD aliases by their expanded properties", + async () => { + const aliasedActivity = { + "@context": [ + ...portableContext, + { performedBy: "https://www.w3.org/ns/activitystreams#actor" }, + ], + id: `ap://did:key:${portableDidMethod}/activities/aliased`, + type: "UnknownActivity", + performedBy: `ap://did:key:${portableDidMethod}/actor`, + }; + const result = await verifyPortableObjectProof( + await signPortableJsonLd(aliasedActivity), + options, + ); + assert(result.verified); + }, + ); + + await t.step( + "does not claim to verify embedded portable objects", + async () => { + const outer = { + "@context": portableContext, + id: `ap://did:key:${portableDidMethod}/activities/outer`, + type: "Create", + actor: `ap://did:key:${portableDidMethod}/actor`, + object: { + id: `ap://did:key:${portableDidMethod}/objects/embedded`, + type: "Note", + content: "Unsigned embedded object", + }, + }; + const result = await verifyPortableObjectProof( + await signPortableJsonLd(outer), + options, + ); + assert(result.verified); + }, + ); + + await t.step("rejects malformed roots and portable IDs", async () => { + await assertRejects( + () => verifyPortableObjectProof([], options), + TypeError, + "single JSON-LD object", + ); + await assertRejects( + () => + verifyPortableObjectProof({ + ...unsignedObject, + id: `ap://did:key:${portableDidMethod}`, + }, options), + TypeError, + ); + await assertRejects( + () => + verifyPortableObjectProof({ + ...unsignedObject, + id: "ap://did%ZZkey/objects/1", + }, options), + TypeError, + ); + }); + + await t.step( + "emits metrics only for attempted proof verification", + async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const verified = await verifyPortableObjectProof({ + ...signed, + proof: [proof, structuredClone(proof)], + }, { ...options, meterProvider }); + assert(verified.verified); + assertEquals( + recorder.getMeasurements( + "activitypub.signature.verification.duration", + ).length, + 2, + ); + + const [rejectedMeterProvider, rejectedRecorder] = + createTestMeterProvider(); + await verifyPortableObjectProof(unsignedObject, { + ...options, + meterProvider: rejectedMeterProvider, + }); + assertEquals( + rejectedRecorder.getMeasurements( + "activitypub.signature.verification.duration", + ).length, + 0, + ); + }, + ); +}); + test("verifyObject()", async () => { const options: VerifyObjectOptions = { documentLoader: mockDocumentLoader, diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 12175dd68..f1afe4551 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -5,7 +5,13 @@ import { Multikey, type Object, } from "@fedify/vocab"; -import { type DocumentLoader, haveSameFe34Origin } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + getFe34Origin, + haveSameFe34Origin, + parseIri, +} from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { getLogger } from "@logtape/logtape"; import { type MeterProvider, @@ -31,6 +37,7 @@ import { type KeyCache, validateCryptoKey, } from "./key.ts"; +import { getNormalizationContextLoader } from "./ld.ts"; /** * Known Object Integrity Proof `cryptosuite` values, used to keep @@ -309,6 +316,83 @@ export interface VerifyProofOptions { meterProvider?: MeterProvider; } +/** + * Options for {@link verifyPortableObjectProof}. + * @since 2.4.0 + */ +export interface VerifyPortableObjectProofOptions extends VerifyProofOptions { +} + +/** + * The reason why {@link verifyPortableObjectProof} could not verify a portable + * object proof. + * @since 2.4.0 + */ +export type VerifyPortableObjectProofFailureReason = + | { + /** The document does not have a portable `ap:` or `ap+ef61:` ID. */ + readonly type: "notPortableObject"; + } + | { + /** + * The document is a portable collection without an Object Integrity + * Proof. Its trust policy is outside this verifier. + */ + readonly type: "unsecuredCollection"; + } + | { + /** The portable document is a core type outside FEP-ef61 proof policy. */ + readonly type: "unsupportedObjectType"; + /** The FEP-2277 core type of the document. */ + readonly objectType: "verificationMethod" | "publicKey" | "link"; + } + | { + /** A portable actor, activity, or object has no proof. */ + readonly type: "missingProof"; + } + | { + /** The proof is malformed, unsupported, or cryptographically invalid. */ + readonly type: "invalidProof"; + /** The zero-based index of the invalid proof. */ + readonly proofIndex: number; + } + | { + /** The proof's verification method is not a valid DID URL. */ + readonly type: "unsupportedVerificationMethod"; + /** The zero-based index of the proof. */ + readonly proofIndex: number; + /** The unsupported verification method. */ + readonly verificationMethod: URL; + } + | { + /** The verification method DID does not match the portable ID authority. */ + readonly type: "verificationMethodMismatch"; + /** The zero-based index of the proof. */ + readonly proofIndex: number; + /** The portable object ID. */ + readonly objectId: URL; + /** The mismatching verification method. */ + readonly verificationMethod: URL; + }; + +/** + * The detailed result of {@link verifyPortableObjectProof}. + * @since 2.4.0 + */ +export type VerifyPortableObjectProofResult = + | { + /** Whether every Object Integrity Proof was verified. */ + readonly verified: true; + /** The public keys used by the verified proofs, in proof order. */ + readonly keys: readonly Multikey[]; + } + | { + /** Whether every Object Integrity Proof was verified. */ + readonly verified: false; + /** Why portable proof verification did not succeed. */ + readonly reason: VerifyPortableObjectProofFailureReason; + }; + /** * Verifies the given proof for the object. * @param jsonLd The JSON-LD object to verify the proof for. If it contains @@ -323,6 +407,15 @@ export async function verifyProof( jsonLd: unknown, proof: DataIntegrityProof, options: VerifyProofOptions = {}, +): Promise { + return await verifyProofWithMessageDigestCache(jsonLd, proof, options); +} + +async function verifyProofWithMessageDigestCache( + jsonLd: unknown, + proof: DataIntegrityProof, + options: VerifyProofOptions, + messageDigestCache: ProofMessageDigestCache = {}, ): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); @@ -358,7 +451,12 @@ export async function verifyProof( } } try { - const key = await verifyProofInternal(jsonLd, proof, options); + const key = await verifyProofInternal( + jsonLd, + proof, + options, + messageDigestCache, + ); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); else verified = true; return key; @@ -388,10 +486,57 @@ export async function verifyProof( ); } +interface ProofMessageDigests { + readonly onWire: ArrayBuffer; + readonly normalized: () => Promise; +} + +interface ProofMessageDigestCache { + value?: Promise; +} + +async function createProofMessageDigests( + jsonLd: unknown, +): Promise { + const msg = { ...(jsonLd as Record) }; + // `verifyProof()` promises to ignore existing proofs on the input; + // strip both the compact (`proof`) and the expanded + // (`https://w3id.org/security#proof`) forms so callers passing JSON-LD + // in either shape do not have the proof bytes folded into the JCS + // message digest. + if ("proof" in msg) delete msg.proof; + if ("https://w3id.org/security#proof" in msg) { + delete msg["https://w3id.org/security#proof"]; + } + const encoder = new TextEncoder(); + const digest = async (value: unknown): Promise => { + const bytes = encoder.encode(serialize(value)); + return await crypto.subtle.digest("SHA-256", bytes); + }; + const onWire = await digest(msg); + let normalizedPromise: Promise | undefined; + return { + onWire, + normalized() { + normalizedPromise ??= (async () => { + // This fallback runs on inbound, attacker-controlled JSON-LD, so the + // loader must not fetch custom `@context` URLs from the network. + const normalized = await normalizeOutgoingActivityJsonLd( + msg, + preloadedOnlyDocumentLoader, + ); + return normalized === msg ? null : await digest(normalized); + })(); + return normalizedPromise; + }, + }; +} + async function verifyProofInternal( jsonLd: unknown, proof: DataIntegrityProof, options: VerifyProofOptions, + messageDigestCache: ProofMessageDigestCache, ): Promise { if ( typeof jsonLd !== "object" || @@ -425,16 +570,6 @@ async function verifyProofInternal( const encoder = new TextEncoder(); const proofBytes = encoder.encode(serialize(proofConfig)); const proofDigest = await crypto.subtle.digest("SHA-256", proofBytes); - const msg = { ...(jsonLd as Record) }; - // `verifyProof()` promises to ignore existing proofs on the input; - // strip both the compact (`proof`) and the expanded - // (`https://w3id.org/security#proof`) forms so callers passing JSON-LD - // in either shape do not have the proof bytes folded into the JCS - // message digest. - if ("proof" in msg) delete msg.proof; - if ("https://w3id.org/security#proof" in msg) { - delete msg["https://w3id.org/security#proof"]; - } // Try the on-wire form first. Only if that fails do we fall back to // Fedify's outgoing JSON-LD compatibility form so that signatures created // by `createProof` (which signs the normalized bytes) still verify when the @@ -472,17 +607,22 @@ async function verifyProofInternal( // Recurse into `verifyProofInternal()` (not `verifyProof()`) so the // retry reuses the outer `object_integrity_proofs.verify` span and // `activitypub.signature.verification.duration` measurement. - return await verifyProofInternal(jsonLd, proof, { - ...options, - keyCache: { - // Returning `undefined` signals "nothing cached" and forces - // `fetchKey()` to refetch from the network; returning `null` - // would instead be interpreted as a cached-unavailable result - // and short-circuit the retry. - get: () => Promise.resolve(undefined), - set: async (keyId, key) => await options.keyCache?.set(keyId, key), + return await verifyProofInternal( + jsonLd, + proof, + { + ...options, + keyCache: { + // Returning `undefined` signals "nothing cached" and forces + // `fetchKey()` to refetch from the network; returning `null` + // would instead be interpreted as a cached-unavailable result + // and short-circuit the retry. + get: () => Promise.resolve(undefined), + set: async (keyId, key) => await options.keyCache?.set(keyId, key), + }, }, - }); + messageDigestCache, + ); } logger.debug( "The fetched key (verificationMethod) for the proof is not a valid " + @@ -498,9 +638,7 @@ async function verifyProofInternal( const digest = new Uint8Array(proofDigest.byteLength + SHA256_LENGTH); digest.set(new Uint8Array(proofDigest), 0); const proofValue = proof.proofValue; - const verifyCandidate = async (candidate: unknown): Promise => { - const msgBytes = encoder.encode(serialize(candidate)); - const msgDigest = await crypto.subtle.digest("SHA-256", msgBytes); + const verifyCandidate = async (msgDigest: ArrayBuffer): Promise => { digest.set(new Uint8Array(msgDigest), proofDigest.byteLength); return await crypto.subtle.verify( "Ed25519", @@ -512,14 +650,12 @@ async function verifyProofInternal( digest, ); }; - if (await verifyCandidate(msg)) return publicKey; - // This fallback runs on inbound, attacker-controlled JSON-LD, so the loader - // must not fetch custom `@context` URLs from the network. - const normalized = await normalizeOutgoingActivityJsonLd( - msg, - preloadedOnlyDocumentLoader, + const messageDigests = await ( + messageDigestCache.value ??= createProofMessageDigests(jsonLd) ); - if (normalized !== msg && await verifyCandidate(normalized)) { + if (await verifyCandidate(messageDigests.onWire)) return publicKey; + const normalizedDigest = await messageDigests.normalized(); + if (normalizedDigest != null && await verifyCandidate(normalizedDigest)) { return publicKey; } if (fetchedKey.cached) { @@ -531,13 +667,18 @@ async function verifyProofInternal( // Recurse into `verifyProofInternal()` (not `verifyProof()`) so the // retry reuses the outer `object_integrity_proofs.verify` span and // `activitypub.signature.verification.duration` measurement. - return await verifyProofInternal(jsonLd, proof, { - ...options, - keyCache: { - get: () => Promise.resolve(undefined), - set: async (keyId, key) => await options.keyCache?.set(keyId, key), + return await verifyProofInternal( + jsonLd, + proof, + { + ...options, + keyCache: { + get: () => Promise.resolve(undefined), + set: async (keyId, key) => await options.keyCache?.set(keyId, key), + }, }, - }); + messageDigestCache, + ); } logger.debug( "Failed to verify the proof with the fetched key {keyId}:\n{proof}", @@ -546,6 +687,229 @@ async function verifyProofInternal( return null; } +type Fep2277CoreType = + | "actor" + | "activity" + | "collection" + | "verificationMethod" + | "publicKey" + | "link" + | "object"; + +const AS_NAMESPACE = "https://www.w3.org/ns/activitystreams#"; +const SECURITY_NAMESPACE = "https://w3id.org/security#"; +const FEP_2277_ACTOR_PROPERTIES = [ + "http://www.w3.org/ns/ldp#inbox", + `${AS_NAMESPACE}outbox`, +] as const; +const FEP_2277_COLLECTION_PROPERTIES = [ + "items", + "orderedItems", + "totalItems", + "partOf", + "first", + "last", + "next", + "prev", + "current", +].map((property) => AS_NAMESPACE + property); +const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; + +function isJsonLdNode(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function classifyFep2277CoreType( + node: Record, +): Fep2277CoreType { + if (FEP_2277_ACTOR_PROPERTIES.every((property) => property in node)) { + return "actor"; + } + if (`${SECURITY_NAMESPACE}publicKeyMultibase` in node) { + return "verificationMethod"; + } + if (`${SECURITY_NAMESPACE}publicKeyPem` in node) return "publicKey"; + if (`${AS_NAMESPACE}href` in node) return "link"; + if (`${AS_NAMESPACE}actor` in node) return "activity"; + if (FEP_2277_COLLECTION_PROPERTIES.some((property) => property in node)) { + return "collection"; + } + return "object"; +} + +async function expandPortableObjectRoot( + jsonLd: unknown, + contextLoader: DocumentLoader | undefined, +): Promise> { + if (!isJsonLdNode(jsonLd)) { + throw new TypeError("Expected a single JSON-LD object."); + } + const expanded = await jsonld.expand(jsonLd, { + documentLoader: getNormalizationContextLoader(contextLoader), + keepFreeFloatingNodes: true, + }); + if (expanded.length !== 1 || !isJsonLdNode(expanded[0])) { + throw new TypeError("Expected a single JSON-LD object."); + } + return expanded[0]; +} + +/** + * Verifies the FEP-ef61 Object Integrity Proof policy for a portable object. + * + * This applies the FEP-2277 core-type classification to the top-level JSON-LD + * node. Portable actors, activities, and objects require proofs. A portable + * collection without a proof is reported separately so a caller can apply a + * gateway trust policy. Embedded portable objects are not traversed. + * + * Every proof must use a DID URL whose DID matches the portable object's + * authority, and every proof must pass {@link verifyProof}. + * + * @param jsonLd The JSON-LD document to verify. + * @param options Additional options. See also + * {@link VerifyPortableObjectProofOptions}. + * @returns The detailed portable proof-policy result. + * @throws {TypeError} If the input is not a single JSON-LD object or has a + * malformed portable ID. + * @since 2.4.0 + */ +export async function verifyPortableObjectProof( + jsonLd: unknown, + options: VerifyPortableObjectProofOptions = {}, +): Promise { + const root = await expandPortableObjectRoot(jsonLd, options.contextLoader); + const id = root["@id"]; + if ( + typeof id !== "string" || + !/^ap(?:\+ef61)?:\/\//i.test(id) + ) { + return { + verified: false, + reason: { type: "notPortableObject" }, + }; + } + const objectId = parseIri(id); + // parseIri() validates the portable ID; this additionally guarantees that + // its authority is a valid cryptographic origin before any key work begins. + getFe34Origin(objectId); + + const objectType = classifyFep2277CoreType(root); + if ( + objectType === "verificationMethod" || + objectType === "publicKey" || + objectType === "link" + ) { + return { + verified: false, + reason: { type: "unsupportedObjectType", objectType }, + }; + } + + const proofValues = root[SECURITY_PROOF]; + if ( + proofValues == null || Array.isArray(proofValues) && proofValues.length < 1 + ) { + return objectType === "collection" + ? { + verified: false, + reason: { type: "unsecuredCollection" }, + } + : { + verified: false, + reason: { type: "missingProof" }, + }; + } + if (!Array.isArray(proofValues)) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }; + } + + const proofs: DataIntegrityProof[] = []; + for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { + let proof: DataIntegrityProof; + try { + proof = await DataIntegrityProof.fromJsonLd( + proofValues[proofIndex], + options, + ); + } catch { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } + proofs.push(proof); + } + + // Complete policy validation for the whole proof set before resolving any + // keys. This prevents a later non-DID or cross-authority proof from causing + // unnecessary attacker-controlled document fetches. + for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { + const verificationMethod = proofs[proofIndex].verificationMethodId; + if (verificationMethod == null) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } + if (verificationMethod.protocol !== "did:") { + return { + verified: false, + reason: { + type: "unsupportedVerificationMethod", + proofIndex, + verificationMethod, + }, + }; + } + try { + getFe34Origin(verificationMethod); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return { + verified: false, + reason: { + type: "unsupportedVerificationMethod", + proofIndex, + verificationMethod, + }, + }; + } + if (!haveSameFe34Origin(objectId, verificationMethod)) { + return { + verified: false, + reason: { + type: "verificationMethodMismatch", + proofIndex, + objectId, + verificationMethod, + }, + }; + } + } + + const keys: Multikey[] = []; + const messageDigestCache: ProofMessageDigestCache = {}; + for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { + const key = await verifyProofWithMessageDigestCache( + jsonLd, + proofs[proofIndex], + options, + messageDigestCache, + ); + if (key == null) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } + keys.push(key); + } + return { verified: true, keys }; +} + /** * Options for {@link verifyObject}. * @since 0.10.0 From 6afef27594cc6b248a697d5f8585ea2ccc248894 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 02:50:01 +0900 Subject: [PATCH 02/12] Reject ambiguous proof verification methods Require every portable proof to expand to exactly one verification method before decoding or resolving keys. This prevents a valid first DID from hiding a second non-DID or cross-authority method from the FEP-ef61 policy check. https://github.com/fedify-dev/fedify/pull/968#discussion_r3650657649 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 31 +++++++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 30 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 860247f90..c55315c93 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1312,6 +1312,37 @@ test("verifyPortableObjectProof()", async (t) => { assertFalse(fetched); }); + await t.step( + "rejects proofs with multiple verification methods", + async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + for ( + const additionalVerificationMethod of [ + "did:key:z6MkMallory#z6MkMallory", + "https://attacker.example/key", + ] + ) { + assertEquals( + await verifyPortableObjectProof({ + ...signed, + proof: { + ...proof, + verificationMethod: [ + portableKeyId.href, + additionalVerificationMethod, + ], + }, + }, options), + { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }, + ); + } + }, + ); + await t.step("reports cryptographic failures separately", async () => { const signed = await signPortableJsonLd(unsignedObject); const tampered = { ...signed, content: "Tampered" }; diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index f1afe4551..c9c3dbc4b 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -714,11 +714,32 @@ const FEP_2277_COLLECTION_PROPERTIES = [ "current", ].map((property) => AS_NAMESPACE + property); const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; +const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; function isJsonLdNode(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } +function hasSingleVerificationMethod(proofValue: unknown): boolean { + if (!isJsonLdNode(proofValue)) return false; + let proofNode = proofValue; + if ("@graph" in proofNode) { + const graph = proofNode["@graph"]; + if ( + !Array.isArray(graph) || graph.length !== 1 || + !isJsonLdNode(graph[0]) + ) { + return false; + } + proofNode = graph[0]; + } + const verificationMethods = proofNode[SECURITY_VERIFICATION_METHOD]; + return Array.isArray(verificationMethods) && + verificationMethods.length === 1 && + !(isJsonLdNode(verificationMethods[0]) && + "@list" in verificationMethods[0]); +} + function classifyFep2277CoreType( node: Record, ): Fep2277CoreType { @@ -828,10 +849,17 @@ export async function verifyPortableObjectProof( const proofs: DataIntegrityProof[] = []; for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { + const proofValue = proofValues[proofIndex]; + if (!hasSingleVerificationMethod(proofValue)) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } let proof: DataIntegrityProof; try { proof = await DataIntegrityProof.fromJsonLd( - proofValues[proofIndex], + proofValue, options, ); } catch { From ab86306e49c5b5be33389def71295f2be3c0a850 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 03:04:04 +0900 Subject: [PATCH 03/12] Strip aliased proofs before hashing Resolve the active JSON-LD context before constructing the proofless JCS message, including type-scoped contexts and aliased @type terms. Use the preloaded-only loader so this lookup cannot fetch attacker-controlled remote contexts. https://github.com/fedify-dev/fedify/pull/968#discussion_r3650694709 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 50 +++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 63 ++++++++++++++++++++++++--- 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index c55315c93..3d32b1631 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1049,6 +1049,56 @@ test("verifyPortableObjectProof()", async (t) => { }, ); + await t.step("verifies aliased proof properties", async () => { + const proofAlias = "integrityProof"; + const proofIri = "https://w3id.org/security#proof"; + for ( + const { context, typeProperty, type } of [ + { + context: { [proofAlias]: proofIri }, + typeProperty: "type", + type: "Note", + }, + { + context: { + PortableNote: { + "@id": "https://www.w3.org/ns/activitystreams#Note", + "@context": { [proofAlias]: proofIri }, + }, + }, + typeProperty: "type", + type: "PortableNote", + }, + { + context: { + kind: "@type", + PortableNote: { + "@id": "https://www.w3.org/ns/activitystreams#Note", + "@context": { [proofAlias]: proofIri }, + }, + }, + typeProperty: "kind", + type: "PortableNote", + }, + ] + ) { + const document: Record = { + ...unsignedObject, + "@context": [...portableContext, context], + }; + delete document.type; + document[typeProperty] = type; + const { proof, ...signed } = await signPortableJsonLd(document); + const result = await verifyPortableObjectProof({ + ...signed, + [proofAlias]: proof, + }, options); + assert(result.verified); + assertEquals(result.keys.length, 1); + assertEquals(result.keys[0].id, portableKeyId); + } + }); + await t.step("verifies portable actors and activities by shape", async () => { for ( const document of [ diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index c9c3dbc4b..5db9b27d0 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -495,18 +495,69 @@ interface ProofMessageDigestCache { value?: Promise; } +async function getProofPropertyNames( + jsonLd: Record, +): Promise> { + const names = new Set(["proof", SECURITY_PROOF]); + if (jsonLd["@context"] == null) return names; + try { + const options = { documentLoader: preloadedOnlyDocumentLoader }; + let activeContext = await jsonld.processContext(null, null, options); + activeContext = await jsonld.processContext( + activeContext, + jsonLd["@context"], + options, + ); + const typeScopedContext = activeContext; + for (const key of globalThis.Object.keys(jsonLd).sort()) { + if ( + key !== "@type" && + jsonld.getContextValue(activeContext, key, "@id") !== "@type" + ) { + continue; + } + const value = jsonLd[key]; + const types = Array.isArray(value) ? value.slice().sort() : [value]; + for (const type of types) { + if (typeof type !== "string") continue; + const scopedContext = jsonld.getContextValue( + typeScopedContext, + type, + "@context", + ); + if (scopedContext != null) { + activeContext = await jsonld.processContext( + activeContext, + scopedContext, + options, + ); + } + } + } + for (const key of globalThis.Object.keys(jsonLd)) { + if ( + jsonld.getContextValue(activeContext, key, "@id") === SECURITY_PROOF + ) { + names.add(key); + } + } + } catch { + // Unavailable contexts must not prevent the literal proof properties + // from being removed without a network fetch. + } + return names; +} + async function createProofMessageDigests( jsonLd: unknown, ): Promise { const msg = { ...(jsonLd as Record) }; // `verifyProof()` promises to ignore existing proofs on the input; - // strip both the compact (`proof`) and the expanded - // (`https://w3id.org/security#proof`) forms so callers passing JSON-LD - // in either shape do not have the proof bytes folded into the JCS + // strip every top-level property that the active JSON-LD context maps to + // the security proof predicate so its bytes are not folded into the JCS // message digest. - if ("proof" in msg) delete msg.proof; - if ("https://w3id.org/security#proof" in msg) { - delete msg["https://w3id.org/security#proof"]; + for (const property of await getProofPropertyNames(msg)) { + delete msg[property]; } const encoder = new TextEncoder(); const digest = async (value: unknown): Promise => { From 1f21b470dbf30c07f3365cef7c462e75c1deab15 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 03:21:21 +0900 Subject: [PATCH 04/12] Expand compact proof IRIs before hashing Honor JSON-LD prefix mappings when identifying proof properties to remove from the proofless JCS message. This keeps compact IRIs aligned with direct aliases and expanded predicates. https://github.com/fedify-dev/fedify/pull/968#discussion_r3650722245 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 24 +++++++++++++++++++++-- packages/fedify/src/sig/proof.ts | 28 +++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 3d32b1631..4c0503777 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1053,9 +1053,10 @@ test("verifyPortableObjectProof()", async (t) => { const proofAlias = "integrityProof"; const proofIri = "https://w3id.org/security#proof"; for ( - const { context, typeProperty, type } of [ + const { context, proofProperty, typeProperty, type } of [ { context: { [proofAlias]: proofIri }, + proofProperty: proofAlias, typeProperty: "type", type: "Note", }, @@ -1066,6 +1067,7 @@ test("verifyPortableObjectProof()", async (t) => { "@context": { [proofAlias]: proofIri }, }, }, + proofProperty: proofAlias, typeProperty: "type", type: "PortableNote", }, @@ -1077,9 +1079,27 @@ test("verifyPortableObjectProof()", async (t) => { "@context": { [proofAlias]: proofIri }, }, }, + proofProperty: proofAlias, typeProperty: "kind", type: "PortableNote", }, + { + context: { sec: "https://w3id.org/security#" }, + proofProperty: "sec:proof", + typeProperty: "type", + type: "Note", + }, + { + context: { + sec: { + "@id": "https://w3id.org/security#", + "@prefix": true, + }, + }, + proofProperty: "sec:proof", + typeProperty: "type", + type: "Note", + }, ] ) { const document: Record = { @@ -1091,7 +1111,7 @@ test("verifyPortableObjectProof()", async (t) => { const { proof, ...signed } = await signPortableJsonLd(document); const result = await verifyPortableObjectProof({ ...signed, - [proofAlias]: proof, + [proofProperty]: proof, }, options); assert(result.verified); assertEquals(result.keys.length, 1); diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 5db9b27d0..7a433ee2d 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -495,6 +495,28 @@ interface ProofMessageDigestCache { value?: Promise; } +function expandContextPropertyIri( + activeContext: unknown, + key: string, +): unknown { + const termId = jsonld.getContextValue(activeContext, key, "@id"); + if (termId != null) return termId; + const colon = key.indexOf(":"); + if (colon < 1) return key; + const prefix = key.substring(0, colon); + const suffix = key.substring(colon + 1); + if (prefix === "_" || suffix.startsWith("//")) return key; + const mapping = jsonld.getContextValue(activeContext, prefix); + if ( + typeof mapping === "object" && mapping != null && + mapping._prefix === true && + typeof mapping["@id"] === "string" + ) { + return mapping["@id"] + suffix; + } + return key; +} + async function getProofPropertyNames( jsonLd: Record, ): Promise> { @@ -512,7 +534,7 @@ async function getProofPropertyNames( for (const key of globalThis.Object.keys(jsonLd).sort()) { if ( key !== "@type" && - jsonld.getContextValue(activeContext, key, "@id") !== "@type" + expandContextPropertyIri(activeContext, key) !== "@type" ) { continue; } @@ -535,9 +557,7 @@ async function getProofPropertyNames( } } for (const key of globalThis.Object.keys(jsonLd)) { - if ( - jsonld.getContextValue(activeContext, key, "@id") === SECURITY_PROOF - ) { + if (expandContextPropertyIri(activeContext, key) === SECURITY_PROOF) { names.add(key); } } From e78a16594b3b95ab147827bf4f5e49514b98bfef Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 12:41:18 +0900 Subject: [PATCH 05/12] Reuse resolved contexts for proof aliases Record remote contexts loaded during portable object expansion and replay only those responses when identifying proof property aliases. This keeps caller-defined aliases verifiable without letting digest construction fetch any new attacker-controlled context. https://github.com/fedify-dev/fedify/pull/968#discussion_r3650756453 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 37 +++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 47 ++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index 4c0503777..de920bcc5 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1119,6 +1119,43 @@ test("verifyPortableObjectProof()", async (t) => { } }); + await t.step( + "verifies proof aliases from caller-loaded contexts", + async () => { + const contextUrl = "https://context.example/security"; + const proofAlias = "integrityProof"; + const proofIri = "https://w3id.org/security#proof"; + let contextLoads = 0; + const contextLoader = async (url: string) => { + if (url !== contextUrl) return await mockDocumentLoader(url); + contextLoads++; + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": { [proofAlias]: proofIri }, + }, + }; + }; + const document = { + ...unsignedObject, + "@context": [...portableContext, contextUrl], + }; + const { proof, ...signed } = await signPortableJsonLd(document); + const result = await verifyPortableObjectProof({ + ...signed, + [proofAlias]: proof, + }, { + ...options, + contextLoader, + }); + assert(result.verified); + assertEquals(result.keys.length, 1); + assertEquals(result.keys[0].id, portableKeyId); + assertEquals(contextLoads, 1); + }, + ); + await t.step("verifies portable actors and activities by shape", async () => { for ( const document of [ diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 7a433ee2d..2b3554237 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -10,6 +10,7 @@ import { getFe34Origin, haveSameFe34Origin, parseIri, + type RemoteDocument, } from "@fedify/vocab-runtime"; import jsonld from "@fedify/vocab-runtime/jsonld"; import { getLogger } from "@logtape/logtape"; @@ -493,6 +494,7 @@ interface ProofMessageDigests { interface ProofMessageDigestCache { value?: Promise; + proofContextLoader?: DocumentLoader; } function expandContextPropertyIri( @@ -519,11 +521,12 @@ function expandContextPropertyIri( async function getProofPropertyNames( jsonLd: Record, + documentLoader: DocumentLoader = preloadedOnlyDocumentLoader, ): Promise> { const names = new Set(["proof", SECURITY_PROOF]); if (jsonLd["@context"] == null) return names; try { - const options = { documentLoader: preloadedOnlyDocumentLoader }; + const options = { documentLoader }; let activeContext = await jsonld.processContext(null, null, options); activeContext = await jsonld.processContext( activeContext, @@ -570,13 +573,16 @@ async function getProofPropertyNames( async function createProofMessageDigests( jsonLd: unknown, + proofContextLoader?: DocumentLoader, ): Promise { const msg = { ...(jsonLd as Record) }; // `verifyProof()` promises to ignore existing proofs on the input; // strip every top-level property that the active JSON-LD context maps to // the security proof predicate so its bytes are not folded into the JCS // message digest. - for (const property of await getProofPropertyNames(msg)) { + for ( + const property of await getProofPropertyNames(msg, proofContextLoader) + ) { delete msg[property]; } const encoder = new TextEncoder(); @@ -722,7 +728,10 @@ async function verifyProofInternal( ); }; const messageDigests = await ( - messageDigestCache.value ??= createProofMessageDigests(jsonLd) + messageDigestCache.value ??= createProofMessageDigests( + jsonLd, + messageDigestCache.proofContextLoader, + ) ); if (await verifyCandidate(messageDigests.onWire)) return publicKey; const normalizedDigest = await messageDigests.normalized(); @@ -832,18 +841,37 @@ function classifyFep2277CoreType( async function expandPortableObjectRoot( jsonLd: unknown, contextLoader: DocumentLoader | undefined, -): Promise> { +): Promise<{ + root: Record; + proofContextLoader: DocumentLoader; +}> { if (!isJsonLdNode(jsonLd)) { throw new TypeError("Expected a single JSON-LD object."); } + const loadedContexts = new Map(); + const loader = getNormalizationContextLoader(contextLoader); + const recordingLoader: DocumentLoader = async (url, options) => { + const remoteDocument = await loader(url, options); + const key = URL.canParse(url) ? new URL(url).href : url; + loadedContexts.set(key, structuredClone(remoteDocument)); + return remoteDocument; + }; const expanded = await jsonld.expand(jsonLd, { - documentLoader: getNormalizationContextLoader(contextLoader), + documentLoader: recordingLoader, keepFreeFloatingNodes: true, }); if (expanded.length !== 1 || !isJsonLdNode(expanded[0])) { throw new TypeError("Expected a single JSON-LD object."); } - return expanded[0]; + return { + root: expanded[0], + proofContextLoader: async (url, options) => { + const key = URL.canParse(url) ? new URL(url).href : url; + const remoteDocument = loadedContexts.get(key); + if (remoteDocument != null) return structuredClone(remoteDocument); + return await preloadedOnlyDocumentLoader(url, options); + }, + }; } /** @@ -869,7 +897,10 @@ export async function verifyPortableObjectProof( jsonLd: unknown, options: VerifyPortableObjectProofOptions = {}, ): Promise { - const root = await expandPortableObjectRoot(jsonLd, options.contextLoader); + const { root, proofContextLoader } = await expandPortableObjectRoot( + jsonLd, + options.contextLoader, + ); const id = root["@id"]; if ( typeof id !== "string" || @@ -990,7 +1021,7 @@ export async function verifyPortableObjectProof( } const keys: Multikey[] = []; - const messageDigestCache: ProofMessageDigestCache = {}; + const messageDigestCache: ProofMessageDigestCache = { proofContextLoader }; for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { const key = await verifyProofWithMessageDigestCache( jsonLd, From e126686525b683a9a4c04de2a850f9eb28d96b81 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 13:29:24 +0900 Subject: [PATCH 06/12] Reject clear non-portable IDs early Inspect an explicit JSON-LD @id before expanding remote contexts. This avoids unnecessary attacker-controlled context work for obviously non-portable objects while preserving expansion for aliased or absent IDs. https://github.com/fedify-dev/fedify/pull/968#pullrequestreview-4780890298 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 22 ++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 13 ++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index de920bcc5..dae0fd1d4 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1299,6 +1299,28 @@ test("verifyPortableObjectProof()", async (t) => { } }); + await t.step( + "short-circuits clearly non-portable raw IDs", + async () => { + let contextLoads = 0; + const result = await verifyPortableObjectProof({ + "@context": "https://attacker.example/context", + "@id": "https://social.example/objects/1", + }, { + ...options, + contextLoader() { + contextLoads++; + throw new TypeError("unexpected context load"); + }, + }); + assertEquals(result, { + verified: false, + reason: { type: "notPortableObject" }, + }); + assertEquals(contextLoads, 0); + }, + ); + await t.step("distinguishes unsecured and signed collections", async () => { const collection = { "@context": portableContext, diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 2b3554237..ab2bb3070 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -795,6 +795,7 @@ const FEP_2277_COLLECTION_PROPERTIES = [ ].map((property) => AS_NAMESPACE + property); const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; +const PORTABLE_OBJECT_ID_PATTERN = /^ap(?:\+ef61)?:\/\//i; function isJsonLdNode(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); @@ -897,6 +898,16 @@ export async function verifyPortableObjectProof( jsonLd: unknown, options: VerifyPortableObjectProofOptions = {}, ): Promise { + if ( + isJsonLdNode(jsonLd) && + typeof jsonLd["@id"] === "string" && + !PORTABLE_OBJECT_ID_PATTERN.test(jsonLd["@id"]) + ) { + return { + verified: false, + reason: { type: "notPortableObject" }, + }; + } const { root, proofContextLoader } = await expandPortableObjectRoot( jsonLd, options.contextLoader, @@ -904,7 +915,7 @@ export async function verifyPortableObjectProof( const id = root["@id"]; if ( typeof id !== "string" || - !/^ap(?:\+ef61)?:\/\//i.test(id) + !PORTABLE_OBJECT_ID_PATTERN.test(id) ) { return { verified: false, From 8405a46a3c4c3071486367b90ea8000ced868187 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 13:56:56 +0900 Subject: [PATCH 07/12] Reject ambiguous proof field values Require every functional Data Integrity Proof property to expand to exactly one non-list value before decoding. This prevents appended values from being ignored by singular accessors while the original signature still verifies. https://github.com/fedify-dev/fedify/pull/968#discussion_r3651688799 https://github.com/fedify-dev/fedify/pull/968#discussion_r3651763700 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 33 +++++++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 22 ++++++++++++------ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index dae0fd1d4..ef5d2ea09 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1472,6 +1472,39 @@ test("verifyPortableObjectProof()", async (t) => { }, ); + await t.step( + "rejects multiple values in functional proof fields", + async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const cases: readonly (readonly [string, unknown])[] = [ + ["cryptosuite", "eddsa-jcs-2022"], + ["proofPurpose", "authentication"], + ["proofValue", proof.proofValue], + ["created", "2024-01-01T00:00:00Z"], + ]; + for ( + const [field, additionalValue] of cases + ) { + const originalValue = proof[field]; + assert(originalValue != null); + assertEquals( + await verifyPortableObjectProof({ + ...signed, + proof: { + ...proof, + [field]: [originalValue, additionalValue], + }, + }, options), + { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }, + ); + } + }, + ); + await t.step("reports cryptographic failures separately", async () => { const signed = await signPortableJsonLd(unsignedObject); const tampered = { ...signed, content: "Tampered" }; diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index ab2bb3070..81c414136 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -796,12 +796,19 @@ const FEP_2277_COLLECTION_PROPERTIES = [ const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; const PORTABLE_OBJECT_ID_PATTERN = /^ap(?:\+ef61)?:\/\//i; +const FUNCTIONAL_PROOF_PROPERTIES = [ + `${SECURITY_NAMESPACE}cryptosuite`, + SECURITY_VERIFICATION_METHOD, + `${SECURITY_NAMESPACE}proofPurpose`, + `${SECURITY_NAMESPACE}proofValue`, + "http://purl.org/dc/terms/created", +] as const; function isJsonLdNode(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } -function hasSingleVerificationMethod(proofValue: unknown): boolean { +function hasSingleFunctionalProofValues(proofValue: unknown): boolean { if (!isJsonLdNode(proofValue)) return false; let proofNode = proofValue; if ("@graph" in proofNode) { @@ -814,11 +821,12 @@ function hasSingleVerificationMethod(proofValue: unknown): boolean { } proofNode = graph[0]; } - const verificationMethods = proofNode[SECURITY_VERIFICATION_METHOD]; - return Array.isArray(verificationMethods) && - verificationMethods.length === 1 && - !(isJsonLdNode(verificationMethods[0]) && - "@list" in verificationMethods[0]); + return FUNCTIONAL_PROOF_PROPERTIES.every((property) => { + const values = proofNode[property]; + return Array.isArray(values) && + values.length === 1 && + !(isJsonLdNode(values[0]) && "@list" in values[0]); + }); } function classifyFep2277CoreType( @@ -963,7 +971,7 @@ export async function verifyPortableObjectProof( const proofs: DataIntegrityProof[] = []; for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { const proofValue = proofValues[proofIndex]; - if (!hasSingleVerificationMethod(proofValue)) { + if (!hasSingleFunctionalProofValues(proofValue)) { return { verified: false, reason: { type: "invalidProof", proofIndex }, From d55cc8f50e05d1cec70a4696330f7d61c4882eb7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 15:33:46 +0900 Subject: [PATCH 08/12] Narrow proof digest input type Reuse the JSON-LD node guard in verification so TypeScript carries the exact record contract into digest creation. This removes the local assertion without changing runtime rejection. https://github.com/fedify-dev/fedify/pull/968#discussion_r3651728503 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 81c414136..104fece55 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -572,10 +572,10 @@ async function getProofPropertyNames( } async function createProofMessageDigests( - jsonLd: unknown, + jsonLd: Record, proofContextLoader?: DocumentLoader, ): Promise { - const msg = { ...(jsonLd as Record) }; + const msg = { ...jsonLd }; // `verifyProof()` promises to ignore existing proofs on the input; // strip every top-level property that the active JSON-LD context maps to // the security proof predicate so its bytes are not folded into the JCS @@ -616,9 +616,7 @@ async function verifyProofInternal( messageDigestCache: ProofMessageDigestCache, ): Promise { if ( - typeof jsonLd !== "object" || - jsonLd == null || - Array.isArray(jsonLd) || + !isJsonLdNode(jsonLd) || proof.cryptosuite !== "eddsa-jcs-2022" || proof.verificationMethodId == null || proof.proofPurpose !== "assertionMethod" || From 54fa3c7cb1ad7d942e7ee3ccd83a5f5ea7ea0522 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 15:34:38 +0900 Subject: [PATCH 09/12] Clarify proof validation comment Use an explicit verb to describe why the complete proof set is validated before any key resolution begins. https://github.com/fedify-dev/fedify/pull/968#discussion_r3651909250 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 104fece55..5da080c5b 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -990,9 +990,9 @@ export async function verifyPortableObjectProof( proofs.push(proof); } - // Complete policy validation for the whole proof set before resolving any - // keys. This prevents a later non-DID or cross-authority proof from causing - // unnecessary attacker-controlled document fetches. + // Validate the whole proof set before resolving any keys. A later non-DID + // or cross-authority proof therefore cannot cause unnecessary + // attacker-controlled document fetches. for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { const verificationMethod = proofs[proofIndex].verificationMethodId; if (verificationMethod == null) { From 3c0f6621fe5d934cffdc3c5245506c057c819129 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Jul 2026 15:46:29 +0900 Subject: [PATCH 10/12] Require the portable proof type Require each expanded portable proof to declare exactly the DataIntegrityProof type before decoding. This prevents a missing or appended type from being ignored while the reconstructed proof configuration still verifies. https://github.com/fedify-dev/fedify/pull/968#discussion_r3651941071 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 28 +++++++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 21 ++++++++++++-------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index ef5d2ea09..f6fff4d51 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -1505,6 +1505,34 @@ test("verifyPortableObjectProof()", async (t) => { }, ); + await t.step( + "requires exactly the DataIntegrityProof type", + async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const missingType = { ...proof }; + delete missingType.type; + for ( + const invalidProof of [ + missingType, + { ...proof, type: "Object" }, + { ...proof, type: ["DataIntegrityProof", "Object"] }, + ] + ) { + assertEquals( + await verifyPortableObjectProof({ + ...signed, + proof: invalidProof, + }, options), + { + verified: false, + reason: { type: "invalidProof", proofIndex: 0 }, + }, + ); + } + }, + ); + await t.step("reports cryptographic failures separately", async () => { const signed = await signPortableJsonLd(unsignedObject); const tampered = { ...signed, content: "Tampered" }; diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 5da080c5b..0a8ee6e01 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -792,6 +792,7 @@ const FEP_2277_COLLECTION_PROPERTIES = [ "current", ].map((property) => AS_NAMESPACE + property); const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; +const DATA_INTEGRITY_PROOF = `${SECURITY_NAMESPACE}DataIntegrityProof`; const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; const PORTABLE_OBJECT_ID_PATTERN = /^ap(?:\+ef61)?:\/\//i; const FUNCTIONAL_PROOF_PROPERTIES = [ @@ -806,7 +807,7 @@ function isJsonLdNode(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } -function hasSingleFunctionalProofValues(proofValue: unknown): boolean { +function hasValidPortableProofShape(proofValue: unknown): boolean { if (!isJsonLdNode(proofValue)) return false; let proofNode = proofValue; if ("@graph" in proofNode) { @@ -819,12 +820,16 @@ function hasSingleFunctionalProofValues(proofValue: unknown): boolean { } proofNode = graph[0]; } - return FUNCTIONAL_PROOF_PROPERTIES.every((property) => { - const values = proofNode[property]; - return Array.isArray(values) && - values.length === 1 && - !(isJsonLdNode(values[0]) && "@list" in values[0]); - }); + const types = proofNode["@type"]; + return Array.isArray(types) && + types.length === 1 && + types[0] === DATA_INTEGRITY_PROOF && + FUNCTIONAL_PROOF_PROPERTIES.every((property) => { + const values = proofNode[property]; + return Array.isArray(values) && + values.length === 1 && + !(isJsonLdNode(values[0]) && "@list" in values[0]); + }); } function classifyFep2277CoreType( @@ -969,7 +974,7 @@ export async function verifyPortableObjectProof( const proofs: DataIntegrityProof[] = []; for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { const proofValue = proofValues[proofIndex]; - if (!hasSingleFunctionalProofValues(proofValue)) { + if (!hasValidPortableProofShape(proofValue)) { return { verified: false, reason: { type: "invalidProof", proofIndex }, From 30a49782f50bd6c7e7d180de1f3392f4841e4ab7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 27 Jul 2026 06:53:31 +0900 Subject: [PATCH 11/12] Authenticate complete proof configurations Preserve every received proof option except proofValue when building the JCS verification input so expiry, domain, challenge, nonce, and extension fields cannot be changed without invalidating the signature. Resolve raw and typed proofs by content rather than array position, parse raw candidates once, and fail closed when unresolved contexts could hide aliased proof options. Retain compatibility for literal proof fields on documents with unrelated remote contexts. Add domain and challenge expectations to VerifyProofOptions, document the behavior, and cover malformed, expired, aliased, multi-proof, and context edge cases. https://github.com/fedify-dev/fedify/pull/968#pullrequestreview-4782424873 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.6-sol --- CHANGES.md | 7 + docs/manual/send.md | 6 + packages/fedify/src/sig/proof.test.ts | 365 ++++++++++++++++- packages/fedify/src/sig/proof.ts | 567 ++++++++++++++++++++++++-- 4 files changed, 914 insertions(+), 31 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 76b9bd584..71eda0573 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,13 @@ To be released. ### @fedify/fedify + - Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received + proof option except `proofValue`, including `expires`, `domain`, + `challenge`, `nonce`, and extension options. It now rejects expired or + malformed proof options, and callers can provide expected `domain` and + `challenge` values through `VerifyProofOptions` to prevent cross-domain or + replay use. [[#968]] + - Added `verifyPortableObjectProof()` to enforce the [FEP-ef61] proof policy for portable actors, activities, objects, and signed collections. Its detailed result distinguishes documents outside the policy, unsecured diff --git a/docs/manual/send.md b/docs/manual/send.md index 46f9fbb08..c0a681211 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1094,6 +1094,12 @@ When `verifyObject()` checks whether a proof authenticates an object's actor or attribution, it treats matching portable `ap:`/`ap+ef61:` IDs and `did:key` controllers as the same [FEP-fe34] cryptographic origin. +`verifyProof()` authenticates every received proof option except `proofValue` +as part of the Ed25519 JCS signature. It rejects expired proofs and malformed +standard options. When a proof is bound to a security domain or challenge, +pass the expected `domain` or `challenge` through `VerifyProofOptions`; a +missing or different value makes verification fail. + For received portable objects, use `verifyPortableObjectProof()`. It keeps `verifyProof()` focused on cryptographic verification while also enforcing the [FEP-ef61] policy: a portable actor, activity, or object must have proofs, every diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index f6fff4d51..e1b6b5249 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -86,9 +86,11 @@ async function signPortableJsonLd( { privateKey = ed25519PrivateKey, verificationMethod = portableKeyId, + proofOptions = {}, }: { privateKey?: CryptoKey; verificationMethod?: URL; + proofOptions?: Record; } = {}, ): Promise> { const proofConfig = { @@ -98,6 +100,7 @@ async function signPortableJsonLd( verificationMethod: verificationMethod.href, proofPurpose: "assertionMethod", created: portableProofCreated, + ...proofOptions, }; const encoder = new TextEncoder(); const proofDigest = await crypto.subtle.digest( @@ -124,6 +127,35 @@ async function signPortableJsonLd( }, }; } + +async function parsePortableProof( + document: Record, +): Promise { + const proof = document.proof; + assert( + typeof proof === "object" && proof != null && !Array.isArray(proof), + ); + return await DataIntegrityProof.fromJsonLd({ + "@context": document["@context"], + ...proof, + }, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); +} + +async function assertPortableProofVerified( + document: Record, + options: VerifyProofOptions, +): Promise { + const key = await verifyProof( + document, + await parsePortableProof(document), + options, + ); + assert(key != null); + assertEquals(key.id, portableKeyId); +} const fep8b32TestVectorActivity = new Create({ id: new URL("https://server.example/activities/1"), actor: new URL("https://server.example/users/alice"), @@ -630,6 +662,268 @@ test("verifyProof()", async () => { assertFalse(contextLoaderCalls.includes("https://attacker.example/ctx")); }); +test("verifyProof() authenticates the complete proof configuration", async (t) => { + const jsonLd = { + "@context": portableContext, + id: "https://example.com/activities/complete-proof-options", + type: "Create", + actor: "https://example.com/users/alice", + object: { + type: "Note", + content: "Every proof option is authenticated.", + }, + }; + const options: VerifyProofOptions = { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }; + + await t.step("rejects an expires option added after signing", async () => { + const signed = await signPortableJsonLd(jsonLd); + const tampered = { + ...signed, + proof: { + ...signed.proof as Record, + expires: "3000-01-01T00:00:00Z", + }, + }; + assertEquals( + await verifyProof(tampered, await parsePortableProof(tampered), options), + null, + ); + }); + + await t.step("accepts an authenticated future expiration", async () => { + const signed = await signPortableJsonLd(jsonLd, { + proofOptions: { expires: "3000-01-01T00:00:00Z" }, + }); + await assertPortableProofVerified(signed, options); + }); + + await t.step("rejects an authenticated expired proof", async () => { + const signed = await signPortableJsonLd(jsonLd, { + proofOptions: { expires: "2000-01-01T00:00:00Z" }, + }); + assertEquals( + await verifyProof(signed, await parsePortableProof(signed), options), + null, + ); + }); + + await t.step("authenticates domain, challenge, and nonce", async () => { + const signed = await signPortableJsonLd(jsonLd, { + proofOptions: { + domain: ["social.example", "https://social.example"], + challenge: "challenge-123", + nonce: "nonce-456", + }, + }); + const proof = await parsePortableProof(signed); + const matchingKey = await verifyProof(signed, proof, { + ...options, + domain: ["https://social.example", "social.example"], + challenge: "challenge-123", + }); + assert(matchingKey != null); + assertEquals(matchingKey.id, portableKeyId); + assertEquals( + await verifyProof(signed, proof, { + ...options, + domain: ["social.example"], + challenge: "challenge-123", + }), + null, + ); + assertEquals( + await verifyProof(signed, proof, { + ...options, + domain: ["https://social.example", "social.example"], + challenge: "wrong-challenge", + }), + null, + ); + + for ( + const [property, value] of [ + ["domain", ["other.example"]], + ["challenge", "tampered-challenge"], + ["nonce", "tampered-nonce"], + ] as const + ) { + const tampered = { + ...signed, + proof: { + ...signed.proof as Record, + [property]: value, + }, + }; + assertEquals( + await verifyProof( + tampered, + await parsePortableProof(tampered), + options, + ), + null, + ); + } + }); + + await t.step("requires requested domain and challenge options", async () => { + const signed = await signPortableJsonLd(jsonLd); + const proof = await parsePortableProof(signed); + assertEquals( + await verifyProof(signed, proof, { + ...options, + domain: "social.example", + }), + null, + ); + assertEquals( + await verifyProof(signed, proof, { + ...options, + challenge: "challenge-123", + }), + null, + ); + }); + + await t.step("rejects malformed proof options", async () => { + for ( + const proofOptions of [ + { expires: "not-a-date" }, + { domain: ["social.example", 42] }, + { challenge: 42 }, + { nonce: { value: "not-a-string" } }, + { nonce: ["not", "a", "string"] }, + { previousProof: ["urn:uuid:proof-1", 42] }, + ] + ) { + const signed = await signPortableJsonLd(jsonLd, { proofOptions }); + assertEquals( + await verifyProof(signed, await parsePortableProof(signed), options), + null, + ); + } + }); + + await t.step("authenticates aliased and extension options", async () => { + const aliasedJsonLd = { + ...jsonLd, + "@context": [ + ...portableContext, + { + validUntil: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + extensionOption: "https://example.com/security#extensionOption", + }, + ], + }; + const signed = await signPortableJsonLd(aliasedJsonLd, { + proofOptions: { + validUntil: "3000-01-01T00:00:00Z", + extensionOption: { nested: ["one", "two"] }, + }, + }); + await assertPortableProofVerified(signed, options); + + for ( + const [property, value] of [ + ["validUntil", "2999-01-01T00:00:00Z"], + ["extensionOption", { nested: ["one", "changed"] }], + ] as const + ) { + const tampered = { + ...signed, + proof: { + ...signed.proof as Record, + [property]: value, + }, + }; + assertEquals( + await verifyProof( + tampered, + await parsePortableProof(tampered), + options, + ), + null, + ); + } + }); + + await t.step( + "accepts literal proof fields with an unresolved extra context", + async () => { + const document = { + ...jsonLd, + "@context": [ + ...portableContext, + "https://context.example/unrelated", + ], + }; + const signed = await signPortableJsonLd(document); + const rawProof = signed.proof as Record; + const proof = new DataIntegrityProof({ + cryptosuite: "eddsa-jcs-2022", + verificationMethod: portableKeyId, + proofPurpose: "assertionMethod", + proofValue: decodeMultibase(rawProof.proofValue as string), + created: Temporal.Instant.from(portableProofCreated), + }); + const key = await verifyProof(signed, proof, options); + assert(key != null); + assertEquals(key.id, portableKeyId); + }, + ); + + await t.step( + "rejects aliases whose proof context cannot be resolved safely", + async () => { + const contextUrl = "https://context.example/proof-options"; + const document = { + ...jsonLd, + "@context": [...portableContext, contextUrl], + }; + const signed = await signPortableJsonLd(document, { + proofOptions: { + validUntil: "2000-01-01T00:00:00Z", + }, + }); + const rawProof = signed.proof as Record; + const proof = new DataIntegrityProof({ + cryptosuite: "eddsa-jcs-2022", + verificationMethod: portableKeyId, + proofPurpose: "assertionMethod", + proofValue: decodeMultibase(rawProof.proofValue as string), + created: Temporal.Instant.from(portableProofCreated), + }); + const contextLoader = async (url: string) => { + if (url !== contextUrl) return await mockDocumentLoader(url); + return { + contextUrl: null, + documentUrl: url, + document: { + "@context": { + validUntil: { + "@id": "https://w3id.org/security#expiration", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + }, + }, + }; + }; + assertEquals( + await verifyProof(signed, proof, { + ...options, + contextLoader, + }), + null, + ); + }, + ); +}); + test("verifyProof() resolves did:key verification methods locally", async () => { const multibaseKey = (await exportDidKey(ed25519PublicKey.publicKey)).slice( "did:key:".length, @@ -949,12 +1243,14 @@ test("verifyProof() records verification duration metric", async (t) => { "verifyObject() wrapper emits one measurement per inner verifyProof()", async () => { const [meterProvider, recorder] = createTestMeterProvider(); + const serializedProof = await proof.toJsonLd({ + format: "compact", + contextLoader: mockDocumentLoader, + }) as Record; + const { "@context": _proofContext, ...embeddedProof } = serializedProof; const create = await verifyObject(Create, { ...jsonLd, - proof: await proof.toJsonLd({ - format: "compact", - contextLoader: mockDocumentLoader, - }), + proof: embeddedProof, }, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, @@ -1049,6 +1345,41 @@ test("verifyPortableObjectProof()", async (t) => { }, ); + await t.step( + "rejects unauthenticated and expired proof options", + async () => { + const signed = await signPortableJsonLd(unsignedObject); + const tampered = { + ...signed, + proof: { + ...signed.proof as Record, + expires: "3000-01-01T00:00:00Z", + }, + }; + const tamperedResult = await verifyPortableObjectProof( + tampered, + options, + ); + assertFalse(tamperedResult.verified); + assertEquals(tamperedResult.reason, { + type: "invalidProof", + proofIndex: 0, + }); + + const expiredResult = await verifyPortableObjectProof( + await signPortableJsonLd(unsignedObject, { + proofOptions: { expires: "2000-01-01T00:00:00Z" }, + }), + options, + ); + assertFalse(expiredResult.verified); + assertEquals(expiredResult.reason, { + type: "invalidProof", + proofIndex: 0, + }); + }, + ); + await t.step("verifies aliased proof properties", async () => { const proofAlias = "integrityProof"; const proofIri = "https://w3id.org/security#proof"; @@ -1587,6 +1918,32 @@ test("verifyPortableObjectProof()", async (t) => { ]); }); + await t.step("matches proofs across aliased properties", async () => { + const document = { + ...unsignedObject, + "@context": [ + ...portableContext, + { + firstProof: "https://w3id.org/security#proof", + secondProof: "https://w3id.org/security#proof", + }, + ], + }; + const firstSigned = await signPortableJsonLd(document, { + proofOptions: { domain: "first.example" }, + }); + const secondSigned = await signPortableJsonLd(document, { + proofOptions: { domain: "second.example" }, + }); + const result = await verifyPortableObjectProof({ + ...document, + secondProof: secondSigned.proof, + firstProof: firstSigned.proof, + }, options); + assert(result.verified); + assertEquals(result.keys.length, 2); + }); + await t.step( "canonicalizes the document only once for multiple proofs", async () => { diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index 0a8ee6e01..c97ef5341 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -53,6 +53,16 @@ const OIP_KNOWN_CRYPTOSUITES = new Set( ); const logger = getLogger(["fedify", "sig", "proof"]); +const SECURITY_NAMESPACE = "https://w3id.org/security#"; +const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; +const SECURITY_PROOF_VALUE = `${SECURITY_NAMESPACE}proofValue`; +const DATA_INTEGRITY_PROOF = `${SECURITY_NAMESPACE}DataIntegrityProof`; +const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; +const SECURITY_EXPIRATION = `${SECURITY_NAMESPACE}expiration`; +const SECURITY_DOMAIN = `${SECURITY_NAMESPACE}domain`; +const SECURITY_CHALLENGE = `${SECURITY_NAMESPACE}challenge`; +const SECURITY_NONCE = `${SECURITY_NAMESPACE}nonce`; +const SECURITY_PREVIOUS_PROOF = `${SECURITY_NAMESPACE}previousProof`; /** * Checks if the given JSON-LD document has a DataIntegrityProof-like object, @@ -286,6 +296,20 @@ export async function signObject( * @since 0.10.0 */ export interface VerifyProofOptions { + /** + * The security domain expected by the verifier. When specified, it must + * contain the same strings as the proof's `domain` option. + * @since 2.4.0 + */ + domain?: string | readonly string[]; + + /** + * The challenge expected by the verifier. When specified, it must exactly + * match the proof's `challenge` option. + * @since 2.4.0 + */ + challenge?: string; + /** * The context loader for loading remote JSON-LD contexts. */ @@ -417,6 +441,7 @@ async function verifyProofWithMessageDigestCache( proof: DataIntegrityProof, options: VerifyProofOptions, messageDigestCache: ProofMessageDigestCache = {}, + rawProofCandidates?: readonly RawProofCandidate[], ): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); @@ -457,6 +482,7 @@ async function verifyProofWithMessageDigestCache( proof, options, messageDigestCache, + rawProofCandidates, ); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); else verified = true; @@ -493,7 +519,7 @@ interface ProofMessageDigests { } interface ProofMessageDigestCache { - value?: Promise; + values?: Map>; proofContextLoader?: DocumentLoader; } @@ -519,18 +545,23 @@ function expandContextPropertyIri( return key; } -async function getProofPropertyNames( +async function getJsonLdPropertyNames( jsonLd: Record, + propertyIri: string, + defaults: readonly string[], documentLoader: DocumentLoader = preloadedOnlyDocumentLoader, -): Promise> { - const names = new Set(["proof", SECURITY_PROOF]); - if (jsonLd["@context"] == null) return names; + inheritedContext?: unknown, + rejectOnContextError = false, +): Promise | null> { + const names = new Set(defaults); + const context = jsonLd["@context"] ?? inheritedContext; + if (context == null) return names; try { const options = { documentLoader }; let activeContext = await jsonld.processContext(null, null, options); activeContext = await jsonld.processContext( activeContext, - jsonLd["@context"], + context, options, ); const typeScopedContext = activeContext; @@ -560,20 +591,34 @@ async function getProofPropertyNames( } } for (const key of globalThis.Object.keys(jsonLd)) { - if (expandContextPropertyIri(activeContext, key) === SECURITY_PROOF) { + if (expandContextPropertyIri(activeContext, key) === propertyIri) { names.add(key); } } } catch { + if (rejectOnContextError) return null; // Unavailable contexts must not prevent the literal proof properties // from being removed without a network fetch. } return names; } +async function getProofPropertyNames( + jsonLd: Record, + documentLoader: DocumentLoader = preloadedOnlyDocumentLoader, +): Promise> { + return await getJsonLdPropertyNames( + jsonLd, + SECURITY_PROOF, + ["proof", SECURITY_PROOF], + documentLoader, + ) ?? new Set(["proof", SECURITY_PROOF]); +} + async function createProofMessageDigests( jsonLd: Record, proofContextLoader?: DocumentLoader, + context?: unknown, ): Promise { const msg = { ...jsonLd }; // `verifyProof()` promises to ignore existing proofs on the input; @@ -585,6 +630,7 @@ async function createProofMessageDigests( ) { delete msg[property]; } + if (context != null) msg["@context"] = structuredClone(context); const encoder = new TextEncoder(); const digest = async (value: unknown): Promise => { const bytes = encoder.encode(serialize(value)); @@ -609,11 +655,432 @@ async function createProofMessageDigests( }; } +interface ProofConfiguration { + readonly value: Record; + readonly context: unknown; +} + +function contextValues(context: unknown): unknown[] { + return Array.isArray(context) ? context : [context]; +} + +function equalJsonValues(left: unknown, right: unknown): boolean { + try { + return serialize(left) === serialize(right); + } catch { + return false; + } +} + +function contextStartsWith( + documentContext: unknown, + proofContext: unknown, +): boolean { + if (documentContext == null) return false; + const documentValues = contextValues(documentContext); + const proofValues = contextValues(proofContext); + return proofValues.length <= documentValues.length && + proofValues.every((value, index) => + equalJsonValues(value, documentValues[index]) + ); +} + +function appendProofValues(values: unknown[], value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) appendProofValues(values, item); + } else if ( + isJsonLdNode(value) && Array.isArray(value["@graph"]) + ) { + for (const item of value["@graph"]) appendProofValues(values, item); + } else { + values.push(value); + } +} + +async function getRawProofValues( + jsonLd: Record, + documentLoader: DocumentLoader, +): Promise { + const propertyNames = await getProofPropertyNames(jsonLd, documentLoader); + const values: unknown[] = []; + for (const [property, value] of globalThis.Object.entries(jsonLd)) { + if (propertyNames.has(property)) appendProofValues(values, value); + } + return values; +} + +function sameBytes( + left: Uint8Array | null, + right: Uint8Array | null, +): boolean { + if (left == null || right == null) return left === right; + return left.length === right.length && + left.every((value, index) => value === right[index]); +} + +function sameProof( + left: DataIntegrityProof, + right: DataIntegrityProof, +): boolean { + return left.cryptosuite === right.cryptosuite && + left.verificationMethodId?.href === right.verificationMethodId?.href && + left.proofPurpose === right.proofPurpose && + sameBytes(left.proofValue, right.proofValue) && + left.created?.toString() === right.created?.toString(); +} + +interface RawProofCandidate { + readonly value: unknown; + readonly proof: DataIntegrityProof | null; +} + +async function parseRawProofCandidates( + jsonLd: Record, + values: readonly unknown[], + options: VerifyProofOptions, + documentLoader: DocumentLoader, +): Promise { + const candidates: RawProofCandidate[] = []; + for (const value of values) { + let parsed: DataIntegrityProof | null = null; + if (isJsonLdNode(value)) { + const proofJsonLd = value["@context"] == null && + jsonLd["@context"] != null + ? { "@context": jsonLd["@context"], ...value } + : value; + try { + parsed = await DataIntegrityProof.fromJsonLd( + proofJsonLd, + { ...options, contextLoader: documentLoader }, + ); + } catch { + // Malformed sibling proofs cannot match a typed proof. + } + } + candidates.push({ value, proof: parsed }); + } + return candidates; +} + +async function findRawProofValue( + jsonLd: Record, + proof: DataIntegrityProof, + options: VerifyProofOptions, + documentLoader: DocumentLoader, + rawProofCandidates?: readonly RawProofCandidate[], +): Promise { + const candidates = rawProofCandidates ?? + await parseRawProofCandidates( + jsonLd, + await getRawProofValues(jsonLd, documentLoader), + options, + documentLoader, + ); + for (const candidate of candidates) { + if ( + candidate.proof != null && + sameProof(candidate.proof, proof) + ) { + return candidate.value; + } + } + return undefined; +} + +const STANDARD_COMPACT_PROOF_PROPERTIES = new Set([ + "@context", + "type", + "cryptosuite", + "verificationMethod", + "proofPurpose", + "proofValue", + "created", +]); + +const STANDARD_EXPANDED_PROOF_PROPERTIES = new Set([ + "@context", + "@type", + `${SECURITY_NAMESPACE}cryptosuite`, + SECURITY_VERIFICATION_METHOD, + `${SECURITY_NAMESPACE}proofPurpose`, + `${SECURITY_NAMESPACE}proofValue`, + "http://purl.org/dc/terms/created", +]); + +function hasAdditionalProofOptions(value: unknown): boolean { + const node = Array.isArray(value) && value.length === 1 ? value[0] : value; + return isJsonLdNode(node) && + globalThis.Object.keys(node).some((property) => + !STANDARD_COMPACT_PROOF_PROPERTIES.has(property) && + !STANDARD_EXPANDED_PROOF_PROPERTIES.has(property) + ); +} + +function isExpandedJsonLdNode(value: Record): boolean { + const properties = globalThis.Object.keys(value).filter((key) => + !key.startsWith("@") + ); + return properties.length > 0 && + properties.every((property) => URL.canParse(property)); +} + +async function normalizeProofConfiguration( + rawProof: unknown, + documentContext: unknown, + documentLoader: DocumentLoader, +): Promise { + let node = Array.isArray(rawProof) && rawProof.length === 1 + ? rawProof[0] + : rawProof; + if (!isJsonLdNode(node)) return null; + if (isExpandedJsonLdNode(node)) { + if (documentContext == null) return null; + node = await jsonld.compact(node, documentContext, { + documentLoader, + }); + if (!isJsonLdNode(node)) return null; + } else { + node = structuredClone(node); + } + + const receivedContext = node["@context"]; + if ( + receivedContext != null && + !contextStartsWith(documentContext, receivedContext) + ) { + return null; + } + const context = receivedContext ?? documentContext; + if (context != null) node["@context"] = structuredClone(context); + + const proofValueProperties = await getJsonLdPropertyNames( + node, + SECURITY_PROOF_VALUE, + ["proofValue", SECURITY_PROOF_VALUE], + documentLoader, + context, + ) ?? new Set(["proofValue", SECURITY_PROOF_VALUE]); + for (const property of proofValueProperties) delete node[property]; + return { value: node, context }; +} + +async function createProofConfiguration( + jsonLd: Record, + proof: DataIntegrityProof, + options: VerifyProofOptions, + documentLoader: DocumentLoader, + rawProofCandidates?: readonly RawProofCandidate[], +): Promise { + let rawProof = await findRawProofValue( + jsonLd, + proof, + options, + documentLoader, + rawProofCandidates, + ); + if (rawProof == null) { + const serializedProof = await proof.toJsonLd(); + if (hasAdditionalProofOptions(serializedProof)) { + rawProof = serializedProof; + } + } + if (rawProof != null) { + return await normalizeProofConfiguration( + rawProof, + jsonLd["@context"], + documentLoader, + ); + } + const context = jsonLd["@context"]; + return { + value: { + ...(context == null ? {} : { "@context": context }), + type: "DataIntegrityProof", + cryptosuite: proof.cryptosuite, + verificationMethod: proof.verificationMethodId!.href, + proofPurpose: proof.proofPurpose, + created: proof.created!.toString(), + }, + context, + }; +} + +interface ProofOption { + readonly present: boolean; + readonly value?: unknown; +} + +const KNOWN_PROOF_CONFIGURATION_PROPERTIES = new Set([ + "@context", + "@id", + "@type", + "id", + "type", + "cryptosuite", + `${SECURITY_NAMESPACE}cryptosuite`, + "verificationMethod", + SECURITY_VERIFICATION_METHOD, + "proofPurpose", + `${SECURITY_NAMESPACE}proofPurpose`, + "created", + "http://purl.org/dc/terms/created", + "expires", + SECURITY_EXPIRATION, + "domain", + SECURITY_DOMAIN, + "challenge", + SECURITY_CHALLENGE, + "nonce", + SECURITY_NONCE, + "previousProof", + SECURITY_PREVIOUS_PROOF, +]); + +async function getProofOption( + proofConfig: Record, + propertyIri: string, + defaults: readonly string[], + documentLoader: DocumentLoader, +): Promise { + let names = await getJsonLdPropertyNames( + proofConfig, + propertyIri, + defaults, + documentLoader, + proofConfig["@context"], + true, + ); + if (names == null) { + if ( + globalThis.Object.keys(proofConfig).some((property) => + !KNOWN_PROOF_CONFIGURATION_PROPERTIES.has(property) + ) + ) { + return null; + } + names = new Set(defaults); + } + const present = globalThis.Object.keys(proofConfig).filter((property) => + names.has(property) + ); + if (present.length > 1) return null; + return present.length < 1 + ? { present: false } + : { present: true, value: proofConfig[present[0]] }; +} + +function parseStringSet(value: unknown): Set | null { + if (typeof value === "string") return new Set([value]); + if ( + !Array.isArray(value) || + value.some((item) => typeof item !== "string") + ) { + return null; + } + return new Set(value); +} + +function equalStringSets(left: Set, right: Set): boolean { + return left.size === right.size && + [...left].every((value) => right.has(value)); +} + +async function hasValidProofOptions( + proofConfig: Record, + options: VerifyProofOptions, + documentLoader: DocumentLoader, +): Promise { + const expires = await getProofOption( + proofConfig, + SECURITY_EXPIRATION, + ["expires", SECURITY_EXPIRATION], + documentLoader, + ); + if (expires == null) return false; + if (expires.present) { + if (typeof expires.value !== "string") return false; + let expiration: Temporal.Instant; + try { + expiration = Temporal.Instant.from(expires.value); + } catch { + return false; + } + if (Temporal.Instant.compare(Temporal.Now.instant(), expiration) >= 0) { + return false; + } + } + + const domain = await getProofOption( + proofConfig, + SECURITY_DOMAIN, + ["domain", SECURITY_DOMAIN], + documentLoader, + ); + if (domain == null) return false; + const proofDomains = domain.present ? parseStringSet(domain.value) : null; + if (domain.present && proofDomains == null) return false; + if (options.domain != null) { + const expectedDomains = parseStringSet(options.domain); + if ( + expectedDomains == null || proofDomains == null || + !equalStringSets(proofDomains, expectedDomains) + ) { + return false; + } + } + + const challenge = await getProofOption( + proofConfig, + SECURITY_CHALLENGE, + ["challenge", SECURITY_CHALLENGE], + documentLoader, + ); + if ( + challenge == null || + challenge.present && typeof challenge.value !== "string" || + options.challenge != null && + (!challenge.present || challenge.value !== options.challenge) + ) { + return false; + } + + const nonce = await getProofOption( + proofConfig, + SECURITY_NONCE, + ["nonce", SECURITY_NONCE], + documentLoader, + ); + if ( + nonce == null || + nonce.present && typeof nonce.value !== "string" + ) { + return false; + } + + const previousProof = await getProofOption( + proofConfig, + SECURITY_PREVIOUS_PROOF, + ["previousProof", SECURITY_PREVIOUS_PROOF], + documentLoader, + ); + if ( + previousProof == null || + previousProof.present && + typeof previousProof.value !== "string" && + (!Array.isArray(previousProof.value) || + previousProof.value.some((item) => typeof item !== "string")) + ) { + return false; + } + return true; +} + async function verifyProofInternal( jsonLd: unknown, proof: DataIntegrityProof, options: VerifyProofOptions, messageDigestCache: ProofMessageDigestCache, + rawProofCandidates?: readonly RawProofCandidate[], ): Promise { if ( !isJsonLdNode(jsonLd) || @@ -623,6 +1090,25 @@ async function verifyProofInternal( proof.proofValue == null || proof.created == null ) return null; + const proofContextLoader = messageDigestCache.proofContextLoader ?? + preloadedOnlyDocumentLoader; + const proofConfiguration = await createProofConfiguration( + jsonLd, + proof, + options, + proofContextLoader, + rawProofCandidates, + ); + if ( + proofConfiguration == null || + !await hasValidProofOptions( + proofConfiguration.value, + options, + proofContextLoader, + ) + ) { + return null; + } // Start the key fetch eagerly so it overlaps with the JCS // canonicalization and SHA-256 digest work below. `measureSignatureKeyFetch` // is an async function whose body runs synchronously up to the first @@ -633,17 +1119,8 @@ async function verifyProofInternal( "object_integrity", () => fetchKey(proof.verificationMethodId!, Multikey, options), ); - const proofConfig = { - // deno-lint-ignore no-explicit-any - "@context": (jsonLd as any)["@context"], - type: "DataIntegrityProof", - cryptosuite: proof.cryptosuite, - verificationMethod: proof.verificationMethodId.href, - proofPurpose: proof.proofPurpose, - created: proof.created.toString(), - }; const encoder = new TextEncoder(); - const proofBytes = encoder.encode(serialize(proofConfig)); + const proofBytes = encoder.encode(serialize(proofConfiguration.value)); const proofDigest = await crypto.subtle.digest("SHA-256", proofBytes); // Try the on-wire form first. Only if that fails do we fall back to // Fedify's outgoing JSON-LD compatibility form so that signatures created @@ -697,6 +1174,7 @@ async function verifyProofInternal( }, }, messageDigestCache, + rawProofCandidates, ); } logger.debug( @@ -725,12 +1203,18 @@ async function verifyProofInternal( digest, ); }; - const messageDigests = await ( - messageDigestCache.value ??= createProofMessageDigests( + const messageDigestKey = serialize(proofConfiguration.context ?? null); + const messageDigestValues = messageDigestCache.values ??= new Map(); + let messageDigestsPromise = messageDigestValues.get(messageDigestKey); + if (messageDigestsPromise == null) { + messageDigestsPromise = createProofMessageDigests( jsonLd, messageDigestCache.proofContextLoader, - ) - ); + proofConfiguration.context, + ); + messageDigestValues.set(messageDigestKey, messageDigestsPromise); + } + const messageDigests = await messageDigestsPromise; if (await verifyCandidate(messageDigests.onWire)) return publicKey; const normalizedDigest = await messageDigests.normalized(); if (normalizedDigest != null && await verifyCandidate(normalizedDigest)) { @@ -756,6 +1240,7 @@ async function verifyProofInternal( }, }, messageDigestCache, + rawProofCandidates, ); } logger.debug( @@ -775,7 +1260,6 @@ type Fep2277CoreType = | "object"; const AS_NAMESPACE = "https://www.w3.org/ns/activitystreams#"; -const SECURITY_NAMESPACE = "https://w3id.org/security#"; const FEP_2277_ACTOR_PROPERTIES = [ "http://www.w3.org/ns/ldp#inbox", `${AS_NAMESPACE}outbox`, @@ -791,9 +1275,6 @@ const FEP_2277_COLLECTION_PROPERTIES = [ "prev", "current", ].map((property) => AS_NAMESPACE + property); -const SECURITY_PROOF = `${SECURITY_NAMESPACE}proof`; -const DATA_INTEGRITY_PROOF = `${SECURITY_NAMESPACE}DataIntegrityProof`; -const SECURITY_VERIFICATION_METHOD = `${SECURITY_NAMESPACE}verificationMethod`; const PORTABLE_OBJECT_ID_PATTERN = /^ap(?:\+ef61)?:\/\//i; const FUNCTIONAL_PROOF_PROPERTIES = [ `${SECURITY_NAMESPACE}cryptosuite`, @@ -970,7 +1451,9 @@ export async function verifyPortableObjectProof( reason: { type: "invalidProof", proofIndex: 0 }, }; } - + const rawProofValues = isJsonLdNode(jsonLd) + ? await getRawProofValues(jsonLd, proofContextLoader) + : []; const proofs: DataIntegrityProof[] = []; for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { const proofValue = proofValues[proofIndex]; @@ -1043,6 +1526,12 @@ export async function verifyPortableObjectProof( } const keys: Multikey[] = []; + const rawProofCandidates = await parseRawProofCandidates( + jsonLd as Record, + rawProofValues, + options, + proofContextLoader, + ); const messageDigestCache: ProofMessageDigestCache = { proofContextLoader }; for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { const key = await verifyProofWithMessageDigestCache( @@ -1050,6 +1539,7 @@ export async function verifyPortableObjectProof( proofs[proofIndex], options, messageDigestCache, + rawProofCandidates, ); if (key == null) { return { @@ -1097,8 +1587,31 @@ export async function verifyObject( if (object instanceof Activity) { for (const uri of object.actorIds) attributions.add(uri.href); } + const rawProofValues = isJsonLdNode(jsonLd) + ? await getRawProofValues( + jsonLd, + options.contextLoader ?? preloadedOnlyDocumentLoader, + ) + : []; + const rawProofCandidates = isJsonLdNode(jsonLd) + ? await parseRawProofCandidates( + jsonLd, + rawProofValues, + options, + options.contextLoader ?? preloadedOnlyDocumentLoader, + ) + : []; for await (const proof of object.getProofs(options)) { - const key = await verifyProof(jsonLd, proof, options); + const key = await verifyProofWithMessageDigestCache( + jsonLd, + proof, + options, + { + proofContextLoader: options.contextLoader ?? + preloadedOnlyDocumentLoader, + }, + rawProofCandidates, + ); if (key === null) return null; if (proof.verificationMethodId == null) return null; if (key.controllerId == null) { From 0c10e0d8988342f8c6ae410ae0ef7105d9bd57c5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 27 Jul 2026 23:01:46 +0900 Subject: [PATCH 12/12] Correlate duplicate proof configurations Bind each parsed proof to one raw occurrence so duplicate typed projections cannot reuse a configuration that omits appended proof options. Compare normalized configurations when occurrence identity is ambiguous. Retain fetched raw documents for remote proof references and canonicalize portable reference URLs. This preserves valid remote proofs while still rejecting tampered duplicates. https://github.com/fedify-dev/fedify/pull/968#pullrequestreview-4786682756 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.6-sol --- packages/fedify/src/sig/proof.test.ts | 179 ++++++++++++++++++++++ packages/fedify/src/sig/proof.ts | 213 +++++++++++++++++++++----- 2 files changed, 357 insertions(+), 35 deletions(-) diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index e1b6b5249..e45862881 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -19,6 +19,7 @@ import { encodeMultibase, exportDidKey, exportMultibaseKey, + formatIri, importMultibaseKey, parseIri, } from "@fedify/vocab-runtime"; @@ -852,6 +853,47 @@ test("verifyProof() authenticates the complete proof configuration", async (t) = } }); + await t.step( + "distinguishes equivalent and ambiguous raw proof configurations", + async () => { + const signed = await signPortableJsonLd(jsonLd); + const proof = signed.proof as Record; + const parsed = await parsePortableProof(signed); + const identicalDuplicates = { + ...signed, + proof: [proof, structuredClone(proof)], + }; + assert( + await verifyProof(identicalDuplicates, parsed, options) != null, + ); + + const inheritedContextProof = structuredClone(proof); + delete inheritedContextProof["@context"]; + const equivalentDuplicates = { + ...signed, + proof: [proof, inheritedContextProof], + }; + assert( + await verifyProof(equivalentDuplicates, parsed, options) != null, + ); + + const ambiguousDuplicates = { + ...signed, + proof: [ + proof, + { + ...structuredClone(proof), + expires: "3000-01-01T00:00:00Z", + }, + ], + }; + assertEquals( + await verifyProof(ambiguousDuplicates, parsed, options), + null, + ); + }, + ); + await t.step( "accepts literal proof fields with an unresolved extra context", async () => { @@ -1918,6 +1960,22 @@ test("verifyPortableObjectProof()", async (t) => { ]); }); + await t.step("rejects a tampered duplicate proof", async () => { + const signed = await signPortableJsonLd(unsignedObject); + const proof = signed.proof as Record; + const result = await verifyPortableObjectProof({ + ...signed, + proof: [ + proof, + { + ...structuredClone(proof), + expires: "3000-01-01T00:00:00Z", + }, + ], + }, options); + assertFalse(result.verified); + }); + await t.step("matches proofs across aliased properties", async () => { const document = { ...unsignedObject, @@ -2233,6 +2291,127 @@ test("verifyObject() accepts did:key proofs for matching portable attribution or assertInstanceOf(verified, Note); assertEquals(verified.content, "Portable note"); + + assert( + typeof jsonLd === "object" && jsonLd != null && !Array.isArray(jsonLd), + ); + const rawProof = (jsonLd as Record).proof; + assert( + typeof rawProof === "object" && rawProof != null && + !Array.isArray(rawProof), + ); + assertEquals( + await verifyObject(Note, { + ...jsonLd as Record, + proof: [ + rawProof, + { + ...structuredClone(rawProof), + expires: "3000-01-01T00:00:00Z", + }, + ], + }, { + documentLoader() { + throw new TypeError("did:key must not use the document loader"); + }, + contextLoader: mockDocumentLoader, + }), + null, + ); + + async function verifyRemotelyReferencedProof( + proofOptions: Record = {}, + proofUrl = "https://proof.example/proofs/1", + ): Promise { + const remotelyReferencedJsonLd = { + ...jsonLd as Record, + }; + delete remotelyReferencedJsonLd.proof; + remotelyReferencedJsonLd["https://w3id.org/security#proof"] = [ + { "@graph": [rawProof] }, + { "@graph": [{ "@id": proofUrl }] }, + ]; + let proofFetches = 0; + const result = await verifyObject(Note, remotelyReferencedJsonLd, { + documentLoader(url) { + if (url !== formatIri(parseIri(proofUrl))) { + throw new TypeError(`unexpected document URL: ${url}`); + } + proofFetches++; + return Promise.resolve({ + contextUrl: null, + document: { + "@context": (jsonLd as Record)["@context"], + ...structuredClone(rawProof as Record), + ...proofOptions, + }, + documentUrl: url, + }); + }, + contextLoader: mockDocumentLoader, + }); + assertEquals(proofFetches, 1); + return result; + } + + assertInstanceOf( + await verifyRemotelyReferencedProof(), + Note, + ); + assertInstanceOf( + await verifyRemotelyReferencedProof( + {}, + `ap://did:key:${did.substring("did:key:".length)}/proofs/1`, + ), + Note, + ); + assertEquals( + await verifyRemotelyReferencedProof({ + expires: "3000-01-01T00:00:00Z", + }), + null, + ); +}); + +test("verifyObject() hydrates pending proof references", async () => { + const did = await exportDidKey(ed25519PublicKey.publicKey); + const method = did.substring("did:key:".length); + const keyId = new URL(`${did}#${method}`); + const proofUrl = `ap://did:key:${method}/proofs/1`; + const signed = await signPortableJsonLd({ + "@context": portableContext, + id: `ap://did:key:${method}/objects/1`, + type: "Note", + attributedTo: `ap://did:key:${method}/actor`, + content: "Portable note with a referenced proof", + }, { + verificationMethod: keyId, + proofOptions: { id: proofUrl }, + }); + const rawProof = signed.proof as Record; + const remotelyReferencedJsonLd = { ...signed }; + delete remotelyReferencedJsonLd.proof; + remotelyReferencedJsonLd["https://w3id.org/security#proof"] = [ + { "@graph": [rawProof] }, + { "@graph": [{ "@id": proofUrl }] }, + ]; + + let proofFetches = 0; + const verified = await verifyObject(Note, remotelyReferencedJsonLd, { + documentLoader(url) { + assertEquals(url, formatIri(parseIri(proofUrl))); + proofFetches++; + return Promise.resolve({ + contextUrl: null, + document: structuredClone(rawProof), + documentUrl: url, + }); + }, + contextLoader: mockDocumentLoader, + }); + + assertEquals(proofFetches, 1); + assertInstanceOf(verified, Note); }); test("verifyObject() accepts multiple portable attributions from the same did:key origin", async () => { diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index c97ef5341..64fd53217 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -7,6 +7,8 @@ import { } from "@fedify/vocab"; import { type DocumentLoader, + formatIri, + getDocumentLoader, getFe34Origin, haveSameFe34Origin, parseIri, @@ -420,13 +422,16 @@ export type VerifyPortableObjectProofResult = /** * Verifies the given proof for the object. - * @param jsonLd The JSON-LD object to verify the proof for. If it contains - * any proofs, they will be ignored. + * @param jsonLd The JSON-LD object to verify the proof for. Its proof + * properties are excluded from the message, but matching proof + * occurrences are used to authenticate the received proof + * configuration. * @param proof The proof to verify. * @param options Additional options. See also {@link VerifyProofOptions}. * @returns The public key that was used to sign the proof, or `null` if the * proof is invalid. * @since 0.10.0 + * @since 2.4.0 Matching proof configurations are authenticated. */ export async function verifyProof( jsonLd: unknown, @@ -441,7 +446,7 @@ async function verifyProofWithMessageDigestCache( proof: DataIntegrityProof, options: VerifyProofOptions, messageDigestCache: ProofMessageDigestCache = {}, - rawProofCandidates?: readonly RawProofCandidate[], + rawProofCandidate?: RawProofCandidate, ): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); @@ -482,7 +487,7 @@ async function verifyProofWithMessageDigestCache( proof, options, messageDigestCache, - rawProofCandidates, + rawProofCandidate, ); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); else verified = true; @@ -732,6 +737,23 @@ function sameProof( interface RawProofCandidate { readonly value: unknown; readonly proof: DataIntegrityProof | null; + readonly reference?: string; +} + +function normalizeDocumentUrl(url: string): string { + try { + return formatIri(parseIri(url)); + } catch { + return URL.canParse(url) ? new URL(url).href : url; + } +} + +function getRawProofReference(value: unknown): string | undefined { + const node = Array.isArray(value) && value.length === 1 ? value[0] : value; + if (typeof node === "string") return normalizeDocumentUrl(node); + if (!isJsonLdNode(node)) return undefined; + const id = node["@id"] ?? node.id; + return typeof id === "string" ? normalizeDocumentUrl(id) : undefined; } async function parseRawProofCandidates( @@ -757,31 +779,79 @@ async function parseRawProofCandidates( // Malformed sibling proofs cannot match a typed proof. } } - candidates.push({ value, proof: parsed }); + candidates.push({ + value, + proof: parsed, + reference: getRawProofReference(value) ?? parsed?.id?.href, + }); } return candidates; } -async function findRawProofValue( +async function findRawProofCandidate( jsonLd: Record, proof: DataIntegrityProof, options: VerifyProofOptions, documentLoader: DocumentLoader, - rawProofCandidates?: readonly RawProofCandidate[], -): Promise { - const candidates = rawProofCandidates ?? - await parseRawProofCandidates( - jsonLd, - await getRawProofValues(jsonLd, documentLoader), - options, - documentLoader, - ); +): Promise { + const candidates = await parseRawProofCandidates( + jsonLd, + await getRawProofValues(jsonLd, documentLoader), + options, + documentLoader, + ); + const matches: RawProofCandidate[] = []; for (const candidate of candidates) { if ( candidate.proof != null && sameProof(candidate.proof, proof) ) { - return candidate.value; + matches.push(candidate); + } + } + if (matches.length < 1) return undefined; + const first = matches[0]; + // A standalone verifyProof() call cannot know which received occurrence + // produced the lossy typed proof. Compare the signed configurations after + // inheriting the document context and removing proofValue so equivalent + // JSON-LD representations remain interchangeable. + const configurations = await Promise.all( + matches.map((candidate) => + normalizeProofConfiguration( + candidate.value, + jsonLd["@context"], + documentLoader, + ) + ), + ); + const firstConfiguration = configurations[0]; + return firstConfiguration != null && + configurations.every((configuration) => + configuration != null && + equalJsonValues(configuration.value, firstConfiguration.value) + ) + ? first + : null; +} + +interface RawProofCandidatePool { + readonly candidates: readonly RawProofCandidate[]; + readonly used: Set; +} + +function takeRawProofCandidate( + pool: RawProofCandidatePool, + proof: DataIntegrityProof, +): RawProofCandidate | undefined { + for (let index = 0; index < pool.candidates.length; index++) { + if (pool.used.has(index)) continue; + const candidate = pool.candidates[index]; + if ( + candidate.proof != null && + sameProof(candidate.proof, proof) + ) { + pool.used.add(index); + return candidate; } } return undefined; @@ -869,15 +939,21 @@ async function createProofConfiguration( proof: DataIntegrityProof, options: VerifyProofOptions, documentLoader: DocumentLoader, - rawProofCandidates?: readonly RawProofCandidate[], + rawProofCandidate?: RawProofCandidate, ): Promise { - let rawProof = await findRawProofValue( - jsonLd, - proof, - options, - documentLoader, - rawProofCandidates, - ); + let rawProof: unknown; + if (rawProofCandidate == null) { + const match = await findRawProofCandidate( + jsonLd, + proof, + options, + documentLoader, + ); + if (match === null) return null; + rawProof = match?.value; + } else { + rawProof = rawProofCandidate.value; + } if (rawProof == null) { const serializedProof = await proof.toJsonLd(); if (hasAdditionalProofOptions(serializedProof)) { @@ -1080,7 +1156,7 @@ async function verifyProofInternal( proof: DataIntegrityProof, options: VerifyProofOptions, messageDigestCache: ProofMessageDigestCache, - rawProofCandidates?: readonly RawProofCandidate[], + rawProofCandidate?: RawProofCandidate, ): Promise { if ( !isJsonLdNode(jsonLd) || @@ -1097,7 +1173,7 @@ async function verifyProofInternal( proof, options, proofContextLoader, - rawProofCandidates, + rawProofCandidate, ); if ( proofConfiguration == null || @@ -1174,7 +1250,7 @@ async function verifyProofInternal( }, }, messageDigestCache, - rawProofCandidates, + rawProofCandidate, ); } logger.debug( @@ -1240,7 +1316,7 @@ async function verifyProofInternal( }, }, messageDigestCache, - rawProofCandidates, + rawProofCandidate, ); } logger.debug( @@ -1532,14 +1608,28 @@ export async function verifyPortableObjectProof( options, proofContextLoader, ); + const rawProofCandidatePool: RawProofCandidatePool = { + candidates: rawProofCandidates, + used: new Set(), + }; const messageDigestCache: ProofMessageDigestCache = { proofContextLoader }; for (let proofIndex = 0; proofIndex < proofs.length; proofIndex++) { + const rawProofCandidate = takeRawProofCandidate( + rawProofCandidatePool, + proofs[proofIndex], + ); + if (rawProofCandidate == null) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } const key = await verifyProofWithMessageDigestCache( jsonLd, proofs[proofIndex], options, messageDigestCache, - rawProofCandidates, + rawProofCandidate, ); if (key == null) { return { @@ -1583,6 +1673,8 @@ export async function verifyObject( ): Promise { const logger = getLogger(["fedify", "sig", "proof"]); const object = await cls.fromJsonLd(jsonLd, options); + const defaultDocumentLoader = getDocumentLoader(); + const proofContextLoader = options.contextLoader ?? defaultDocumentLoader; const attributions = new Set(object.attributionIds.map((uri) => uri.href)); if (object instanceof Activity) { for (const uri of object.actorIds) attributions.add(uri.href); @@ -1590,7 +1682,7 @@ export async function verifyObject( const rawProofValues = isJsonLdNode(jsonLd) ? await getRawProofValues( jsonLd, - options.contextLoader ?? preloadedOnlyDocumentLoader, + proofContextLoader, ) : []; const rawProofCandidates = isJsonLdNode(jsonLd) @@ -1598,19 +1690,70 @@ export async function verifyObject( jsonLd, rawProofValues, options, - options.contextLoader ?? preloadedOnlyDocumentLoader, + proofContextLoader, ) : []; - for await (const proof of object.getProofs(options)) { + const rawProofCandidatePool: RawProofCandidatePool = { + candidates: rawProofCandidates, + used: new Set(), + }; + const baseDocumentLoader = options.documentLoader ?? defaultDocumentLoader; + const hydratedCandidates = new Set(); + const proofDocumentLoader: DocumentLoader = async ( + url, + loaderOptions, + ) => { + const remoteDocument = await baseDocumentLoader(url, loaderOptions); + const reference = normalizeDocumentUrl(url); + const candidateIndex = rawProofCandidates.findIndex( + (candidate, index) => + !hydratedCandidates.has(index) && + !rawProofCandidatePool.used.has(index) && + candidate.reference === reference, + ); + if (candidateIndex >= 0) { + hydratedCandidates.add(candidateIndex); + let parsed: DataIntegrityProof | null = null; + try { + parsed = await DataIntegrityProof.fromJsonLd( + remoteDocument.document, + { + documentLoader: baseDocumentLoader, + contextLoader: proofContextLoader, + tracerProvider: options.tracerProvider, + baseUrl: parseIri(remoteDocument.documentUrl), + }, + ); + } catch { + // The vocabulary parser will report the same malformed remote proof. + } + rawProofCandidates[candidateIndex] = { + value: structuredClone(remoteDocument.document), + proof: parsed, + reference, + }; + } + return remoteDocument; + }; + for await ( + const proof of object.getProofs({ + ...options, + documentLoader: proofDocumentLoader, + }) + ) { + const rawProofCandidate = takeRawProofCandidate( + rawProofCandidatePool, + proof, + ); + if (rawProofCandidate == null) return null; const key = await verifyProofWithMessageDigestCache( jsonLd, proof, options, { - proofContextLoader: options.contextLoader ?? - preloadedOnlyDocumentLoader, + proofContextLoader, }, - rawProofCandidates, + rawProofCandidate, ); if (key === null) return null; if (proof.verificationMethodId == null) return null;