@fedify/fedify
Version:
An ActivityPub server framework
455 lines (454 loc) • 18.8 kB
JavaScript
import "@js-temporal/polyfill";
import "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
import "../std__assert-BBjXFNOb.mjs";
import { t as assertRejects } from "../assert_rejects-DN60FHPX.mjs";
import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs";
import { a as importJwk, i as generateCryptoKeyPair, n as fetchKey, o as validateCryptoKey, r as fetchKeyDetailed, t as exportJwk } from "../key-BKYwmbur.mjs";
import { c as rsaPublicKey3, i as rsaPrivateKey2, o as rsaPublicKey1, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-DGu1NFwu.mjs";
import { CryptographicKey, Multikey } from "@fedify/vocab";
import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
import { FetchError } from "@fedify/vocab-runtime";
//#region src/sig/key.test.ts
test("validateCryptoKey()", async () => {
const pkcs1v15 = await crypto.subtle.generateKey({
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([
1,
0,
1
]),
hash: "SHA-256"
}, true, ["sign", "verify"]);
validateCryptoKey(pkcs1v15.privateKey, "private");
validateCryptoKey(pkcs1v15.privateKey);
validateCryptoKey(pkcs1v15.publicKey, "public");
validateCryptoKey(pkcs1v15.publicKey);
const ed25519 = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]);
validateCryptoKey(ed25519.privateKey, "private");
validateCryptoKey(ed25519.privateKey);
validateCryptoKey(ed25519.publicKey, "public");
validateCryptoKey(ed25519.publicKey);
assertThrows(() => validateCryptoKey(pkcs1v15.privateKey, "public"), TypeError, "The key is not a public key.");
assertThrows(() => validateCryptoKey(pkcs1v15.publicKey, "private"), TypeError, "The key is not a private key.");
assertThrows(() => validateCryptoKey(ed25519.privateKey, "public"), TypeError, "The key is not a public key.");
assertThrows(() => validateCryptoKey(ed25519.publicKey, "private"), TypeError, "The key is not a private key.");
const ecdsa = await crypto.subtle.generateKey({
name: "ECDSA",
namedCurve: "P-256"
}, true, ["sign", "verify"]);
assertThrows(() => validateCryptoKey(ecdsa.publicKey), TypeError, "only RSASSA-PKCS1-v1_5 and Ed25519");
const pkcs1v15Sha512 = await crypto.subtle.generateKey({
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([
1,
0,
1
]),
hash: "SHA-512"
}, true, ["sign", "verify"]);
assertThrows(() => validateCryptoKey(pkcs1v15Sha512.privateKey), TypeError, "hash algorithm for RSASSA-PKCS1-v1_5 keys must be SHA-256");
});
test("generateCryptoKeyPair()", async () => {
const rsaKeyPair = await generateCryptoKeyPair();
assertEquals(rsaKeyPair.privateKey.algorithm, {
name: "RSASSA-PKCS1-v1_5",
hash: { name: "SHA-256" },
modulusLength: 4096,
publicExponent: new Uint8Array([
1,
0,
1
])
});
validateCryptoKey(rsaKeyPair.privateKey, "private");
validateCryptoKey(rsaKeyPair.publicKey, "public");
const rsaKeyPair2 = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5");
assertEquals(rsaKeyPair2.privateKey.algorithm, {
name: "RSASSA-PKCS1-v1_5",
hash: { name: "SHA-256" },
modulusLength: 4096,
publicExponent: new Uint8Array([
1,
0,
1
])
});
validateCryptoKey(rsaKeyPair2.privateKey, "private");
validateCryptoKey(rsaKeyPair2.publicKey, "public");
const ed25519KeyPair = await generateCryptoKeyPair("Ed25519");
assertEquals(ed25519KeyPair.privateKey.algorithm, { name: "Ed25519" });
validateCryptoKey(ed25519KeyPair.privateKey, "private");
validateCryptoKey(ed25519KeyPair.publicKey, "public");
});
const rsaPublicJwk = {
alg: "RS256",
kty: "RSA",
e: "AQAB",
n: "oRmBtnxbdFutoRd1GLGwwGTrsqlRRWUe11hHQaoRLGf5LwQ0tIc6I9q-dynliw-2kxYsLn9SH2je6HcTYOolgW7F_cOWXZQN04b-OiYcU1ConAhLjmn4k1uKawJ614y0ScPNd8PQ-CljsnlPxbq9ofaCMe2BV3B6y09aCuGFJ0nxn1_ubjmIBIWWFTAznoz1J9BhJDGyt3IO3ABy3f9zDVlR32L_n5VIkXnxkjUKdzMAOzYb62kuKOp1iznRTPrV71SNtivJMwSh_LVgBrmZjtIn_oim-KyX_fdLU3tQ7VClyqmJzyAjccOH6Qj6nFTPh-vX07gqN8IlLT2uye4waw",
key_ops: ["verify"],
ext: true
};
const rsaPrivateJwk = {
alg: "RS256",
kty: "RSA",
d: "f-Pa2L7Sb4YUSa1wlSEC-0li35uQ3DFRkY0QTG2xYnpMFGoXWTV9D1epGrqU8pePzias_mCvFiZPx2Y4aRiYm68P2Mu7hCBz9XfWPN1iYTXIFM51BOLVpk3mjdsTICkgOusJI0m9jDR3ZAjwLj14K6qhYvd0VbECmoItLjQoW64Sc9iDgD3CvGoTqv71oTfW70cy-Ve1xQ9CThAmMOTKe6rYCUTA8tMZcPszifZ4iOasOjgvRxyel86LqGNtyslY8k86gQlMtFpR3VeZV_8otAWZn0mDc4vVU8HUO-DzYiIFdAcVxfPJh6tx7snCTsdzze_98OEAK4EWYBn7vsGFeQ",
dp: "lrXReSkZQXSmSxQ1TimV5kMt96gSu4_r-OGIabVmoG5irhjMyN08Jjc3qK9oZS3uNM-LxAOg4OdzefjsF9IMfZJl6wuLd85g_l4BHSaEk5zC8l3QugX1IU9XZ7wDxXUrutMoNtZXDtdbveAMtHNZlIu-qmEBDWzkqJiz2WpW-AE",
dq: "TCLoYcX0ywuNA9DSU6v94KmBh1e_IELEFVbJb5vvLKlAK-ycMK0rfzC1co9Hhkski1LskTnxnoqwZ5oF-7X10eZvy3Te_FHSl0IsTar8ST2-MRtGh2UjTdvP_nnygj4GcXvKfngjPEfthDzVfVMeR38oDhDxMFD5AaY_v9aMH_U",
e: "AQAB",
n: "oRmBtnxbdFutoRd1GLGwwGTrsqlRRWUe11hHQaoRLGf5LwQ0tIc6I9q-dynliw-2kxYsLn9SH2je6HcTYOolgW7F_cOWXZQN04b-OiYcU1ConAhLjmn4k1uKawJ614y0ScPNd8PQ-CljsnlPxbq9ofaCMe2BV3B6y09aCuGFJ0nxn1_ubjmIBIWWFTAznoz1J9BhJDGyt3IO3ABy3f9zDVlR32L_n5VIkXnxkjUKdzMAOzYb62kuKOp1iznRTPrV71SNtivJMwSh_LVgBrmZjtIn_oim-KyX_fdLU3tQ7VClyqmJzyAjccOH6Qj6nFTPh-vX07gqN8IlLT2uye4waw",
p: "xuDd7tE_47NWwvDTpB403X13EPA3768MlNpl_v_BGiuP-1uvWUnsOVZB0F3HXSVg1sBVNtec46v7OU0P693gvYUhouTmSQpayY_VFqMklprWgs7cfneqbeDzv3C4Fw5waY-vjoINDsE1jYELUnl5cVjXXyxuGFG-IaLJKmHmHX0",
q: "z17X2t9zO6WcMp6W04gXdKmniJlxekOrOmWnrX9AwaM8NYCLN3y23r59nqNP9aUAWG1eoGFmav2rYQitWhz_VsEu2pQUsfsYKZYHchu5p_jCYwuM3rIg7aCbhtGv_tBoWAf1NvKMhtp2es0ZaHZCzKDGSOkIYDOB-ZDmNigWigc",
qi: "KC6gWhVM_x7iQgl-gEoSh_iM1Jf314ZLJKAAz1DsTHMi5yuCkCMmmY7h6jlkAJVngK3KIf5LPoAeUoGJ26E1kocbRU_nZBftMDVXHCYICz8qMQXR5euN_5SeJnu_VWXH-CY83MKhPYAorWSZ1-G9gh-C16LlRMzJwoE6h5QNeNo",
key_ops: ["sign"],
ext: true
};
test("exportJwk()", async () => {
assertEquals(await exportJwk(rsaPrivateKey2), rsaPrivateJwk);
assertEquals(await exportJwk(rsaPublicKey2.publicKey), rsaPublicJwk);
});
test("importJwk()", async () => {
assertEquals(await importJwk(rsaPrivateJwk, "private"), rsaPrivateKey2);
assertEquals(await importJwk(rsaPublicJwk, "public"), rsaPublicKey2.publicKey);
assertRejects(() => importJwk(rsaPublicJwk, "private"));
assertRejects(() => importJwk(rsaPrivateJwk, "public"));
});
test("fetchKey()", async () => {
const cache = {};
const options = {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
keyCache: {
get(keyId) {
return Promise.resolve(cache[keyId.href]);
},
set(keyId, key) {
cache[keyId.href] = key;
return Promise.resolve();
}
}
};
assertEquals(await fetchKey("https://example.com/nothing", CryptographicKey, options), {
key: null,
cached: false
});
assertEquals(cache, { "https://example.com/nothing": null });
assertEquals(await fetchKey("https://example.com/nothing", CryptographicKey, options), {
key: null,
cached: true
});
assertEquals(cache, { "https://example.com/nothing": null });
assertEquals(await fetchKey("https://example.com/object", CryptographicKey, options), {
key: null,
cached: false
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null
});
assertEquals(await fetchKey("https://example.com/key", CryptographicKey, options), {
key: rsaPublicKey1,
cached: false
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1
});
assertEquals(await fetchKey("https://example.com/key", CryptographicKey, options), {
key: rsaPublicKey1,
cached: true
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1
});
assertEquals(await fetchKey("https://example.com/person#no-key", CryptographicKey, options), {
key: null,
cached: false
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1,
"https://example.com/person#no-key": null
});
assertEquals(await fetchKey("https://example.com/person2#key3", CryptographicKey, options), {
key: rsaPublicKey3,
cached: false
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1,
"https://example.com/person#no-key": null,
"https://example.com/person2#key3": rsaPublicKey3
});
assertEquals(await fetchKey("https://example.com/person2#key3", CryptographicKey, options), {
key: rsaPublicKey3,
cached: true
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1,
"https://example.com/person#no-key": null,
"https://example.com/person2#key3": rsaPublicKey3
});
assertEquals(await fetchKey("https://example.com/person2#key4", Multikey, options), {
key: ed25519Multikey,
cached: false
});
assertEquals(cache, {
"https://example.com/nothing": null,
"https://example.com/object": null,
"https://example.com/key": rsaPublicKey1,
"https://example.com/person#no-key": null,
"https://example.com/person2#key3": rsaPublicKey3,
"https://example.com/person2#key4": ed25519Multikey
});
assertEquals(await fetchKey("https://example.com/person2#key4", Multikey, options), {
key: ed25519Multikey,
cached: true
});
assertEquals(await fetchKey("https://example.com/key", CryptographicKey, {
...options,
keyCache: void 0
}), {
key: rsaPublicKey1,
cached: false
});
assertEquals(await fetchKey("https://example.com/users/handle", CryptographicKey, options), {
key: new CryptographicKey({
id: new URL("https://example.com/users/handle#main-key"),
publicKey: await importJwk({
kty: "RSA",
alg: "RS256",
n: "oRmBtnxbdFutoRd1GLGwwGTrsqlRRWUe11hHQaoRLGf5LwQ0tIc6I9q-dynliw-2kxYsLn9SH2je6HcTYOolgW7F_cOWXZQN04b-OiYcU1ConAhLjmn4k1uKawJ614y0ScPNd8PQ-CljsnlPxbq9ofaCMe2BV3B6y09aCuGFJ0nxn1_ubjmIBIWWFTAznoz1J9BhJDGyt3IO3ABy3f9zDVlR32L_n5VIkXnxkjUKdzMAOzYb62kuKOp1iznRTPrV71SNtivJMwSh_LVgBrmZjtIn_oim-KyX_fdLU3tQ7VClyqmJzyAjccOH6Qj6nFTPh-vX07gqN8IlLT2uye4waw",
e: "AQAB",
key_ops: ["verify"],
ext: true
}, "public")
}),
cached: false
});
});
test("fetchKeyDetailed()", async () => {
const cache = { "https://example.com/nothing": null };
let documentLoaderCalls = 0;
const [tracerProvider, exporter] = createTestTracerProvider();
const options = {
documentLoader(url) {
documentLoaderCalls++;
return mockDocumentLoader(url);
},
contextLoader: mockDocumentLoader,
tracerProvider,
keyCache: {
get(keyId) {
return Promise.resolve(cache[keyId.href]);
},
set(keyId, key) {
cache[keyId.href] = key;
return Promise.resolve();
}
}
};
assertEquals(await fetchKeyDetailed("https://example.com/nothing", CryptographicKey, options), {
key: null,
cached: true
});
assertEquals(documentLoaderCalls, 0);
assertEquals(await fetchKeyDetailed("https://example.com/key", CryptographicKey, options), {
key: rsaPublicKey1,
cached: false
});
assertEquals(documentLoaderCalls, 1);
const spans = exporter.getSpans("activitypub.fetch_key");
assertEquals(spans.length, 2);
assertEquals(spans[0].attributes["activitypub.actor.key.cached"], true);
assertEquals(spans[1].attributes["activitypub.actor.key.cached"], false);
});
test("fetchKeyDetailed() returns detailed fetch errors", async () => {
const goneKeyId = new URL("https://example.com/gone-key");
const goneResult = await fetchKeyDetailed(goneKeyId, CryptographicKey, {
documentLoader(url) {
if (url === goneKeyId.href) throw new FetchError(goneKeyId, `HTTP 410: ${goneKeyId.href}`, new Response(null, { status: 410 }));
return mockDocumentLoader(url);
},
contextLoader: mockDocumentLoader
});
assertEquals(goneResult.key, null);
assertEquals(goneResult.cached, false);
const goneError = goneResult.fetchError;
assertEquals(goneError != null && "status" in goneError, true);
if (goneError == null || !("status" in goneError)) throw new Error("Expected HTTP fetch error details.");
assertEquals(goneError.status, 410);
assertEquals(goneError.response.status, 410);
const failure = /* @__PURE__ */ new TypeError("boom");
const errorResult = await fetchKeyDetailed("https://example.com/error-key", CryptographicKey, {
documentLoader() {
throw failure;
},
contextLoader: mockDocumentLoader
});
assertEquals(errorResult.key, null);
assertEquals(errorResult.cached, false);
const detailedError = errorResult.fetchError;
assertEquals(detailedError != null && "error" in detailedError, true);
if (detailedError == null || !("error" in detailedError)) throw new Error("Expected non-HTTP fetch error details.");
assertEquals(detailedError.error, failure);
});
test("fetchKey() records activitypub.key.lookup with hit on cached key", async () => {
const [meterProvider, recorder] = createTestMeterProvider();
const cache = {};
const options = {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
keyCache: {
get(keyId) {
return Promise.resolve(cache[keyId.href]);
},
set(keyId, key) {
cache[keyId.href] = key;
return Promise.resolve();
}
},
meterProvider
};
await fetchKey("https://example.com/key", CryptographicKey, options);
recorder.clear();
assertEquals((await fetchKey("https://example.com/key", CryptographicKey, options)).cached, true);
const counters = recorder.getMeasurements("activitypub.key.lookup");
assertEquals(counters.length, 1);
assertEquals(counters[0].attributes["activitypub.lookup.kind"], "public_key");
assertEquals(counters[0].attributes["activitypub.lookup.result"], "hit");
assertEquals(counters[0].attributes["activitypub.cache.enabled"], true);
assertEquals(counters[0].attributes["activitypub.remote.host"], "example.com");
const duration = recorder.getMeasurement("activitypub.key.lookup.duration");
assertEquals(duration?.type, "histogram");
assertEquals(duration?.attributes["activitypub.lookup.result"], "hit");
});
test("fetchKey() records activitypub.key.lookup with fetched on cache miss", async () => {
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await fetchKey("https://example.com/key", CryptographicKey, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader,
meterProvider
})).cached, false);
const counter = recorder.getMeasurement("activitypub.key.lookup");
assertEquals(counter?.attributes["activitypub.lookup.result"], "fetched");
assertEquals(counter?.attributes["activitypub.cache.enabled"], false);
assertEquals(counter?.attributes["activitypub.remote.host"], "example.com");
assertEquals(recorder.getMeasurement("activitypub.key.lookup.duration")?.attributes["activitypub.lookup.result"], "fetched");
});
test("fetchKey() records not_found and status code on HTTP 404", async () => {
const [meterProvider, recorder] = createTestMeterProvider();
const missingKeyId = new URL("https://example.com/missing-key");
const documentLoader = (url) => {
if (url === missingKeyId.href) throw new FetchError(missingKeyId, `HTTP 404: ${missingKeyId.href}`, new Response(null, { status: 404 }));
return mockDocumentLoader(url);
};
assertEquals((await fetchKey(missingKeyId, CryptographicKey, {
documentLoader,
contextLoader: mockDocumentLoader,
meterProvider
})).key, null);
const counter = recorder.getMeasurement("activitypub.key.lookup");
assertEquals(counter?.attributes["activitypub.lookup.result"], "not_found");
assertEquals(counter?.attributes["http.response.status_code"], 404);
assertEquals(counter?.attributes["activitypub.cache.enabled"], false);
assertEquals(counter?.attributes["activitypub.remote.host"], "example.com");
});
test("fetchKey() records network_error on TypeError from the document loader", async () => {
const [meterProvider, recorder] = createTestMeterProvider();
const documentLoader = () => {
throw new TypeError("connect failed");
};
await fetchKey("https://example.com/key", CryptographicKey, {
documentLoader,
contextLoader: mockDocumentLoader,
meterProvider
});
const counter = recorder.getMeasurement("activitypub.key.lookup");
assertEquals(counter?.attributes["activitypub.lookup.result"], "network_error");
assertEquals("http.response.status_code" in counter.attributes, false);
});
test("fetchKeyDetailed() records activitypub.key.lookup with the same taxonomy", async () => {
const [meterProvider, recorder] = createTestMeterProvider();
const goneKeyId = new URL("https://example.com/gone-key");
const documentLoader = (url) => {
if (url === goneKeyId.href) throw new FetchError(goneKeyId, `HTTP 410: ${goneKeyId.href}`, new Response(null, { status: 410 }));
return mockDocumentLoader(url);
};
await fetchKeyDetailed(goneKeyId, CryptographicKey, {
documentLoader,
contextLoader: mockDocumentLoader,
meterProvider
});
const counter = recorder.getMeasurement("activitypub.key.lookup");
assertEquals(counter?.attributes["activitypub.lookup.result"], "not_found");
assertEquals(counter?.attributes["http.response.status_code"], 410);
});
test("fetchKey() works when meterProvider is omitted", async () => {
assertEquals((await fetchKey("https://example.com/key", CryptographicKey, {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
})).cached, false);
});
test("fetchKey() returns null for a malformed actor publicKey", async () => {
const actorId = "https://example.com/malformed-public-key";
const keyId = "https://example.com/malformed-public-key#main-key";
const cache = {};
const options = {
async documentLoader(resource) {
if (resource === actorId) return {
contextUrl: null,
documentUrl: resource,
document: {
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
id: actorId,
type: "Person",
publicKey: keyId
}
};
if (resource === keyId) return {
contextUrl: null,
documentUrl: resource,
document: {
"@context": "https://w3id.org/security/v1",
id: keyId,
type: "Key",
owner: actorId,
publicKeyPem: "not a public key"
}
};
return await mockDocumentLoader(resource);
},
contextLoader: mockDocumentLoader,
keyCache: {
get(keyId) {
return Promise.resolve(cache[keyId.href]);
},
set(keyId, key) {
cache[keyId.href] = key;
return Promise.resolve();
}
}
};
assertEquals(await fetchKey(actorId, CryptographicKey, options), {
key: null,
cached: false
});
assertEquals(cache, { [actorId]: null });
assertEquals(await fetchKey(actorId, CryptographicKey, options), {
key: null,
cached: true
});
});
//#endregion
export {};