Loading CHANGES.md +14 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,20 @@ Version 1.0.0 To be released. - Fedify now supports [Linked Data Signatures], which is outdated but still widely used in the fediverse. - Added `verifySignature()` function. - Added `VerifySignatureOptions` interface. - Added `detachSignature()` function. - Added more log messages using the [LogTape] library. Currently the below logger categories are used: - `["fedify", "sig", "ld"]` [Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ Version 0.15.0 -------------- Loading src/runtime/contexts.ts +153 −0 Original line number Diff line number Diff line Loading @@ -630,6 +630,159 @@ const preloadedContexts: Record<string, unknown> = { }, }, }, "https://w3id.org/identity/v1": { "@context": { "id": "@id", "type": "@type", "cred": "https://w3id.org/credentials#", "dc": "http://purl.org/dc/terms/", "identity": "https://w3id.org/identity#", "perm": "https://w3id.org/permissions#", "ps": "https://w3id.org/payswarm#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "sec": "https://w3id.org/security#", "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", "Group": "https://www.w3.org/ns/activitystreams#Group", "claim": { "@id": "cred:claim", "@type": "@id", }, "credential": { "@id": "cred:credential", "@type": "@id", }, "issued": { "@id": "cred:issued", "@type": "xsd:dateTime", }, "issuer": { "@id": "cred:issuer", "@type": "@id", }, "recipient": { "@id": "cred:recipient", "@type": "@id", }, "Credential": "cred:Credential", "CryptographicKeyCredential": "cred:CryptographicKeyCredential", "about": { "@id": "schema:about", "@type": "@id", }, "address": { "@id": "schema:address", "@type": "@id", }, "addressCountry": "schema:addressCountry", "addressLocality": "schema:addressLocality", "addressRegion": "schema:addressRegion", "comment": "rdfs:comment", "created": { "@id": "dc:created", "@type": "xsd:dateTime", }, "creator": { "@id": "dc:creator", "@type": "@id", }, "description": "schema:description", "email": "schema:email", "familyName": "schema:familyName", "givenName": "schema:givenName", "image": { "@id": "schema:image", "@type": "@id", }, "label": "rdfs:label", "name": "schema:name", "postalCode": "schema:postalCode", "streetAddress": "schema:streetAddress", "title": "dc:title", "url": { "@id": "schema:url", "@type": "@id", }, "Person": "schema:Person", "PostalAddress": "schema:PostalAddress", "Organization": "schema:Organization", "identityService": { "@id": "identity:identityService", "@type": "@id", }, "idp": { "@id": "identity:idp", "@type": "@id", }, "Identity": "identity:Identity", "paymentProcessor": "ps:processor", "preferences": { "@id": "ps:preferences", "@type": "@vocab", }, "cipherAlgorithm": "sec:cipherAlgorithm", "cipherData": "sec:cipherData", "cipherKey": "sec:cipherKey", "digestAlgorithm": "sec:digestAlgorithm", "digestValue": "sec:digestValue", "domain": "sec:domain", "expires": { "@id": "sec:expiration", "@type": "xsd:dateTime", }, "initializationVector": "sec:initializationVector", "member": { "@id": "schema:member", "@type": "@id", }, "memberOf": { "@id": "schema:memberOf", "@type": "@id", }, "nonce": "sec:nonce", "normalizationAlgorithm": "sec:normalizationAlgorithm", "owner": { "@id": "sec:owner", "@type": "@id", }, "password": "sec:password", "privateKey": { "@id": "sec:privateKey", "@type": "@id", }, "privateKeyPem": "sec:privateKeyPem", "publicKey": { "@id": "sec:publicKey", "@type": "@id", }, "publicKeyPem": "sec:publicKeyPem", "publicKeyService": { "@id": "sec:publicKeyService", "@type": "@id", }, "revoked": { "@id": "sec:revoked", "@type": "xsd:dateTime", }, "signature": "sec:signature", "signatureAlgorithm": "sec:signatureAlgorithm", "signatureValue": "sec:signatureValue", "CryptographicKey": "sec:Key", "EncryptedMessage": "sec:EncryptedMessage", "GraphSignature2012": "sec:GraphSignature2012", "LinkedDataSignature2015": "sec:LinkedDataSignature2015", "accessControl": { "@id": "perm:accessControl", "@type": "@id", }, "writePermission": { "@id": "perm:writePermission", "@type": "@id", }, }, }, }; export default preloadedContexts; src/sig/http.ts +10 −1 Original line number Diff line number Diff line Loading @@ -162,7 +162,16 @@ export async function verifyRequest( for (let [algo, digestBase64] of digests) { algo = algo.trim().toLowerCase(); if (!(algo in supportedHashAlgorithms)) continue; const digest = decodeBase64(digestBase64); let digest: Uint8Array; try { digest = decodeBase64(digestBase64); } catch (error) { logger.debug("Failed to verify; invalid base64 encoding: {digest}.", { digest: digestBase64, error, }); return null; } const expectedDigest = await crypto.subtle.digest( supportedHashAlgorithms[algo], body, Loading src/sig/ld.test.ts 0 → 100644 +177 −0 Original line number Diff line number Diff line import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/assert-equals"; import { encodeBase64 } from "@std/encoding/base64"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { test } from "../testing/mod.ts"; import { CryptographicKey } from "../vocab/vocab.ts"; import { generateCryptoKeyPair } from "./key.ts"; import { detachSignature, verifyJsonLd, verifySignature } from "./ld.ts"; const document = { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount", }, ], "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/activity", "type": "Create", "actor": "https://activitypub.academy/users/brauca_darradiul", "published": "2024-09-12T16:50:45Z", "to": [ "https://www.w3.org/ns/activitystreams#Public", ], "cc": [ "https://activitypub.academy/users/brauca_darradiul/followers", ], "object": { "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678", "type": "Note", "summary": null, "inReplyTo": null, "published": "2024-09-12T16:50:45Z", "url": "https://activitypub.academy/@brauca_darradiul/113125611605598678", "attributedTo": "https://activitypub.academy/users/brauca_darradiul", "to": [ "https://www.w3.org/ns/activitystreams#Public", ], "cc": [ "https://activitypub.academy/users/brauca_darradiul/followers", ], "sensitive": false, "atomUri": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678", "inReplyToAtomUri": null, "conversation": "tag:activitypub.academy,2024-09-12:objectId=187606:objectType=Conversation", "content": "<p>Test</p>", "contentMap": { "en": "<p>Test</p>", }, "attachment": [], "tag": [], "replies": { "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies?only_other_accounts=true&page=true", "partOf": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies", "items": [], }, }, }, }; const signature = { "type": "RsaSignature2017", "creator": "https://activitypub.academy/users/brauca_darradiul#main-key", "created": "2024-09-12T16:50:46Z", "signatureValue": "osp9n4Pubp8XFvBi0iwrpCjDkIpuuUr2klp+r8Jp289ISqRNlUPeHVvNrQSE2vqNm4j/cJGuQruIqZPTAmTjjB3HtqgawoAG11DA7OPpY6mJLruKnbqadV1cy5V0DJI9CRJXEBuEmMTJRO9gi1cyzlM4QxK30YrjmtQNLoU9th97da4lumsl+a5cAue38MDuJZvLWDOTZ1EGixwhLP8FevdnZ+jqwctGu9KrgDImBIpBkQaqHFTTGrbE7FlXsj1pneOUQTuRDa9zlk2DmgXeEBWN2OJZDjgJ4iBsF2JHtCn6PccKbuI9s2VLhnobPtLB8YdHYKqIPLmv0UOjAM8XrQ==", }; const testVector = { ...document, signature }; test("detachSignature()", () => { assertEquals(detachSignature(testVector), document); assertEquals(detachSignature(document), document); }); test("verifySignature()", async () => { const doc = { ...testVector }; const key = await verifySignature(doc, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc, testVector); assertEquals( key, await CryptographicKey.fromJsonLd({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], id: "https://activitypub.academy/users/brauca_darradiul#main-key", owner: "https://activitypub.academy/users/brauca_darradiul", publicKeyPem: "-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5W/9rYXddIjKo9Ury/LK XqQYbj0cOx/c+T1uRHJzced8JbvXdiBCNZXVVrIaygy3G/MOvxMW4kbA1bqeiSYY V9TXBMI6gVVDnl5VG64uGxswcvUWqQU5Q1mwuwyGCPhexAq3BKe/7uH64AZgx11e KLl3W3WcIMKmunYn8+z6hm0003hMensXMNpMVfqLoXaeuro7pYnwOSWoHFS3AxWK llMwAoa5waulgai8gD7/uA5Y9Hvguk/OBYBh9YnIX5N5jScsmY/EYuesNIH2Ct9s E3aVkTjZUt55JtXnk8Q9eTnrcB/98RtLWH4pJTKJhzxv19i3aZT3yDApPk0Q/biI JQIDAQAB -----END PUBLIC KEY----- ", }), ); // Test invalid signature (wrong base64): const doc2 = { ...testVector, signature: { ...testVector.signature, signatureValue: "!" }, }; const key2 = await verifySignature(doc2, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc2, { ...testVector, signature: { ...testVector.signature, signatureValue: "!" }, }); assertEquals(key2, null); // Test incorrect signature: const incorrectSig = encodeBase64(new Uint8Array([1, 2, 3, 4])); const doc3 = { ...testVector, signature: { ...testVector.signature, signatureValue: incorrectSig }, }; const key3 = await verifySignature(doc3, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc3, { ...testVector, signature: { ...testVector.signature, signatureValue: incorrectSig }, }); assertEquals(key3, null); // Test outdated key cache: const doc4 = { ...testVector }; const key4 = await verifySignature(doc4, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, keyCache: { async get(keyId: URL) { return new CryptographicKey({ id: keyId, owner: new URL("https://activitypub.academy/users/brauca_darradiul"), publicKey: (await generateCryptoKeyPair("RSASSA-PKCS1-v1_5")).publicKey, }); }, set(_keyId: URL, _key: CryptographicKey) { return Promise.resolve(); }, }, }); assertEquals(doc4, testVector); assertEquals(key4, key); }); test("verifyJsonLd()", async () => { const verified = await verifyJsonLd(testVector, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assert(verified); // TODO: Test a correctly signed document, but with a different key. }); // cSpell: ignore ostatus src/sig/ld.ts 0 → 100644 +216 −0 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { decodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import jsonld from "jsonld"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts"; import { fetchKey, type KeyCache } from "./key.ts"; const logger = getLogger(["fedify", "sig", "ld"]); interface SignedJsonLd { signature: { type: "RsaSignature2017"; creator: string; created: string; signatureValue: string; }; } function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd { if (typeof jsonLd !== "object" || jsonLd == null) return false; if ("signature" in jsonLd) { const signature = jsonLd.signature; if (typeof signature !== "object" || signature == null) return false; return "type" in signature && signature.type === "RsaSignature2017" && "creator" in signature && typeof signature.creator === "string" && "created" in signature && typeof signature.created === "string" && "signatureValue" in signature && typeof signature.signatureValue === "string"; } return false; } /** * Detaches Linked Data Signatures from the given JSON-LD document. * @param jsonLd The JSON-LD document to modify. * @returns The modified JSON-LD document. If the input document does not * contain a signature, the original document is returned. * @since 1.0.0 */ export function detachSignature(jsonLd: unknown): unknown { if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd; const doc: { signature?: unknown } = { ...jsonLd }; delete doc.signature; return doc; } /** * Options for verifying Linked Data Signatures. * @since 1.0.0 */ export interface VerifySignatureOptions { /** * The document loader to use for fetching the public key. */ documentLoader?: DocumentLoader; /** * The context loader to use for JSON-LD context retrieval. */ contextLoader?: DocumentLoader; /** * The key cache to use for caching public keys. */ keyCache?: KeyCache; } /** * Verifies Linked Data Signatures of the given JSON-LD document. * @param jsonLd The JSON-LD document to verify. * @param options Options for verifying the signature. * @returns The public key that signed the document or `null` if the signature * is invalid or the key is not found. * @since 1.0.0 */ export async function verifySignature( jsonLd: unknown, options: VerifySignatureOptions = {}, ): Promise<CryptographicKey | null> { if (!hasSignature(jsonLd)) return null; const sig = jsonLd.signature; let signature: Uint8Array; try { signature = decodeBase64(sig.signatureValue); } catch (error) { logger.debug( "Failed to verify; invalid base64 signatureValue: {signatureValue}", { ...sig, error }, ); return null; } const keyResult = await fetchKey( new URL(sig.creator), CryptographicKey, options, ); if (keyResult == null) return null; const { key, cached } = keyResult; const sigOpts: { "@context": string; type?: string; id?: string; signatureValue?: string; } = { ...sig, "@context": "https://w3id.org/identity/v1", }; delete sigOpts.type; delete sigOpts.id; delete sigOpts.signatureValue; const sigOptsHash = await hashJsonLd(sigOpts, options.contextLoader); const document: { signature?: unknown } = { ...jsonLd }; delete document.signature; const docHash = await hashJsonLd(document, options.contextLoader); const encoder = new TextEncoder(); const message = sigOptsHash + docHash; const messageBytes = encoder.encode(message); const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes, ); if (verified) return key; if (cached) { logger.debug( "Failed to verify with the cached key {keyId}; " + "signature {signatureValue} is invalid. " + "Retrying with the freshly fetched key...", { keyId: sig.creator, ...sig }, ); const keyResult = await fetchKey( new URL(sig.creator), CryptographicKey, { ...options, keyCache: undefined }, ); if (keyResult == null) return null; const { key } = keyResult; const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes, ); return verified ? key : null; } logger.debug( "Failed to verify with the fetched key {keyId}; " + "signature {signatureValue} is invalid. " + "Check if the key is correct or if the signed message is correct. " + "The message to sign is:\n{message}", { keyId: sig.creator, ...sig, message }, ); return null; } /** * Options for verifying JSON-LD documents. */ export interface VerifyJsonLdOptions extends VerifySignatureOptions { } /** * Verify the authenticity of the given JSON-LD document using Linked Data * Signatures. If the document is signed, this function verifies the signature * and checks if the document is attributed to the owner of the public key. * If the document is not signed, this function returns `false`. * @param jsonLd The JSON-LD document to verify. * @param options Options for verifying the document. * @returns `true` if the document is authentic; `false` otherwise. */ export async function verifyJsonLd( jsonLd: unknown, options: VerifyJsonLdOptions = {}, ): Promise<boolean> { const object = await Object.fromJsonLd(jsonLd, options); const attributions = new Set(object.attributionIds.map((uri) => uri.href)); if (object instanceof Activity) { for (const uri of object.actorIds) attributions.add(uri.href); } const key = await verifySignature(jsonLd, options); if (key == null) return false; if (key.ownerId == null) { logger.debug("Key {keyId} has no owner.", { keyId: key.id?.href }); return false; } attributions.delete(key.ownerId.href); if (attributions.size > 0) { logger.debug( "Some attributions are not authenticated by the Linked Data Signatures" + ": {attributions}.", { attributions: [...attributions] }, ); return false; } return true; } async function hashJsonLd( jsonLd: unknown, contextLoader: DocumentLoader | undefined, ): Promise<string> { const canon = await jsonld.canonize(jsonLd, { format: "application/n-quads", documentLoader: contextLoader ?? fetchDocumentLoader, }); const encoder = new TextEncoder(); const hash = await crypto.subtle.digest("SHA-256", encoder.encode(canon)); return encodeHex(hash); } // cSpell: ignore URGNA2012 Loading
CHANGES.md +14 −0 Original line number Diff line number Diff line Loading @@ -8,6 +8,20 @@ Version 1.0.0 To be released. - Fedify now supports [Linked Data Signatures], which is outdated but still widely used in the fediverse. - Added `verifySignature()` function. - Added `VerifySignatureOptions` interface. - Added `detachSignature()` function. - Added more log messages using the [LogTape] library. Currently the below logger categories are used: - `["fedify", "sig", "ld"]` [Linked Data Signatures]: https://web.archive.org/web/20170923124140/https://w3c-dvcg.github.io/ld-signatures/ Version 0.15.0 -------------- Loading
src/runtime/contexts.ts +153 −0 Original line number Diff line number Diff line Loading @@ -630,6 +630,159 @@ const preloadedContexts: Record<string, unknown> = { }, }, }, "https://w3id.org/identity/v1": { "@context": { "id": "@id", "type": "@type", "cred": "https://w3id.org/credentials#", "dc": "http://purl.org/dc/terms/", "identity": "https://w3id.org/identity#", "perm": "https://w3id.org/permissions#", "ps": "https://w3id.org/payswarm#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "sec": "https://w3id.org/security#", "schema": "http://schema.org/", "xsd": "http://www.w3.org/2001/XMLSchema#", "Group": "https://www.w3.org/ns/activitystreams#Group", "claim": { "@id": "cred:claim", "@type": "@id", }, "credential": { "@id": "cred:credential", "@type": "@id", }, "issued": { "@id": "cred:issued", "@type": "xsd:dateTime", }, "issuer": { "@id": "cred:issuer", "@type": "@id", }, "recipient": { "@id": "cred:recipient", "@type": "@id", }, "Credential": "cred:Credential", "CryptographicKeyCredential": "cred:CryptographicKeyCredential", "about": { "@id": "schema:about", "@type": "@id", }, "address": { "@id": "schema:address", "@type": "@id", }, "addressCountry": "schema:addressCountry", "addressLocality": "schema:addressLocality", "addressRegion": "schema:addressRegion", "comment": "rdfs:comment", "created": { "@id": "dc:created", "@type": "xsd:dateTime", }, "creator": { "@id": "dc:creator", "@type": "@id", }, "description": "schema:description", "email": "schema:email", "familyName": "schema:familyName", "givenName": "schema:givenName", "image": { "@id": "schema:image", "@type": "@id", }, "label": "rdfs:label", "name": "schema:name", "postalCode": "schema:postalCode", "streetAddress": "schema:streetAddress", "title": "dc:title", "url": { "@id": "schema:url", "@type": "@id", }, "Person": "schema:Person", "PostalAddress": "schema:PostalAddress", "Organization": "schema:Organization", "identityService": { "@id": "identity:identityService", "@type": "@id", }, "idp": { "@id": "identity:idp", "@type": "@id", }, "Identity": "identity:Identity", "paymentProcessor": "ps:processor", "preferences": { "@id": "ps:preferences", "@type": "@vocab", }, "cipherAlgorithm": "sec:cipherAlgorithm", "cipherData": "sec:cipherData", "cipherKey": "sec:cipherKey", "digestAlgorithm": "sec:digestAlgorithm", "digestValue": "sec:digestValue", "domain": "sec:domain", "expires": { "@id": "sec:expiration", "@type": "xsd:dateTime", }, "initializationVector": "sec:initializationVector", "member": { "@id": "schema:member", "@type": "@id", }, "memberOf": { "@id": "schema:memberOf", "@type": "@id", }, "nonce": "sec:nonce", "normalizationAlgorithm": "sec:normalizationAlgorithm", "owner": { "@id": "sec:owner", "@type": "@id", }, "password": "sec:password", "privateKey": { "@id": "sec:privateKey", "@type": "@id", }, "privateKeyPem": "sec:privateKeyPem", "publicKey": { "@id": "sec:publicKey", "@type": "@id", }, "publicKeyPem": "sec:publicKeyPem", "publicKeyService": { "@id": "sec:publicKeyService", "@type": "@id", }, "revoked": { "@id": "sec:revoked", "@type": "xsd:dateTime", }, "signature": "sec:signature", "signatureAlgorithm": "sec:signatureAlgorithm", "signatureValue": "sec:signatureValue", "CryptographicKey": "sec:Key", "EncryptedMessage": "sec:EncryptedMessage", "GraphSignature2012": "sec:GraphSignature2012", "LinkedDataSignature2015": "sec:LinkedDataSignature2015", "accessControl": { "@id": "perm:accessControl", "@type": "@id", }, "writePermission": { "@id": "perm:writePermission", "@type": "@id", }, }, }, }; export default preloadedContexts;
src/sig/http.ts +10 −1 Original line number Diff line number Diff line Loading @@ -162,7 +162,16 @@ export async function verifyRequest( for (let [algo, digestBase64] of digests) { algo = algo.trim().toLowerCase(); if (!(algo in supportedHashAlgorithms)) continue; const digest = decodeBase64(digestBase64); let digest: Uint8Array; try { digest = decodeBase64(digestBase64); } catch (error) { logger.debug("Failed to verify; invalid base64 encoding: {digest}.", { digest: digestBase64, error, }); return null; } const expectedDigest = await crypto.subtle.digest( supportedHashAlgorithms[algo], body, Loading
src/sig/ld.test.ts 0 → 100644 +177 −0 Original line number Diff line number Diff line import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/assert-equals"; import { encodeBase64 } from "@std/encoding/base64"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { test } from "../testing/mod.ts"; import { CryptographicKey } from "../vocab/vocab.ts"; import { generateCryptoKeyPair } from "./key.ts"; import { detachSignature, verifyJsonLd, verifySignature } from "./ld.ts"; const document = { "@context": [ "https://www.w3.org/ns/activitystreams", { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri", "inReplyToAtomUri": "ostatus:inReplyToAtomUri", "conversation": "ostatus:conversation", "sensitive": "as:sensitive", "toot": "http://joinmastodon.org/ns#", "votersCount": "toot:votersCount", }, ], "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/activity", "type": "Create", "actor": "https://activitypub.academy/users/brauca_darradiul", "published": "2024-09-12T16:50:45Z", "to": [ "https://www.w3.org/ns/activitystreams#Public", ], "cc": [ "https://activitypub.academy/users/brauca_darradiul/followers", ], "object": { "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678", "type": "Note", "summary": null, "inReplyTo": null, "published": "2024-09-12T16:50:45Z", "url": "https://activitypub.academy/@brauca_darradiul/113125611605598678", "attributedTo": "https://activitypub.academy/users/brauca_darradiul", "to": [ "https://www.w3.org/ns/activitystreams#Public", ], "cc": [ "https://activitypub.academy/users/brauca_darradiul/followers", ], "sensitive": false, "atomUri": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678", "inReplyToAtomUri": null, "conversation": "tag:activitypub.academy,2024-09-12:objectId=187606:objectType=Conversation", "content": "<p>Test</p>", "contentMap": { "en": "<p>Test</p>", }, "attachment": [], "tag": [], "replies": { "id": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies", "type": "Collection", "first": { "type": "CollectionPage", "next": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies?only_other_accounts=true&page=true", "partOf": "https://activitypub.academy/users/brauca_darradiul/statuses/113125611605598678/replies", "items": [], }, }, }, }; const signature = { "type": "RsaSignature2017", "creator": "https://activitypub.academy/users/brauca_darradiul#main-key", "created": "2024-09-12T16:50:46Z", "signatureValue": "osp9n4Pubp8XFvBi0iwrpCjDkIpuuUr2klp+r8Jp289ISqRNlUPeHVvNrQSE2vqNm4j/cJGuQruIqZPTAmTjjB3HtqgawoAG11DA7OPpY6mJLruKnbqadV1cy5V0DJI9CRJXEBuEmMTJRO9gi1cyzlM4QxK30YrjmtQNLoU9th97da4lumsl+a5cAue38MDuJZvLWDOTZ1EGixwhLP8FevdnZ+jqwctGu9KrgDImBIpBkQaqHFTTGrbE7FlXsj1pneOUQTuRDa9zlk2DmgXeEBWN2OJZDjgJ4iBsF2JHtCn6PccKbuI9s2VLhnobPtLB8YdHYKqIPLmv0UOjAM8XrQ==", }; const testVector = { ...document, signature }; test("detachSignature()", () => { assertEquals(detachSignature(testVector), document); assertEquals(detachSignature(document), document); }); test("verifySignature()", async () => { const doc = { ...testVector }; const key = await verifySignature(doc, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc, testVector); assertEquals( key, await CryptographicKey.fromJsonLd({ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", ], id: "https://activitypub.academy/users/brauca_darradiul#main-key", owner: "https://activitypub.academy/users/brauca_darradiul", publicKeyPem: "-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5W/9rYXddIjKo9Ury/LK XqQYbj0cOx/c+T1uRHJzced8JbvXdiBCNZXVVrIaygy3G/MOvxMW4kbA1bqeiSYY V9TXBMI6gVVDnl5VG64uGxswcvUWqQU5Q1mwuwyGCPhexAq3BKe/7uH64AZgx11e KLl3W3WcIMKmunYn8+z6hm0003hMensXMNpMVfqLoXaeuro7pYnwOSWoHFS3AxWK llMwAoa5waulgai8gD7/uA5Y9Hvguk/OBYBh9YnIX5N5jScsmY/EYuesNIH2Ct9s E3aVkTjZUt55JtXnk8Q9eTnrcB/98RtLWH4pJTKJhzxv19i3aZT3yDApPk0Q/biI JQIDAQAB -----END PUBLIC KEY----- ", }), ); // Test invalid signature (wrong base64): const doc2 = { ...testVector, signature: { ...testVector.signature, signatureValue: "!" }, }; const key2 = await verifySignature(doc2, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc2, { ...testVector, signature: { ...testVector.signature, signatureValue: "!" }, }); assertEquals(key2, null); // Test incorrect signature: const incorrectSig = encodeBase64(new Uint8Array([1, 2, 3, 4])); const doc3 = { ...testVector, signature: { ...testVector.signature, signatureValue: incorrectSig }, }; const key3 = await verifySignature(doc3, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(doc3, { ...testVector, signature: { ...testVector.signature, signatureValue: incorrectSig }, }); assertEquals(key3, null); // Test outdated key cache: const doc4 = { ...testVector }; const key4 = await verifySignature(doc4, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, keyCache: { async get(keyId: URL) { return new CryptographicKey({ id: keyId, owner: new URL("https://activitypub.academy/users/brauca_darradiul"), publicKey: (await generateCryptoKeyPair("RSASSA-PKCS1-v1_5")).publicKey, }); }, set(_keyId: URL, _key: CryptographicKey) { return Promise.resolve(); }, }, }); assertEquals(doc4, testVector); assertEquals(key4, key); }); test("verifyJsonLd()", async () => { const verified = await verifyJsonLd(testVector, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assert(verified); // TODO: Test a correctly signed document, but with a different key. }); // cSpell: ignore ostatus
src/sig/ld.ts 0 → 100644 +216 −0 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { decodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import jsonld from "jsonld"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts"; import { fetchKey, type KeyCache } from "./key.ts"; const logger = getLogger(["fedify", "sig", "ld"]); interface SignedJsonLd { signature: { type: "RsaSignature2017"; creator: string; created: string; signatureValue: string; }; } function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd { if (typeof jsonLd !== "object" || jsonLd == null) return false; if ("signature" in jsonLd) { const signature = jsonLd.signature; if (typeof signature !== "object" || signature == null) return false; return "type" in signature && signature.type === "RsaSignature2017" && "creator" in signature && typeof signature.creator === "string" && "created" in signature && typeof signature.created === "string" && "signatureValue" in signature && typeof signature.signatureValue === "string"; } return false; } /** * Detaches Linked Data Signatures from the given JSON-LD document. * @param jsonLd The JSON-LD document to modify. * @returns The modified JSON-LD document. If the input document does not * contain a signature, the original document is returned. * @since 1.0.0 */ export function detachSignature(jsonLd: unknown): unknown { if (typeof jsonLd !== "object" || jsonLd == null) return jsonLd; const doc: { signature?: unknown } = { ...jsonLd }; delete doc.signature; return doc; } /** * Options for verifying Linked Data Signatures. * @since 1.0.0 */ export interface VerifySignatureOptions { /** * The document loader to use for fetching the public key. */ documentLoader?: DocumentLoader; /** * The context loader to use for JSON-LD context retrieval. */ contextLoader?: DocumentLoader; /** * The key cache to use for caching public keys. */ keyCache?: KeyCache; } /** * Verifies Linked Data Signatures of the given JSON-LD document. * @param jsonLd The JSON-LD document to verify. * @param options Options for verifying the signature. * @returns The public key that signed the document or `null` if the signature * is invalid or the key is not found. * @since 1.0.0 */ export async function verifySignature( jsonLd: unknown, options: VerifySignatureOptions = {}, ): Promise<CryptographicKey | null> { if (!hasSignature(jsonLd)) return null; const sig = jsonLd.signature; let signature: Uint8Array; try { signature = decodeBase64(sig.signatureValue); } catch (error) { logger.debug( "Failed to verify; invalid base64 signatureValue: {signatureValue}", { ...sig, error }, ); return null; } const keyResult = await fetchKey( new URL(sig.creator), CryptographicKey, options, ); if (keyResult == null) return null; const { key, cached } = keyResult; const sigOpts: { "@context": string; type?: string; id?: string; signatureValue?: string; } = { ...sig, "@context": "https://w3id.org/identity/v1", }; delete sigOpts.type; delete sigOpts.id; delete sigOpts.signatureValue; const sigOptsHash = await hashJsonLd(sigOpts, options.contextLoader); const document: { signature?: unknown } = { ...jsonLd }; delete document.signature; const docHash = await hashJsonLd(document, options.contextLoader); const encoder = new TextEncoder(); const message = sigOptsHash + docHash; const messageBytes = encoder.encode(message); const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes, ); if (verified) return key; if (cached) { logger.debug( "Failed to verify with the cached key {keyId}; " + "signature {signatureValue} is invalid. " + "Retrying with the freshly fetched key...", { keyId: sig.creator, ...sig }, ); const keyResult = await fetchKey( new URL(sig.creator), CryptographicKey, { ...options, keyCache: undefined }, ); if (keyResult == null) return null; const { key } = keyResult; const verified = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key.publicKey, signature, messageBytes, ); return verified ? key : null; } logger.debug( "Failed to verify with the fetched key {keyId}; " + "signature {signatureValue} is invalid. " + "Check if the key is correct or if the signed message is correct. " + "The message to sign is:\n{message}", { keyId: sig.creator, ...sig, message }, ); return null; } /** * Options for verifying JSON-LD documents. */ export interface VerifyJsonLdOptions extends VerifySignatureOptions { } /** * Verify the authenticity of the given JSON-LD document using Linked Data * Signatures. If the document is signed, this function verifies the signature * and checks if the document is attributed to the owner of the public key. * If the document is not signed, this function returns `false`. * @param jsonLd The JSON-LD document to verify. * @param options Options for verifying the document. * @returns `true` if the document is authentic; `false` otherwise. */ export async function verifyJsonLd( jsonLd: unknown, options: VerifyJsonLdOptions = {}, ): Promise<boolean> { const object = await Object.fromJsonLd(jsonLd, options); const attributions = new Set(object.attributionIds.map((uri) => uri.href)); if (object instanceof Activity) { for (const uri of object.actorIds) attributions.add(uri.href); } const key = await verifySignature(jsonLd, options); if (key == null) return false; if (key.ownerId == null) { logger.debug("Key {keyId} has no owner.", { keyId: key.id?.href }); return false; } attributions.delete(key.ownerId.href); if (attributions.size > 0) { logger.debug( "Some attributions are not authenticated by the Linked Data Signatures" + ": {attributions}.", { attributions: [...attributions] }, ); return false; } return true; } async function hashJsonLd( jsonLd: unknown, contextLoader: DocumentLoader | undefined, ): Promise<string> { const canon = await jsonld.canonize(jsonLd, { format: "application/n-quads", documentLoader: contextLoader ?? fetchDocumentLoader, }); const encoder = new TextEncoder(); const hash = await crypto.subtle.digest("SHA-256", encoder.encode(canon)); return encodeHex(hash); } // cSpell: ignore URGNA2012