Loading CHANGES.md +11 −1 Original line number Diff line number Diff line Loading @@ -83,6 +83,11 @@ To be released. - `Object.clone()` method now accepts `proof` option. - `Object.clone()` method now accepts `proofs` option. - Implemented Object Integrity Proofs. [[FEP-8b32], [#54]] - Added `fetchKey()` function. - Added `FetchKeyOptions` interface. - Added `context` option to `Object.toJsonLd()` method. This applies to any subclasses of the `Object` class too. Loading @@ -97,6 +102,11 @@ To be released. `following`, `followers`, `outbox`, `manuallyApprovesFollowers`, and `url`. - Added more log messages using the [LogTape] library. Currently the below logger categories are used: - `["fedify", "sig", "key"]` [#54]: https://github.com/dahlia/fedify/issues/54 [#55]: https://github.com/dahlia/fedify/issues/55 [FEP-521a]: https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md Loading runtime/key.ts +1 −1 Original line number Diff line number Diff line Loading @@ -102,7 +102,7 @@ export async function importMultibaseKey(key: string): Promise<CryptoKey> { ["verify"], ); } else { throw new TypeError("Unsupported key type: " + code); throw new TypeError("Unsupported key type: 0x" + code.toString(16)); } } Loading sig/http.ts +11 −73 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { equals } from "@std/bytes"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { CryptographicKey, Object as ASObject } from "../vocab/vocab.ts"; import { validateCryptoKey } from "./key.ts"; import type { DocumentLoader } from "../runtime/docloader.ts"; import { CryptographicKey } from "../vocab/vocab.ts"; import { fetchKey, validateCryptoKey } from "./key.ts"; /** * Signs a request using the given private key. Loading @@ -24,6 +20,9 @@ export async function signRequest( keyId: URL, ): Promise<Request> { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const url = new URL(request.url); const body: ArrayBuffer | null = request.method !== "GET" && request.method !== "HEAD" Loading Loading @@ -226,72 +225,11 @@ export async function verifyRequest( return null; } const { keyId, headers, signature } = sigValues; logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document: unknown; try { const remoteDocument = await (documentLoader ?? fetchDocumentLoader)(keyId); document = remoteDocument.document; } catch (_) { logger.debug("Failed to fetch key {keyId}.", { keyId }); return null; } let object: ASObject | CryptographicKey; try { object = await ASObject.fromJsonLd(document, { const key = await fetchKey(new URL(keyId), CryptographicKey, { documentLoader, contextLoader, }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await CryptographicKey.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (e instanceof TypeError) { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } throw e; } } let key: CryptographicKey | null = null; if (object instanceof CryptographicKey) key = object; else if (isActor(object)) { for await ( const k of object.getPublicKeys({ documentLoader, contextLoader }) ) { if (k.id?.href === keyId) { key = k; break; } } if (key == null) { logger.debug( "Failed to verify; object {keyId} returned an {actorType}, " + "but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }, ); return null; } } else { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } if (key.publicKey == null) { logger.debug( "Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }, ); return null; } if (key == null) return null; const headerNames = headers.split(/\s+/g); if ( !headerNames.includes("(request-target)") || !headerNames.includes("date") Loading sig/key.test.ts +54 −1 Original line number Diff line number Diff line import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { rsaPrivateKey2, rsaPublicKey2 } from "../testing/keys.ts"; import { ed25519Multikey, rsaPrivateKey2, rsaPublicKey1, rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.ts"; import { CryptographicKey, Multikey } from "../vocab/vocab.ts"; import { exportJwk, fetchKey, type FetchKeyOptions, generateCryptoKeyPair, importJwk, validateCryptoKey, } from "./key.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; Deno.test("validateCryptoKey()", async () => { const pkcs1v15 = await crypto.subtle.generateKey( Loading Loading @@ -186,3 +196,46 @@ Deno.test("importJwk()", async () => { assertRejects(() => importJwk(rsaPublicJwk, "private")); assertRejects(() => importJwk(rsaPrivateJwk, "public")); }); Deno.test("fetchKey()", async () => { const options: FetchKeyOptions = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; assertEquals( await fetchKey("https://example.com/nothing", CryptographicKey, options), null, ); assertEquals( await fetchKey("https://example.com/object", CryptographicKey, options), null, ); assertEquals( await fetchKey("https://example.com/key", CryptographicKey, options), rsaPublicKey1, ); assertEquals( await fetchKey( "https://example.com/person#no-key", CryptographicKey, options, ), null, ); assertEquals( await fetchKey( "https://example.com/person2#key3", CryptographicKey, options, ), rsaPublicKey3, ); assertEquals( await fetchKey( "https://example.com/person2#key4", Multikey, options, ), ed25519Multikey, ); }); sig/key.ts +122 −0 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { CryptographicKey, type Multikey, Object } from "../vocab/vocab.ts"; /** * Checks if the given key is valid and supported. No-op if the key is valid, Loading Loading @@ -124,3 +130,119 @@ export async function importJwk( validateCryptoKey(key, type); return key; } /** * Options for {@link fetchKey}. * @since 0.10.0 */ export interface FetchKeyOptions { /** * The document loader for loading remote JSON-LD documents. */ documentLoader?: DocumentLoader; /** * The context loader for loading remote JSON-LD contexts. */ contextLoader?: DocumentLoader; } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL. * If the given URL contains an {@link Actor} object, it tries to find * the corresponding key in the `publicKey` or `assertionMethod` property. * @typeParam T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. See {@link FetchKeyOptions}. * @returns The fetched key or `null` if the key is not found. * @since 0.10.0 */ export async function fetchKey<T extends CryptographicKey | Multikey>( keyId: URL | string, // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => T) & { fromJsonLd( jsonLd: unknown, options: { documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; }, ): Promise<T>; }, { documentLoader, contextLoader }: FetchKeyOptions = {}, ): Promise<T & { publicKey: CryptoKey } | null> { const logger = getLogger(["fedify", "sig", "key"]); keyId = typeof keyId === "string" ? keyId : keyId.href; logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document: unknown; try { const remoteDocument = await (documentLoader ?? fetchDocumentLoader)(keyId); document = remoteDocument.document; } catch (_) { logger.debug("Failed to fetch key {keyId}.", { keyId }); return null; } let object: Object | T; try { object = await Object.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await cls.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (e instanceof TypeError) { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } throw e; } } let key: T | null = null; if (object instanceof cls) key = object; else if (isActor(object)) { // @ts-ignore: cls is either CryptographicKey or Multikey const keys = cls === CryptographicKey ? object.getPublicKeys({ documentLoader, contextLoader }) : object.getAssertionMethods({ documentLoader, contextLoader }); for await (const k of keys) { if (k.id?.href === keyId) { key = k as T; break; } } if (key == null) { logger.debug( "Failed to verify; object {keyId} returned an {actorType}, " + "but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }, ); return null; } } else { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } if (key.publicKey == null) { logger.debug( "Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }, ); return null; } return key as T & { publicKey: CryptoKey }; } Loading
CHANGES.md +11 −1 Original line number Diff line number Diff line Loading @@ -83,6 +83,11 @@ To be released. - `Object.clone()` method now accepts `proof` option. - `Object.clone()` method now accepts `proofs` option. - Implemented Object Integrity Proofs. [[FEP-8b32], [#54]] - Added `fetchKey()` function. - Added `FetchKeyOptions` interface. - Added `context` option to `Object.toJsonLd()` method. This applies to any subclasses of the `Object` class too. Loading @@ -97,6 +102,11 @@ To be released. `following`, `followers`, `outbox`, `manuallyApprovesFollowers`, and `url`. - Added more log messages using the [LogTape] library. Currently the below logger categories are used: - `["fedify", "sig", "key"]` [#54]: https://github.com/dahlia/fedify/issues/54 [#55]: https://github.com/dahlia/fedify/issues/55 [FEP-521a]: https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md Loading
runtime/key.ts +1 −1 Original line number Diff line number Diff line Loading @@ -102,7 +102,7 @@ export async function importMultibaseKey(key: string): Promise<CryptoKey> { ["verify"], ); } else { throw new TypeError("Unsupported key type: " + code); throw new TypeError("Unsupported key type: 0x" + code.toString(16)); } } Loading
sig/http.ts +11 −73 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { equals } from "@std/bytes"; import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { CryptographicKey, Object as ASObject } from "../vocab/vocab.ts"; import { validateCryptoKey } from "./key.ts"; import type { DocumentLoader } from "../runtime/docloader.ts"; import { CryptographicKey } from "../vocab/vocab.ts"; import { fetchKey, validateCryptoKey } from "./key.ts"; /** * Signs a request using the given private key. Loading @@ -24,6 +20,9 @@ export async function signRequest( keyId: URL, ): Promise<Request> { validateCryptoKey(privateKey, "private"); if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") { throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const url = new URL(request.url); const body: ArrayBuffer | null = request.method !== "GET" && request.method !== "HEAD" Loading Loading @@ -226,72 +225,11 @@ export async function verifyRequest( return null; } const { keyId, headers, signature } = sigValues; logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document: unknown; try { const remoteDocument = await (documentLoader ?? fetchDocumentLoader)(keyId); document = remoteDocument.document; } catch (_) { logger.debug("Failed to fetch key {keyId}.", { keyId }); return null; } let object: ASObject | CryptographicKey; try { object = await ASObject.fromJsonLd(document, { const key = await fetchKey(new URL(keyId), CryptographicKey, { documentLoader, contextLoader, }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await CryptographicKey.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (e instanceof TypeError) { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } throw e; } } let key: CryptographicKey | null = null; if (object instanceof CryptographicKey) key = object; else if (isActor(object)) { for await ( const k of object.getPublicKeys({ documentLoader, contextLoader }) ) { if (k.id?.href === keyId) { key = k; break; } } if (key == null) { logger.debug( "Failed to verify; object {keyId} returned an {actorType}, " + "but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }, ); return null; } } else { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } if (key.publicKey == null) { logger.debug( "Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }, ); return null; } if (key == null) return null; const headerNames = headers.split(/\s+/g); if ( !headerNames.includes("(request-target)") || !headerNames.includes("date") Loading
sig/key.test.ts +54 −1 Original line number Diff line number Diff line import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { rsaPrivateKey2, rsaPublicKey2 } from "../testing/keys.ts"; import { ed25519Multikey, rsaPrivateKey2, rsaPublicKey1, rsaPublicKey2, rsaPublicKey3, } from "../testing/keys.ts"; import { CryptographicKey, Multikey } from "../vocab/vocab.ts"; import { exportJwk, fetchKey, type FetchKeyOptions, generateCryptoKeyPair, importJwk, validateCryptoKey, } from "./key.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; Deno.test("validateCryptoKey()", async () => { const pkcs1v15 = await crypto.subtle.generateKey( Loading Loading @@ -186,3 +196,46 @@ Deno.test("importJwk()", async () => { assertRejects(() => importJwk(rsaPublicJwk, "private")); assertRejects(() => importJwk(rsaPrivateJwk, "public")); }); Deno.test("fetchKey()", async () => { const options: FetchKeyOptions = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; assertEquals( await fetchKey("https://example.com/nothing", CryptographicKey, options), null, ); assertEquals( await fetchKey("https://example.com/object", CryptographicKey, options), null, ); assertEquals( await fetchKey("https://example.com/key", CryptographicKey, options), rsaPublicKey1, ); assertEquals( await fetchKey( "https://example.com/person#no-key", CryptographicKey, options, ), null, ); assertEquals( await fetchKey( "https://example.com/person2#key3", CryptographicKey, options, ), rsaPublicKey3, ); assertEquals( await fetchKey( "https://example.com/person2#key4", Multikey, options, ), ed25519Multikey, ); });
sig/key.ts +122 −0 Original line number Diff line number Diff line import { getLogger } from "@logtape/logtape"; import { type DocumentLoader, fetchDocumentLoader, } from "../runtime/docloader.ts"; import { isActor } from "../vocab/actor.ts"; import { CryptographicKey, type Multikey, Object } from "../vocab/vocab.ts"; /** * Checks if the given key is valid and supported. No-op if the key is valid, Loading Loading @@ -124,3 +130,119 @@ export async function importJwk( validateCryptoKey(key, type); return key; } /** * Options for {@link fetchKey}. * @since 0.10.0 */ export interface FetchKeyOptions { /** * The document loader for loading remote JSON-LD documents. */ documentLoader?: DocumentLoader; /** * The context loader for loading remote JSON-LD contexts. */ contextLoader?: DocumentLoader; } /** * Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL. * If the given URL contains an {@link Actor} object, it tries to find * the corresponding key in the `publicKey` or `assertionMethod` property. * @typeParam T The type of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param keyId The URL of the key. * @param cls The class of the key to fetch. Either {@link CryptographicKey} * or {@link Multikey}. * @param options Options for fetching the key. See {@link FetchKeyOptions}. * @returns The fetched key or `null` if the key is not found. * @since 0.10.0 */ export async function fetchKey<T extends CryptographicKey | Multikey>( keyId: URL | string, // deno-lint-ignore no-explicit-any cls: (new (...args: any[]) => T) & { fromJsonLd( jsonLd: unknown, options: { documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; }, ): Promise<T>; }, { documentLoader, contextLoader }: FetchKeyOptions = {}, ): Promise<T & { publicKey: CryptoKey } | null> { const logger = getLogger(["fedify", "sig", "key"]); keyId = typeof keyId === "string" ? keyId : keyId.href; logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document: unknown; try { const remoteDocument = await (documentLoader ?? fetchDocumentLoader)(keyId); document = remoteDocument.document; } catch (_) { logger.debug("Failed to fetch key {keyId}.", { keyId }); return null; } let object: Object | T; try { object = await Object.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await cls.fromJsonLd(document, { documentLoader, contextLoader, }); } catch (e) { if (e instanceof TypeError) { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } throw e; } } let key: T | null = null; if (object instanceof cls) key = object; else if (isActor(object)) { // @ts-ignore: cls is either CryptographicKey or Multikey const keys = cls === CryptographicKey ? object.getPublicKeys({ documentLoader, contextLoader }) : object.getAssertionMethods({ documentLoader, contextLoader }); for await (const k of keys) { if (k.id?.href === keyId) { key = k as T; break; } } if (key == null) { logger.debug( "Failed to verify; object {keyId} returned an {actorType}, " + "but has no key matching {keyId}.", { keyId, actorType: object.constructor.name }, ); return null; } } else { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); return null; } if (key.publicKey == null) { logger.debug( "Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }, ); return null; } return key as T & { publicKey: CryptoKey }; }