Loading CHANGES.md +8 −0 Original line number Diff line number Diff line Loading @@ -11,8 +11,16 @@ To be released. - Fedify now supports [Linked Data Signatures], which is outdated but still widely used in the fediverse. - Added `Signature` interface. - Added `signJsonLd()` function. - Added `SignJsonLdOptions` interface. - Added `createSignature()` function. - Added `CreateSignatureOptions` interface. - Added `verifyJsonLd()` function. - Added `VerifyJsonLdOptions` interface. - Added `verifySignature()` function. - Added `VerifySignatureOptions` interface. - Added `attachSignature()` function. - Added `detachSignature()` function. - Added more log messages using the [LogTape] library. Currently the below Loading src/sig/ld.test.ts +103 −2 Original line number Diff line number Diff line import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/assert-equals"; import { assertFalse } from "@std/assert/assert-false"; import { assertRejects } from "@std/assert/assert-rejects"; import { assertThrows } from "@std/assert/assert-throws"; import { encodeBase64 } from "@std/encoding/base64"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { ed25519Multikey, ed25519PrivateKey, rsaPrivateKey2, rsaPrivateKey3, rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.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"; import { attachSignature, createSignature, detachSignature, type Signature, signJsonLd, verifyJsonLd, verifySignature, } from "./ld.ts"; test("attachSignature()", () => { const sig: Signature = { "@context": "https://w3id.org/identity/v1", type: "RsaSignature2017", creator: "https://activitypub.academy/users/brauca_darradiul#main-key", created: "2024-09-12T16:50:46Z", signatureValue: "asdf", }; const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", }; assertEquals(attachSignature(doc, sig), { ...doc, signature: sig, }); assertThrows(() => attachSignature(null, sig), TypeError); assertThrows(() => attachSignature(1234, sig), TypeError); }); test("createSignature()", async () => { const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", }; const sig = await createSignature(doc, rsaPrivateKey2, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }); const key = await verifySignature(attachSignature(doc, sig), { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(key, rsaPublicKey2); assertRejects( () => createSignature(doc, rsaPublicKey2.publicKey!, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }), TypeError, ); assertRejects( () => createSignature(doc, ed25519PrivateKey, ed25519Multikey.id!, { contextLoader: mockDocumentLoader, }), TypeError, ); }); test("signJsonLd()", async () => { const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", actor: "https://example.com/person2", }; const signed = await signJsonLd(doc, rsaPrivateKey3, rsaPublicKey3.id!, { contextLoader: mockDocumentLoader, }); const verified = await verifyJsonLd(signed, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assert(verified); }); const document = { "@context": [ Loading Loading @@ -171,7 +258,21 @@ test("verifyJsonLd()", async () => { }); assert(verified); // TODO: Test a correctly signed document, but with a different key. const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", actor: "https://example.com/person2", }; // rsaPublicKey2 has no owner const signed = await signJsonLd(doc, rsaPrivateKey2, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }); const verified2 = await verifyJsonLd(signed, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertFalse(verified2); }); // cSpell: ignore ostatus src/sig/ld.ts +127 −8 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { decodeBase64 } from "@std/encoding/base64"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import jsonld from "jsonld"; import { Loading @@ -7,17 +7,136 @@ import { fetchDocumentLoader, } from "../runtime/docloader.ts"; import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts"; import { fetchKey, type KeyCache } from "./key.ts"; import { fetchKey, type KeyCache, validateCryptoKey } from "./key.ts"; const logger = getLogger(["fedify", "sig", "ld"]); interface SignedJsonLd { signature: { /** * A signature of a JSON-LD document. * @since 1.0.0 */ export interface Signature { "@context"?: "https://w3id.org/identity/v1"; type: "RsaSignature2017"; id?: string; creator: string; created: string; signatureValue: string; } /** * Attaches a LD signature to the given JSON-LD document. * @param jsonLd The JSON-LD document to attach the signature to. It is not * modified. * @param signature The signature to attach. * @returns The JSON-LD document with the attached signature. * @throws {TypeError} If the input document is not a valid JSON-LD document. * @since 1.0.0 */ export function attachSignature( jsonLd: unknown, signature: Signature, ): { signature: Signature } { if (typeof jsonLd !== "object" || jsonLd == null) { throw new TypeError( "Failed to attach signature; invalid JSON-LD document.", ); } return { ...jsonLd, signature }; } /** * Options for creating Linked Data Signatures. * @since 1.0.0 */ export interface CreateSignatureOptions { /** * The context loader for loading remote JSON-LD contexts. */ contextLoader?: DocumentLoader; /** * The time when the signature was created. If not specified, the current * time will be used. */ created?: Temporal.Instant; } /** * Creates a LD signature for the given JSON-LD document. * @param jsonLd The JSON-LD document to sign. * @param privateKey The private key to sign the document. * @param keyId The ID of the public key that corresponds to the private key. * @param options Additional options for creating the signature. * See also {@link CreateSignatureOptions}. * @return The created signature. * @throws {TypeError} If the private key is invalid or unsupported. * @since 1.0.0 */ export async function createSignature( jsonLd: unknown, privateKey: CryptoKey, keyId: URL, { contextLoader, created }: CreateSignatureOptions = {}, ): Promise<Signature> { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const options = { "@context": "https://w3id.org/identity/v1" as const, creator: keyId.href, created: created?.toString() ?? new Date().toISOString(), }; const optionsHash = await hashJsonLd(options, contextLoader); const docHash = await hashJsonLd(jsonLd, contextLoader); const message = optionsHash + docHash; const encoder = new TextEncoder(); const messageBytes = encoder.encode(message); const signature = await crypto.subtle.sign( "RSASSA-PKCS1-v1_5", privateKey, messageBytes, ); return { ...options, type: "RsaSignature2017", signatureValue: encodeBase64(signature), }; } /** * Options for signing JSON-LD documents. * @since 1.0.0 */ export interface SignJsonLdOptions extends CreateSignatureOptions { } /** * Signs the given JSON-LD document with the private key and returns the signed * JSON-LD document. * @param jsonLd The JSON-LD document to sign. * @param privateKey The private key to sign the document. * @param keyId The key ID to use in the signature. It will be used by the * verifier to fetch the corresponding public key. * @param options Additional options for signing the document. * See also {@link SignJsonLdOptions}. * @returns The signed JSON-LD document. * @throws {TypeError} If the private key is invalid or unsupported. * @since 1.0.0 */ export async function signJsonLd( jsonLd: unknown, privateKey: CryptoKey, keyId: URL, options: SignJsonLdOptions, ): Promise<{ signature: Signature }> { const signature = await createSignature(jsonLd, privateKey, keyId, options); return attachSignature(jsonLd, signature); } interface SignedJsonLd { signature: Signature; } function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd { Loading Loading
CHANGES.md +8 −0 Original line number Diff line number Diff line Loading @@ -11,8 +11,16 @@ To be released. - Fedify now supports [Linked Data Signatures], which is outdated but still widely used in the fediverse. - Added `Signature` interface. - Added `signJsonLd()` function. - Added `SignJsonLdOptions` interface. - Added `createSignature()` function. - Added `CreateSignatureOptions` interface. - Added `verifyJsonLd()` function. - Added `VerifyJsonLdOptions` interface. - Added `verifySignature()` function. - Added `VerifySignatureOptions` interface. - Added `attachSignature()` function. - Added `detachSignature()` function. - Added more log messages using the [LogTape] library. Currently the below Loading
src/sig/ld.test.ts +103 −2 Original line number Diff line number Diff line import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/assert-equals"; import { assertFalse } from "@std/assert/assert-false"; import { assertRejects } from "@std/assert/assert-rejects"; import { assertThrows } from "@std/assert/assert-throws"; import { encodeBase64 } from "@std/encoding/base64"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { ed25519Multikey, ed25519PrivateKey, rsaPrivateKey2, rsaPrivateKey3, rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.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"; import { attachSignature, createSignature, detachSignature, type Signature, signJsonLd, verifyJsonLd, verifySignature, } from "./ld.ts"; test("attachSignature()", () => { const sig: Signature = { "@context": "https://w3id.org/identity/v1", type: "RsaSignature2017", creator: "https://activitypub.academy/users/brauca_darradiul#main-key", created: "2024-09-12T16:50:46Z", signatureValue: "asdf", }; const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", }; assertEquals(attachSignature(doc, sig), { ...doc, signature: sig, }); assertThrows(() => attachSignature(null, sig), TypeError); assertThrows(() => attachSignature(1234, sig), TypeError); }); test("createSignature()", async () => { const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", }; const sig = await createSignature(doc, rsaPrivateKey2, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }); const key = await verifySignature(attachSignature(doc, sig), { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertEquals(key, rsaPublicKey2); assertRejects( () => createSignature(doc, rsaPublicKey2.publicKey!, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }), TypeError, ); assertRejects( () => createSignature(doc, ed25519PrivateKey, ed25519Multikey.id!, { contextLoader: mockDocumentLoader, }), TypeError, ); }); test("signJsonLd()", async () => { const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", actor: "https://example.com/person2", }; const signed = await signJsonLd(doc, rsaPrivateKey3, rsaPublicKey3.id!, { contextLoader: mockDocumentLoader, }); const verified = await verifyJsonLd(signed, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assert(verified); }); const document = { "@context": [ Loading Loading @@ -171,7 +258,21 @@ test("verifyJsonLd()", async () => { }); assert(verified); // TODO: Test a correctly signed document, but with a different key. const doc = { "@context": "https://www.w3.org/ns/activitystreams", id: "https://example.com/1", type: "Create", actor: "https://example.com/person2", }; // rsaPublicKey2 has no owner const signed = await signJsonLd(doc, rsaPrivateKey2, rsaPublicKey2.id!, { contextLoader: mockDocumentLoader, }); const verified2 = await verifyJsonLd(signed, { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }); assertFalse(verified2); }); // cSpell: ignore ostatus
src/sig/ld.ts +127 −8 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { decodeBase64 } from "@std/encoding/base64"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { encodeHex } from "@std/encoding/hex"; import jsonld from "jsonld"; import { Loading @@ -7,17 +7,136 @@ import { fetchDocumentLoader, } from "../runtime/docloader.ts"; import { Activity, CryptographicKey, Object } from "../vocab/vocab.ts"; import { fetchKey, type KeyCache } from "./key.ts"; import { fetchKey, type KeyCache, validateCryptoKey } from "./key.ts"; const logger = getLogger(["fedify", "sig", "ld"]); interface SignedJsonLd { signature: { /** * A signature of a JSON-LD document. * @since 1.0.0 */ export interface Signature { "@context"?: "https://w3id.org/identity/v1"; type: "RsaSignature2017"; id?: string; creator: string; created: string; signatureValue: string; } /** * Attaches a LD signature to the given JSON-LD document. * @param jsonLd The JSON-LD document to attach the signature to. It is not * modified. * @param signature The signature to attach. * @returns The JSON-LD document with the attached signature. * @throws {TypeError} If the input document is not a valid JSON-LD document. * @since 1.0.0 */ export function attachSignature( jsonLd: unknown, signature: Signature, ): { signature: Signature } { if (typeof jsonLd !== "object" || jsonLd == null) { throw new TypeError( "Failed to attach signature; invalid JSON-LD document.", ); } return { ...jsonLd, signature }; } /** * Options for creating Linked Data Signatures. * @since 1.0.0 */ export interface CreateSignatureOptions { /** * The context loader for loading remote JSON-LD contexts. */ contextLoader?: DocumentLoader; /** * The time when the signature was created. If not specified, the current * time will be used. */ created?: Temporal.Instant; } /** * Creates a LD signature for the given JSON-LD document. * @param jsonLd The JSON-LD document to sign. * @param privateKey The private key to sign the document. * @param keyId The ID of the public key that corresponds to the private key. * @param options Additional options for creating the signature. * See also {@link CreateSignatureOptions}. * @return The created signature. * @throws {TypeError} If the private key is invalid or unsupported. * @since 1.0.0 */ export async function createSignature( jsonLd: unknown, privateKey: CryptoKey, keyId: URL, { contextLoader, created }: CreateSignatureOptions = {}, ): Promise<Signature> { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const options = { "@context": "https://w3id.org/identity/v1" as const, creator: keyId.href, created: created?.toString() ?? new Date().toISOString(), }; const optionsHash = await hashJsonLd(options, contextLoader); const docHash = await hashJsonLd(jsonLd, contextLoader); const message = optionsHash + docHash; const encoder = new TextEncoder(); const messageBytes = encoder.encode(message); const signature = await crypto.subtle.sign( "RSASSA-PKCS1-v1_5", privateKey, messageBytes, ); return { ...options, type: "RsaSignature2017", signatureValue: encodeBase64(signature), }; } /** * Options for signing JSON-LD documents. * @since 1.0.0 */ export interface SignJsonLdOptions extends CreateSignatureOptions { } /** * Signs the given JSON-LD document with the private key and returns the signed * JSON-LD document. * @param jsonLd The JSON-LD document to sign. * @param privateKey The private key to sign the document. * @param keyId The key ID to use in the signature. It will be used by the * verifier to fetch the corresponding public key. * @param options Additional options for signing the document. * See also {@link SignJsonLdOptions}. * @returns The signed JSON-LD document. * @throws {TypeError} If the private key is invalid or unsupported. * @since 1.0.0 */ export async function signJsonLd( jsonLd: unknown, privateKey: CryptoKey, keyId: URL, options: SignJsonLdOptions, ): Promise<{ signature: Signature }> { const signature = await createSignature(jsonLd, privateKey, keyId, options); return attachSignature(jsonLd, signature); } interface SignedJsonLd { signature: Signature; } function hasSignature(jsonLd: unknown): jsonLd is SignedJsonLd { Loading