@fedify/fedify
Version:
An ActivityPub server framework
353 lines (352 loc) • 12.2 kB
JavaScript
import "@js-temporal/polyfill";
import "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { n as version, t as name } from "./deno-DMg4SgCb.mjs";
import { CryptographicKey, Object as Object$1, isActor } from "@fedify/vocab";
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
import { FetchError, getDocumentLoader } from "@fedify/vocab-runtime";
import { getLogger } from "@logtape/logtape";
//#region src/sig/key.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.
*/
function validateCryptoKey(key, type) {
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") {
if (key.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.
*/
function generateCryptoKeyPair(algorithm) {
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([
1,
0,
1
]),
hash: "SHA-256"
}, true, ["sign", "verify"]);
else if (algorithm === "Ed25519") return crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
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.
*/
async function exportJwk(key) {
validateCryptoKey(key);
const jwk = await crypto.subtle.exportKey("jwk", key);
if (jwk.crv === "Ed25519") jwk.alg = "Ed25519";
return jwk;
}
/**
* 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.
*/
async function importJwk(jwk, type) {
let key;
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") {
if (navigator?.userAgent === "Cloudflare-Workers") {
jwk = { ...jwk };
delete jwk.alg;
}
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;
}
async function withFetchKeySpan(keyId, tracerProvider, fetcher) {
tracerProvider ??= trace.getTracerProvider();
return await tracerProvider.getTracer(name, version).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 fetcher();
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();
}
});
}
/**
* 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.
* @template 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
*/
function fetchKey(keyId, cls, options = {}) {
keyId = typeof keyId === "string" ? new URL(keyId) : keyId;
return withFetchKeySpan(keyId, options.tracerProvider, () => fetchKeyInternal(keyId, cls, options));
}
/**
* Fetches a {@link CryptographicKey} or {@link Multikey} from the given URL,
* preserving transport-level fetch failures for callers that need to inspect
* why the key could not be loaded.
*
* @template 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.
* @returns The fetched key, or detailed fetch failure information.
* @since 2.1.0
*/
async function fetchKeyDetailed(keyId, cls, options = {}) {
const cacheKey = typeof keyId === "string" ? new URL(keyId) : keyId;
return await withFetchKeySpan(cacheKey, options.tracerProvider, async () => {
return await fetchKeyWithResult(cacheKey, cls, options, async (cacheKey, keyId, keyCache, logger) => {
const fetchError = await keyCache?.getFetchError?.(cacheKey);
if (fetchError != null) {
logger.debug("Entry {keyId} found in cache with preserved fetch failure details.", { keyId });
return {
key: null,
cached: true,
fetchError
};
}
logger.debug("Entry {keyId} found in cache, but no fetch failure details are available.", { keyId });
return {
key: null,
cached: true
};
}, async (error, cacheKey, keyId, keyCache, logger) => {
logger.debug("Failed to fetch key {keyId}.", {
keyId,
error
});
await keyCache?.set(cacheKey, null);
if (error instanceof FetchError && error.response != null) {
const fetchError = {
status: error.response.status,
response: error.response.clone()
};
await keyCache?.setFetchError?.(cacheKey, fetchError);
return {
key: null,
cached: false,
fetchError
};
}
const fetchError = { error: error instanceof Error ? error : new Error(String(error)) };
await keyCache?.setFetchError?.(cacheKey, fetchError);
return {
key: null,
cached: false,
fetchError
};
});
});
}
async function getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger) {
if (keyCache == null) return 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,
cached: true
};
} else if (cachedKey === null) {
logger.debug("Entry {keyId} found in cache, but it is unavailable.", { keyId });
return {
key: null,
cached: true
};
}
return null;
}
async function clearFetchErrorMetadata(keyId, keyCache) {
await keyCache?.setFetchError?.(keyId, null);
}
async function resolveFetchedKey(document, cacheKey, keyId, cls, { documentLoader, contextLoader, keyCache, tracerProvider }, logger) {
let object;
try {
object = await Object$1.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);
await clearFetchErrorMetadata(cacheKey, keyCache);
return {
key: null,
cached: false
};
}
throw e;
}
}
let key = null;
if (object instanceof cls) key = object;
else if (isActor(object)) {
const keys = cls === CryptographicKey ? object.getPublicKeys({
documentLoader,
contextLoader,
tracerProvider
}) : object.getAssertionMethods({
documentLoader,
contextLoader,
tracerProvider
});
let length = 0;
let lastKey = null;
for await (const k of keys) {
length++;
lastKey = k;
if (k.id?.href === keyId) {
key = k;
break;
}
}
const keyIdUrl = new URL(keyId);
if (key == null && keyIdUrl.hash === "" && length === 1) key = lastKey;
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);
await clearFetchErrorMetadata(cacheKey, keyCache);
return {
key: null,
cached: false
};
}
} else {
logger.debug("Failed to verify; key {keyId} returned an invalid object.", { keyId });
await keyCache?.set(cacheKey, null);
await clearFetchErrorMetadata(cacheKey, keyCache);
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);
await clearFetchErrorMetadata(cacheKey, keyCache);
return {
key: null,
cached: false
};
}
if (keyCache != null) {
await keyCache.set(cacheKey, key);
logger.debug("Key {keyId} cached.", { keyId });
}
await clearFetchErrorMetadata(cacheKey, keyCache);
return {
key,
cached: false
};
}
async function fetchKeyWithResult(cacheKey, cls, options, onCachedUnavailable, onFetchError) {
const logger = getLogger([
"fedify",
"sig",
"key"
]);
const keyId = cacheKey.href;
const keyCache = options.keyCache;
const cached = await getCachedFetchKey(cacheKey, keyId, cls, keyCache, logger);
if (cached?.key === null && cached.cached) return await onCachedUnavailable(cacheKey, keyId, keyCache, logger);
if (cached != null) return cached;
logger.debug("Fetching key {keyId} to verify signature...", { keyId });
let document;
try {
document = (await (options.documentLoader ?? getDocumentLoader())(keyId)).document;
} catch (error) {
return await onFetchError(error, cacheKey, keyId, keyCache, logger);
}
return await resolveFetchedKey(document, cacheKey, keyId, cls, options, logger);
}
async function fetchKeyInternal(keyId, cls, options = {}) {
return await fetchKeyWithResult(typeof keyId === "string" ? new URL(keyId) : keyId, cls, options, (_cacheKey, _keyId, _keyCache, _logger) => {
return {
key: null,
cached: true
};
}, async (error, cacheKey, keyId, keyCache, logger) => {
logger.debug("Failed to fetch key {keyId}.", {
keyId,
error
});
await keyCache?.set(cacheKey, null);
if (error instanceof FetchError && error.response != null) await keyCache?.setFetchError?.(cacheKey, {
status: error.response.status,
response: error.response.clone()
});
else await keyCache?.setFetchError?.(cacheKey, { error: error instanceof Error ? error : new Error(String(error)) });
return {
key: null,
cached: false
};
});
}
//#endregion
export { importJwk as a, generateCryptoKeyPair as i, fetchKey as n, validateCryptoKey as o, fetchKeyDetailed as r, exportJwk as t };