diff --git a/CHANGES.md b/CHANGES.md index 4a3a5fc69..71eda0573 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,20 @@ 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 + 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 +106,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 +122,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 +130,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..c0a681211 100644 --- a/docs/manual/send.md +++ b/docs/manual/send.md @@ -1094,6 +1094,51 @@ 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 +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..e45862881 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -16,8 +16,10 @@ import { } from "@fedify/vocab"; import { decodeMultibase, + encodeMultibase, exportDidKey, exportMultibaseKey, + formatIri, importMultibaseKey, parseIri, } from "@fedify/vocab-runtime"; @@ -30,6 +32,7 @@ import { assertRejects, } from "@std/assert"; import { decodeHex } from "byte-encodings/hex"; +import serialize from "json-canon"; import { ed25519Multikey, ed25519PrivateKey, @@ -44,6 +47,7 @@ import { signObject, verifyObject, type VerifyObjectOptions, + verifyPortableObjectProof, verifyProof, type VerifyProofOptions, } from "./proof.ts"; @@ -68,6 +72,91 @@ 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, + proofOptions = {}, + }: { + privateKey?: CryptoKey; + verificationMethod?: URL; + proofOptions?: Record; + } = {}, +): Promise> { + const proofConfig = { + "@context": document["@context"], + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: verificationMethod.href, + proofPurpose: "assertionMethod", + created: portableProofCreated, + ...proofOptions, + }; + 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)), + ), + }, + }; +} + +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"), @@ -574,6 +663,309 @@ 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( + "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 () => { + 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, @@ -893,12 +1285,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, @@ -964,6 +1358,848 @@ 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( + "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"; + for ( + const { context, proofProperty, typeProperty, type } of [ + { + context: { [proofAlias]: proofIri }, + proofProperty: proofAlias, + typeProperty: "type", + type: "Note", + }, + { + context: { + PortableNote: { + "@id": "https://www.w3.org/ns/activitystreams#Note", + "@context": { [proofAlias]: proofIri }, + }, + }, + proofProperty: proofAlias, + typeProperty: "type", + type: "PortableNote", + }, + { + context: { + kind: "@type", + PortableNote: { + "@id": "https://www.w3.org/ns/activitystreams#Note", + "@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 = { + ...unsignedObject, + "@context": [...portableContext, context], + }; + delete document.type; + document[typeProperty] = type; + const { proof, ...signed } = await signPortableJsonLd(document); + const result = await verifyPortableObjectProof({ + ...signed, + [proofProperty]: proof, + }, options); + assert(result.verified); + assertEquals(result.keys.length, 1); + assertEquals(result.keys[0].id, portableKeyId); + } + }); + + 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 [ + { + "@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( + "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, + 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( + "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( + "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( + "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" }; + 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("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, + "@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 () => { + 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, @@ -1055,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 12175dd68..64fd53217 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -5,7 +5,16 @@ import { Multikey, type Object, } from "@fedify/vocab"; -import { type DocumentLoader, haveSameFe34Origin } from "@fedify/vocab-runtime"; +import { + type DocumentLoader, + formatIri, + getDocumentLoader, + getFe34Origin, + haveSameFe34Origin, + parseIri, + type RemoteDocument, +} from "@fedify/vocab-runtime"; +import jsonld from "@fedify/vocab-runtime/jsonld"; import { getLogger } from "@logtape/logtape"; import { type MeterProvider, @@ -31,6 +40,7 @@ import { type KeyCache, validateCryptoKey, } from "./key.ts"; +import { getNormalizationContextLoader } from "./ld.ts"; /** * Known Object Integrity Proof `cryptosuite` values, used to keep @@ -45,6 +55,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, @@ -278,6 +298,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. */ @@ -309,20 +343,110 @@ 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 - * 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, proof: DataIntegrityProof, options: VerifyProofOptions = {}, +): Promise { + return await verifyProofWithMessageDigestCache(jsonLd, proof, options); +} + +async function verifyProofWithMessageDigestCache( + jsonLd: unknown, + proof: DataIntegrityProof, + options: VerifyProofOptions, + messageDigestCache: ProofMessageDigestCache = {}, + rawProofCandidate?: RawProofCandidate, ): Promise { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); @@ -358,7 +482,13 @@ export async function verifyProof( } } try { - const key = await verifyProofInternal(jsonLd, proof, options); + const key = await verifyProofInternal( + jsonLd, + proof, + options, + messageDigestCache, + rawProofCandidate, + ); if (key == null) span.setStatus({ code: SpanStatusCode.ERROR }); else verified = true; return key; @@ -388,21 +518,673 @@ export async function verifyProof( ); } +interface ProofMessageDigests { + readonly onWire: ArrayBuffer; + readonly normalized: () => Promise; +} + +interface ProofMessageDigestCache { + values?: Map>; + proofContextLoader?: DocumentLoader; +} + +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 getJsonLdPropertyNames( + jsonLd: Record, + propertyIri: string, + defaults: readonly string[], + documentLoader: DocumentLoader = preloadedOnlyDocumentLoader, + 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, + context, + options, + ); + const typeScopedContext = activeContext; + for (const key of globalThis.Object.keys(jsonLd).sort()) { + if ( + key !== "@type" && + expandContextPropertyIri(activeContext, key) !== "@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 (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; + // 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, proofContextLoader) + ) { + 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)); + 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; + }, + }; +} + +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; + 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( + 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, + reference: getRawProofReference(value) ?? parsed?.id?.href, + }); + } + return candidates; +} + +async function findRawProofCandidate( + jsonLd: Record, + proof: DataIntegrityProof, + options: VerifyProofOptions, + documentLoader: 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) + ) { + 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; +} + +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, + rawProofCandidate?: RawProofCandidate, +): Promise { + 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)) { + 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, + rawProofCandidate?: RawProofCandidate, ): Promise { if ( - typeof jsonLd !== "object" || - jsonLd == null || - Array.isArray(jsonLd) || + !isJsonLdNode(jsonLd) || proof.cryptosuite !== "eddsa-jcs-2022" || proof.verificationMethodId == null || proof.proofPurpose !== "assertionMethod" || proof.proofValue == null || proof.created == null ) return null; + const proofContextLoader = messageDigestCache.proofContextLoader ?? + preloadedOnlyDocumentLoader; + const proofConfiguration = await createProofConfiguration( + jsonLd, + proof, + options, + proofContextLoader, + rawProofCandidate, + ); + 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 @@ -413,28 +1195,9 @@ 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); - 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 +1235,23 @@ 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, + rawProofCandidate, + ); } logger.debug( "The fetched key (verificationMethod) for the proof is not a valid " + @@ -498,9 +1267,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 +1279,21 @@ 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, - ); - if (normalized !== msg && await verifyCandidate(normalized)) { + 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)) { return publicKey; } if (fetchedKey.cached) { @@ -531,13 +1305,19 @@ 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, + rawProofCandidate, + ); } logger.debug( "Failed to verify the proof with the fetched key {keyId}:\n{proof}", @@ -546,6 +1326,322 @@ async function verifyProofInternal( return null; } +type Fep2277CoreType = + | "actor" + | "activity" + | "collection" + | "verificationMethod" + | "publicKey" + | "link" + | "object"; + +const AS_NAMESPACE = "https://www.w3.org/ns/activitystreams#"; +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 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 hasValidPortableProofShape(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 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( + 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<{ + 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: recordingLoader, + keepFreeFloatingNodes: true, + }); + if (expanded.length !== 1 || !isJsonLdNode(expanded[0])) { + throw new TypeError("Expected a single JSON-LD object."); + } + 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); + }, + }; +} + +/** + * 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 { + 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, + ); + const id = root["@id"]; + if ( + typeof id !== "string" || + !PORTABLE_OBJECT_ID_PATTERN.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 rawProofValues = isJsonLdNode(jsonLd) + ? await getRawProofValues(jsonLd, proofContextLoader) + : []; + const proofs: DataIntegrityProof[] = []; + for (let proofIndex = 0; proofIndex < proofValues.length; proofIndex++) { + const proofValue = proofValues[proofIndex]; + if (!hasValidPortableProofShape(proofValue)) { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } + let proof: DataIntegrityProof; + try { + proof = await DataIntegrityProof.fromJsonLd( + proofValue, + options, + ); + } catch { + return { + verified: false, + reason: { type: "invalidProof", proofIndex }, + }; + } + proofs.push(proof); + } + + // 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) { + 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 rawProofCandidates = await parseRawProofCandidates( + jsonLd as Record, + rawProofValues, + 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, + rawProofCandidate, + ); + 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 @@ -577,12 +1673,88 @@ 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); } - for await (const proof of object.getProofs(options)) { - const key = await verifyProof(jsonLd, proof, options); + const rawProofValues = isJsonLdNode(jsonLd) + ? await getRawProofValues( + jsonLd, + proofContextLoader, + ) + : []; + const rawProofCandidates = isJsonLdNode(jsonLd) + ? await parseRawProofCandidates( + jsonLd, + rawProofValues, + options, + proofContextLoader, + ) + : []; + 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, + }, + rawProofCandidate, + ); if (key === null) return null; if (proof.verificationMethodId == null) return null; if (key.controllerId == null) {