This release is 10 versions behind 1.4.1 — the latest version of @fedify/fedify. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
An ActivityPub/fediverse server framework
This package works with Node.js, Deno, Bun


JSR Score
94%
Published
3 months ago (1.3.0-dev.578+6bbdcb1d)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394import { getLogger } from "jsr:@logtape/logtape@^0.8.0"; import { SpanKind, SpanStatusCode, trace, type TracerProvider, } from "npm:@opentelemetry/api@^1.9.0"; import metadata from "../deno.json" with { type: "json" }; import { type DocumentLoader, getDocumentLoader, } 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, * otherwise throws an error. * @param key The key to check. * @param type Which type of key to check. If not specified, the key can be * either public or private. * @throws {TypeError} If the key is invalid or unsupported. */ export function validateCryptoKey( key: CryptoKey, type?: "public" | "private", ): void { if (type != null && key.type !== type) { throw new TypeError(`The key is not a ${type} key.`); } if (!key.extractable) { throw new TypeError("The key is not extractable."); } if ( key.algorithm.name !== "RSASSA-PKCS1-v1_5" && key.algorithm.name !== "Ed25519" ) { throw new TypeError( "Currently only RSASSA-PKCS1-v1_5 and Ed25519 keys are supported. " + "More algorithms will be added in the future!", ); } if (key.algorithm.name === "RSASSA-PKCS1-v1_5") { // @ts-ignore TS2304 const algorithm = key.algorithm as unknown as RsaHashedKeyAlgorithm; if (algorithm.hash.name !== "SHA-256") { throw new TypeError( "For compatibility with the existing Fediverse software " + "(e.g., Mastodon), hash algorithm for RSASSA-PKCS1-v1_5 keys " + "must be SHA-256.", ); } } } /** * Generates a key pair which is appropriate for Fedify. * @param algorithm The algorithm to use. Currently only RSASSA-PKCS1-v1_5 and * Ed25519 are supported. * @returns The generated key pair. * @throws {TypeError} If the algorithm is unsupported. */ export function generateCryptoKeyPair( algorithm?: "RSASSA-PKCS1-v1_5" | "Ed25519", ): Promise<CryptoKeyPair> { if (algorithm == null) { getLogger(["fedify", "sig", "key"]).warn( "No algorithm specified. Using RSASSA-PKCS1-v1_5 by default, but " + "it is recommended to specify the algorithm explicitly as " + "the parameter will be required in the future.", ); } if (algorithm == null || algorithm === "RSASSA-PKCS1-v1_5") { return crypto.subtle.generateKey( { name: "RSASSA-PKCS1-v1_5", modulusLength: 4096, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: "SHA-256", }, true, ["sign", "verify"], ); } else if (algorithm === "Ed25519") { return crypto.subtle.generateKey( "Ed25519", true, ["sign", "verify"], ) as Promise<CryptoKeyPair>; } throw new TypeError("Unsupported algorithm: " + algorithm); } /** * Exports a key in JWK format. * @param key The key to export. Either public or private key. * @returns The exported key in JWK format. The key is suitable for * serialization and storage. * @throws {TypeError} If the key is invalid or unsupported. */ export async function exportJwk(key: CryptoKey): Promise<JsonWebKey> { validateCryptoKey(key); return await crypto.subtle.exportKey("jwk", key); } /** * Imports a key from JWK format. * @param jwk The key in JWK format. * @param type Which type of key to import, either `"public"` or `"private"`. * @returns The imported key. * @throws {TypeError} If the key is invalid or unsupported. */ export async function importJwk( jwk: JsonWebKey, type: "public" | "private", ): Promise<CryptoKey> { let key: CryptoKey; if (jwk.kty === "RSA" && jwk.alg === "RS256") { key = await crypto.subtle.importKey( "jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, type === "public" ? ["verify"] : ["sign"], ); } else if (jwk.kty === "OKP" && jwk.crv === "Ed25519") { key = await crypto.subtle.importKey( "jwk", jwk, "Ed25519", true, type === "public" ? ["verify"] : ["sign"], ); } else { throw new TypeError("Unsupported JWK format."); } validateCryptoKey(key, type); return key; } /** * Options for {@link fetchKey}. * @since 1.3.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; /** * The key cache to use for caching public keys. * @since 0.12.0 */ keyCache?: KeyCache; /** * The OpenTelemetry tracer provider to use for tracing. If omitted, * the global tracer provider is used. * @since 1.3.0 */ tracerProvider?: TracerProvider; } /** * The result of {@link fetchKey}. * @since 1.3.0 */ export interface FetchKeyResult<T extends CryptographicKey | Multikey> { /** * The fetched (or cached) key. */ readonly key: T & { publicKey: CryptoKey } | null; /** * Whether the key is fetched from the cache. */ readonly cached: boolean; } /** * 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 1.3.0 */ export 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; tracerProvider?: TracerProvider; }, ): Promise<T>; }, options: FetchKeyOptions = {}, ): Promise<FetchKeyResult<T>> { const tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); const tracer = tracerProvider.getTracer(metadata.name, metadata.version); keyId = typeof keyId === "string" ? new URL(keyId) : keyId; return tracer.startActiveSpan( "activitypub.fetch_key", { kind: SpanKind.CLIENT, attributes: { "http.method": "GET", "url.full": keyId.href, "url.scheme": keyId.protocol.replace(/:$/, ""), "url.domain": keyId.hostname, "url.path": keyId.pathname, "url.query": keyId.search.replace(/^\?/, ""), "url.fragment": keyId.hash.replace(/^#/, ""), }, }, async (span) => { try { const result = await fetchKeyInternal(keyId, cls, options); span.setAttribute("activitypub.actor.key.cached", result.cached); return result; } catch (e) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); throw e; } finally { span.end(); } }, ); } async function fetchKeyInternal<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; tracerProvider?: TracerProvider; }, ): Promise<T>; }, { documentLoader, contextLoader, keyCache, tracerProvider }: FetchKeyOptions = {}, ): Promise<FetchKeyResult<T>> { const logger = getLogger(["fedify", "sig", "key"]); const cacheKey = typeof keyId === "string" ? new URL(keyId) : keyId; keyId = typeof keyId === "string" ? keyId : keyId.href; if (keyCache != null) { const cachedKey = await keyCache.get(cacheKey); if (cachedKey instanceof cls && cachedKey.publicKey != null) { logger.debug("Key {keyId} found in cache.", { keyId }); return { key: cachedKey as T & { publicKey: CryptoKey }, cached: true, }; } else if (cachedKey === null) { logger.debug( "Entry {keyId} found in cache, but it is unavailable.", { keyId }, ); return { key: null, cached: true }; } } logger.debug("Fetching key {keyId} to verify signature...", { keyId }); let document: unknown; try { const remoteDocument = await (documentLoader ?? getDocumentLoader())(keyId); document = remoteDocument.document; } catch (_) { logger.debug("Failed to fetch key {keyId}.", { keyId }); await keyCache?.set(cacheKey, null); return { key: null, cached: false }; } let object: Object | T; try { object = await Object.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider, }); } catch (e) { if (!(e instanceof TypeError)) throw e; try { object = await cls.fromJsonLd(document, { documentLoader, contextLoader, tracerProvider, }); } catch (e) { if (e instanceof TypeError) { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); await keyCache?.set(cacheKey, null); return { key: null, cached: false }; } 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, tracerProvider }) : object.getAssertionMethods({ documentLoader, contextLoader, tracerProvider, }); 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 }, ); await keyCache?.set(cacheKey, null); return { key: null, cached: false }; } } else { logger.debug( "Failed to verify; key {keyId} returned an invalid object.", { keyId }, ); await keyCache?.set(cacheKey, null); return { key: null, cached: false }; } if (key.publicKey == null) { logger.debug( "Failed to verify; key {keyId} has no publicKeyPem field.", { keyId }, ); await keyCache?.set(cacheKey, null); return { key: null, cached: false }; } if (keyCache != null) { await keyCache.set(cacheKey, key); logger.debug("Key {keyId} cached.", { keyId }); } return { key: key as T & { publicKey: CryptoKey }, cached: false, }; } /** * A cache for storing cryptographic keys. * @since 0.12.0 */ export interface KeyCache { /** * Gets a key from the cache. * @param keyId The key ID. * @returns The key if found, `null` if the key is not available (e.g., * fetching the key was tried but failed), or `undefined` * if the cache is not available. */ get(keyId: URL): Promise<CryptographicKey | Multikey | null | undefined>; /** * Sets a key to the cache. * * Note that this caches unavailable keys (i.e., `null`) as well, * and it is recommended to make unavailable keys expire after a short period. * @param keyId The key ID. * @param key The key to cache. `null` means the key is not available * (e.g., fetching the key was tried but failed). */ set(keyId: URL, key: CryptographicKey | Multikey | null): Promise<void>; }