diff --git a/CHANGES.md b/CHANGES.md index 4648bf41..05e70115 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -17,6 +17,7 @@ To be released. - Added `CollectionCallbackSetters.authorize()` method. - Added `AuthorizedPredicate` type. - Added `RequestContext.getSignedKey()` method. + - Added `RequestContext.getSignedKeyOwner()` method. - Added `FederationFetchOptions.onUnauthorized` option for handling unauthorized fetches. - Added `getKeyOwner()` function. diff --git a/federation/context.ts b/federation/context.ts index 668275e3..37cd3460 100644 --- a/federation/context.ts +++ b/federation/context.ts @@ -155,6 +155,21 @@ export interface RequestContext extends Context { * @since 0.7.0 */ getSignedKey(): Promise; + + /** + * Gets the owner of the signed key, if any exists and it is verified. + * Otherwise, `null` is returned. + * + * This can be used for implementing [authorized fetch] (also known as + * secure mode) in ActivityPub. + * + * [authorized fetch]: https://swicg.github.io/activitypub-http-signature/#authorized-fetch + * + * @returns The owner of the signed key, or `null` if the key is not verified + * or the owner is not found. + * @since 0.7.0 + */ + getSignedKeyOwner(): Promise; } /** diff --git a/federation/middleware.test.ts b/federation/middleware.test.ts index 3316c958..48461393 100644 --- a/federation/middleware.test.ts +++ b/federation/middleware.test.ts @@ -13,12 +13,18 @@ import { getAuthenticatedDocumentLoader, } from "../runtime/docloader.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; -import { privateKey2, publicKey2 } from "../testing/keys.ts"; +import { + privateKey2, + privateKey3, + publicKey2, + publicKey3, +} from "../testing/keys.ts"; import { Create, Person } from "../vocab/vocab.ts"; import type { Context } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; import { Federation } from "./middleware.ts"; import { RouterError } from "./router.ts"; +import { lookupObject } from "@fedify/fedify/vocab"; Deno.test("Federation.createContext()", async (t) => { const kv = new MemoryKvStore(); @@ -179,8 +185,10 @@ Deno.test("Federation.createContext()", async (t) => { assertEquals(ctx.url, new URL("https://example.com/")); assertEquals(ctx.data, 123); assertEquals(await ctx.getSignedKey(), null); + assertEquals(await ctx.getSignedKeyOwner(), null); // Multiple calls should return the same result: assertEquals(await ctx.getSignedKey(), null); + assertEquals(await ctx.getSignedKeyOwner(), null); const signedReq = await sign( new Request("https://example.com/"), @@ -192,8 +200,29 @@ Deno.test("Federation.createContext()", async (t) => { assertEquals(signedCtx.url, new URL("https://example.com/")); assertEquals(signedCtx.data, 456); assertEquals(await signedCtx.getSignedKey(), publicKey2); + assertEquals(await signedCtx.getSignedKeyOwner(), null); // Multiple calls should return the same result: assertEquals(await signedCtx.getSignedKey(), publicKey2); + assertEquals(await signedCtx.getSignedKeyOwner(), null); + + const signedReq2 = await sign( + new Request("https://example.com/"), + privateKey3, + publicKey3.id!, + ); + const signedCtx2 = federation.createContext(signedReq2, 456); + assertEquals(signedCtx2.request, signedReq2); + assertEquals(signedCtx2.url, new URL("https://example.com/")); + assertEquals(signedCtx2.data, 456); + assertEquals(await signedCtx2.getSignedKey(), publicKey3); + const expectedOwner = await lookupObject( + "https://example.com/person2", + { documentLoader: mockDocumentLoader }, + ); + assertEquals(await signedCtx2.getSignedKeyOwner(), expectedOwner); + // Multiple calls should return the same result: + assertEquals(await signedCtx2.getSignedKey(), publicKey3); + assertEquals(await signedCtx2.getSignedKeyOwner(), expectedOwner); }); mf.uninstall(); diff --git a/federation/middleware.ts b/federation/middleware.ts index 2f9eff00..ee2f74ff 100644 --- a/federation/middleware.ts +++ b/federation/middleware.ts @@ -1,6 +1,6 @@ import { Temporal } from "@js-temporal/polyfill"; import { exportJwk, importJwk, validateCryptoKey } from "../httpsig/key.ts"; -import { verify } from "../httpsig/mod.ts"; +import { getKeyOwner, verify } from "../httpsig/mod.ts"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import { type AuthenticatedDocumentLoaderFactory, @@ -392,6 +392,7 @@ export class Federation { }; if (request == null) return context; let signedKey: CryptographicKey | null | undefined = undefined; + let signedKeyOwner: Actor | null | undefined = undefined; const reqCtx: RequestContext = { ...context, request, @@ -400,6 +401,12 @@ export class Federation { if (signedKey !== undefined) return signedKey; return signedKey = await verify(request, context.documentLoader); }, + async getSignedKeyOwner() { + if (signedKeyOwner !== undefined) return signedKeyOwner; + const key = await this.getSignedKey(); + if (key == null) return signedKeyOwner = null; + return signedKeyOwner = await getKeyOwner(key, context.documentLoader); + }, }; return reqCtx; } diff --git a/httpsig/mod.test.ts b/httpsig/mod.test.ts index 78d72d76..9fd2f798 100644 --- a/httpsig/mod.test.ts +++ b/httpsig/mod.test.ts @@ -168,6 +168,9 @@ Deno.test("getKeyOwner()", async () => { }), ); + const owner3 = await getKeyOwner(publicKey1, mockDocumentLoader); + assertEquals(owner3, owner2); + const noOwner = await getKeyOwner( new URL("https://example.com/key2"), mockDocumentLoader, diff --git a/httpsig/mod.ts b/httpsig/mod.ts index f28f3c43..c323aced 100644 --- a/httpsig/mod.ts +++ b/httpsig/mod.ts @@ -228,32 +228,38 @@ export async function doesActorOwnKey( /** * Gets the actor that owns the specified key. Returns `null` if the key has no known owner. * - * @param keyId The ID of the key to check. + * @param keyId The ID of the key to check, or the key itself. * @param documentLoader The document loader to use for fetching the key and its owner. * @returns The actor that owns the key, or `null` if the key has no known owner. * @sicne 0.7.0 */ export async function getKeyOwner( - keyId: URL, + keyId: URL | CryptographicKey, documentLoader: DocumentLoader, ): Promise { - let keyDoc: unknown; - try { - const { document } = await documentLoader(keyId.href); - keyDoc = document; - } catch (_) { - return null; - } let object: ASObject | CryptographicKey; - try { - object = await ASObject.fromJsonLd(keyDoc, { documentLoader }); - } catch (e) { - if (!(e instanceof TypeError)) throw e; + if (keyId instanceof CryptographicKey) { + object = keyId; + if (object.id == null) return null; + keyId = object.id; + } else { + let keyDoc: unknown; + try { + const { document } = await documentLoader(keyId.href); + keyDoc = document; + } catch (_) { + return null; + } try { - object = await CryptographicKey.fromJsonLd(keyDoc, { documentLoader }); + object = await ASObject.fromJsonLd(keyDoc, { documentLoader }); } catch (e) { - if (e instanceof TypeError) return null; - throw e; + if (!(e instanceof TypeError)) throw e; + try { + object = await CryptographicKey.fromJsonLd(keyDoc, { documentLoader }); + } catch (e) { + if (e instanceof TypeError) return null; + throw e; + } } } let owner: Actor | null = null; diff --git a/testing/context.ts b/testing/context.ts index 40cd1a57..f24436c0 100644 --- a/testing/context.ts +++ b/testing/context.ts @@ -56,5 +56,6 @@ export function createRequestContext( request: args.request ?? new Request(args.url), url: args.url, getSignedKey: args.getSignedKey ?? (() => Promise.resolve(null)), + getSignedKeyOwner: args.getSignedKeyOwner ?? (() => Promise.resolve(null)), }; } diff --git a/testing/fixtures/example.com/key3 b/testing/fixtures/example.com/key3 new file mode 100644 index 00000000..12f8f8f5 --- /dev/null +++ b/testing/fixtures/example.com/key3 @@ -0,0 +1,7 @@ +{ + "@context": "https://w3id.org/security/v1", + "id": "https://example.com/key3", + "type": "CryptographicKey", + "owner": "https://example.com/person2", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4GUqWgdiYlN3Su5Gr4l6\ni+xRS8gDDVKZ718vpGk6eIpvqs33q430nRbHIzbHRXRaAhc/1++rUBcK0V4/kjZl\nCSzVtRgGU6HMkmjcD+uE56a8XbTczfltbEDj7afoEuB2F3UhQEWrSz+QJ29DPXaL\nMIa1Yv61NR2vxGqNbdtoMjDORMBYtg77CYbcFkiJHw65PDa7+f/yjLxuCRPye5L7\nhncN0UZuuFoRJmHNRLSg5omBad9WTvQXmSyXEhEdk9fHwlI022AqAzlWbT79hldc\nDSKGGLLbQIs1c3JZIG8G5i6Uh5Vy0Z7tSNBcxbhqoI9i9je4f/x/OPIVc19f04BE\n1LgWuHsftZzRgW9Sdqz53W83XxVdxlyHeywXOnstSWT11f8dkLyQUcHKTH+E6urb\nH+aiPLiRpYK8W7D9KTQA9kZ5JXaEuveBd5vJX7wakhbzAn8pWJU7GYIHNY38Ycok\nmivkU5pY8S2cKFMwY0b7ade3MComlir5P3ZYSjF+n6gRVsT96P+9mNfCu9gXt/f8\nXCyjKlH89kGwuJ7HhR8CuVdm0l+jYozVt6GsDy0hHYyn79NCCAEzP7ZbhBMR0T5V\nrkl+TIGXoJH9WFiz4VxO+NnglF6dNQjDS5IzYLoFRXIK1f3cmQiEB4FZmL70l9HL\nrgwR+Xys83xia79OqFDRezMCAwEAAQ==\n-----END PUBLIC KEY-----\n" +} diff --git a/testing/fixtures/example.com/person2 b/testing/fixtures/example.com/person2 new file mode 100644 index 00000000..fa35e3c9 --- /dev/null +++ b/testing/fixtures/example.com/person2 @@ -0,0 +1,17 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://example.com/person2", + "type": "Person", + "name": "Jane Doe", + "publicKey": [ + { + "id": "https://example.com/key3", + "type": "CryptographicKey", + "owner": "https://example.com/person2", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4GUqWgdiYlN3Su5Gr4l6\ni+xRS8gDDVKZ718vpGk6eIpvqs33q430nRbHIzbHRXRaAhc/1++rUBcK0V4/kjZl\nCSzVtRgGU6HMkmjcD+uE56a8XbTczfltbEDj7afoEuB2F3UhQEWrSz+QJ29DPXaL\nMIa1Yv61NR2vxGqNbdtoMjDORMBYtg77CYbcFkiJHw65PDa7+f/yjLxuCRPye5L7\nhncN0UZuuFoRJmHNRLSg5omBad9WTvQXmSyXEhEdk9fHwlI022AqAzlWbT79hldc\nDSKGGLLbQIs1c3JZIG8G5i6Uh5Vy0Z7tSNBcxbhqoI9i9je4f/x/OPIVc19f04BE\n1LgWuHsftZzRgW9Sdqz53W83XxVdxlyHeywXOnstSWT11f8dkLyQUcHKTH+E6urb\nH+aiPLiRpYK8W7D9KTQA9kZ5JXaEuveBd5vJX7wakhbzAn8pWJU7GYIHNY38Ycok\nmivkU5pY8S2cKFMwY0b7ade3MComlir5P3ZYSjF+n6gRVsT96P+9mNfCu9gXt/f8\nXCyjKlH89kGwuJ7HhR8CuVdm0l+jYozVt6GsDy0hHYyn79NCCAEzP7ZbhBMR0T5V\nrkl+TIGXoJH9WFiz4VxO+NnglF6dNQjDS5IzYLoFRXIK1f3cmQiEB4FZmL70l9HL\nrgwR+Xys83xia79OqFDRezMCAwEAAQ==\n-----END PUBLIC KEY-----\n" + } + ] +} diff --git a/testing/keys.ts b/testing/keys.ts index 250b9e1b..9f862a19 100644 --- a/testing/keys.ts +++ b/testing/keys.ts @@ -32,8 +32,8 @@ export const publicKey1 = new CryptographicKey({ export const privateKey2 = await crypto.subtle.importKey( "jwk", { - "kty": "RSA", - "alg": "RS256", + kty: "RSA", + alg: "RS256", // cSpell: disable n: "oRmBtnxbdFutoRd1GLGwwGTrsqlRRWUe11hHQaoRLGf5LwQ0tIc6I9q-dynliw-2kxY" + "sLn9SH2je6HcTYOolgW7F_cOWXZQN04b-OiYcU1ConAhLjmn4k1uKawJ614y0ScPNd8P" + @@ -96,3 +96,97 @@ export const publicKey2 = new CryptographicKey({ ["verify"], ), }); + +export const privateKey3 = await crypto.subtle.importKey( + "jwk", + { + kty: "RSA", + alg: "RS256", + // cSpell: disable + n: "4GUqWgdiYlN3Su5Gr4l6i-xRS8gDDVKZ718vpGk6eIpvqs33q430nRbHIzbHRXRaAhc_" + + "1--rUBcK0V4_kjZlCSzVtRgGU6HMkmjcD-uE56a8XbTczfltbEDj7afoEuB2F3UhQEWrS" + + "z-QJ29DPXaLMIa1Yv61NR2vxGqNbdtoMjDORMBYtg77CYbcFkiJHw65PDa7-f_yjLxuCR" + + "Pye5L7hncN0UZuuFoRJmHNRLSg5omBad9WTvQXmSyXEhEdk9fHwlI022AqAzlWbT79hld" + + "cDSKGGLLbQIs1c3JZIG8G5i6Uh5Vy0Z7tSNBcxbhqoI9i9je4f_x_OPIVc19f04BE1LgW" + + "uHsftZzRgW9Sdqz53W83XxVdxlyHeywXOnstSWT11f8dkLyQUcHKTH-E6urbH-aiPLiRp" + + "YK8W7D9KTQA9kZ5JXaEuveBd5vJX7wakhbzAn8pWJU7GYIHNY38YcokmivkU5pY8S2cKF" + + "MwY0b7ade3MComlir5P3ZYSjF-n6gRVsT96P-9mNfCu9gXt_f8XCyjKlH89kGwuJ7HhR8" + + "CuVdm0l-jYozVt6GsDy0hHYyn79NCCAEzP7ZbhBMR0T5Vrkl-TIGXoJH9WFiz4VxO-Nng" + + "lF6dNQjDS5IzYLoFRXIK1f3cmQiEB4FZmL70l9HLrgwR-Xys83xia79OqFDRezM", + e: "AQAB", + d: "HJ_LD0Dx4-kRxpUunyXCZCb5F9mjygdHa6mQwkBKHSZLqFYtycyJ76AANxW9xbZZ5Ppi" + + "QoFoMQc_cgW7xkL6EHmPqVIvPGvfVK3bpIw-n-49CRcRM5UlyDFe4eoRSJcpeUSPwUsh1" + + "q99DAq9YRHGH6KPcNlc9DGdQkj1UZYzbHOdXFfM-SxgCY8SdCU8mKGgL3Yr9HAZ2KoQv0" + + "e0Ht9ZBoYZVSDO7uVOWr8PGDySadYQlBjRQbERcZCmlL9qLnnQGZGy_Gj_8vlVdQob_Q8" + + "XxvUode4a2djoMJndlK2VC7fVapY910-WpTsvGmmz8FdaIF5rQqhK8lCvO9BmwOwT23Ga" + + "DVs0iMpFrVqQ6c0ZwD5Q-c39U8HE3-mlSyyz5kdsa1OdcJ64JSJH7Xl_vwLRgFgO7pPQh" + + "Pm1N1XkDUgZOHAE6Hg-PEM3QdXSWnj2_znildsdUagf-1RsWVouVBSDNjUk5MQFPERW4w" + + "H2ersndnkvYe7FeU_HfkIi2A9xBr7Ti4O-MPU0sl_HdZ9PXGhzSMpMTB9NSeMpi9gd1ZZ" + + "KmAe_t3mj1x6m2qXBv-Z05Gifae82YcaghuqkZ6QCxlpNxwbeZjb0vmMVqsds_qLQ4eg9" + + "Ww-R-8AlWm5HGCOrdJk4JnHZ5HzrFtwCj55cuutd6gHAq3Q1cbaKVO8rM_Ve5cE", + p: "8P4sJeDvEDJFMxARDuzmztlwdPtGfmHBvtzDFCm5UbL1w8Ga3mSv7uQMDMuc9vBqAq2J" + + "d9d4MDwlXtUrk8Q9weuwgGWKLFrsmdgwH6-shNrHzar1Tojf9Fgi-t-Tr_PQwKnyioaKQ" + + "htPNCbishGRt0GJuLq5ag0RPPtHXIX8Ch-00ppL1yW3wFpPBBiOV7yPLEhwowa33yPYEl" + + "TsASNiDKKtS4c_EyzdRWuJjtMZmEPPWfXvrOQSqXR6KY71tOOuo1S7ZkSZibcf88IILSm" + + "aAdzidgkZWlazjBBmNo54KaT6j6x5EMSr6EyafW8tBU-bCLVs__Hap_kSYsQw95l8Fw", + q: "7l5l9KqwDE0uBPKlXzpdg6rZ1dDuInnx0QTp_XUj8GRaeVCP1JfLJoa38-lN39AoAGUv" + + "ndiHpnXkKKbKizcR5WY-6LAUFkAcXit7NP4fHPDLyiOZvTblZEryCbDS6jJrF5ii7rBXc" + + "C8LziZEBce-I7W9CZHUwOXfDAJNeslqDVZ9tlFXezjXZnqiBo3eq0hWQxO7CweZv0qE4G" + + "xMjs0E6IndQk8SAXA5RRbyoFzfChOyRtKOf9gxXOhwTKcKqWkjhK3xdpwcmHDpWycRShu" + + "r-hazt6yBCUbJ402nIxQRfOwdRIgfRO2K5-O54RzF7UkzHX3zeQdZIuzLeXGgu13fRQ", + dp: + "qeITTxR0kg9N9sQRslrQDer8OorT082n3ZsULceH6w7j7v4w5StHVnkOAYsbeHxbzs10h" + + "bWv9RjBI0vUb1M8UdKK1sg9kiz6cy0SJ5QYYoMzrEkiqh0U-tOSvRUUsEmI0_g5kOts1V" + + "MZD2OGFQ8LkIqzwjRm9lqF114vnQqadKyLNJcudVkSYpeG8hU5aqHyr73VISdgQP2smKe" + + "iwt6lhNC8puyNS0AqL4CyNKuddFgA-KLFNTSF70y7vUYY8U47UsotXNdpAMrFzHjweJ3G" + + "AiAqyBh79dH-ufLpivX9wSWat-NWaLqrkJNHqLrRmtfWK1pxny9n-1c6XcN93V0mOw", + dq: + "n_-xA_eGT9uGZj_RDQiKOJT3vvOMxIuB60EXJs_4HaXerMuMn7B75hJLa2dQpEh-cTV6L" + + "sNm2i8LxNWf4q5GTurAk0ONWBoUcIlTHBDvJWfkAny-9yjf9N_xctvD1vucsqv7waeQKX" + + "cKv4cj5ZVbZXDZwJCodApYGyF4jFCh5O4HV9dllwpiWyE5nJihu-rELCYUSKUDaElGw7U" + + "t9jRbdRME9ztH5LtFVcC_fzCXbZYm9i7jA6FEEQ7cQjdliq1N8AMprum-r_wqRssEafAF" + + "EcsnOsSJoIZpgS9gXsVbr7R1OMj95DBmKpzK6fV8TXfy3XrrcHOkOzMiqRPCRcIO2Q", + qi: "LhkzXD7HOowopZpKmEklXoAZlZV-tPqZm9m_0fkh-dXoFaHfIj-eXUr-7Z8yJWx4-nn" + + "GITZR28Q10QHvnG5phqhvgUJ2nHixqaphXfYt80nictRfVcRW4bN_oHm-87zK0BS5OjJv" + + "LoMYKSREvdz1UqVJxA4jKsYURCIj6KSDJyZsd5ENAQWjxs0jEw73sKPT-J-ePCEz05V-e" + + "uBp6RBSePnu3rphrtZ54MNnShyqhoimnymNqx7iXTFBdIP2OKRmzSyKMpwBLU54tctXIO" + + "lv_l89gN7V6F8_I0q359M9tdGmmqxjIzAKm9USP7jfcoejXXt3lpSglwKpEfQBfFF9yg", + // cSpell: enable + key_ops: ["sign"], + ext: true, + }, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + true, + ["sign"], +); + +export const publicKey3 = new CryptographicKey({ + id: new URL("https://example.com/key3"), + owner: new URL("https://example.com/person2"), + publicKey: await crypto.subtle.importKey( + "jwk", + { + kty: "RSA", + alg: "RS256", + // cSpell: disable + n: "4GUqWgdiYlN3Su5Gr4l6i-xRS8gDDVKZ718vpGk6eIpvqs33q430nRbHIzbHRXRaAh" + + "c_1--rUBcK0V4_kjZlCSzVtRgGU6HMkmjcD-uE56a8XbTczfltbEDj7afoEuB2F3UhQ" + + "EWrSz-QJ29DPXaLMIa1Yv61NR2vxGqNbdtoMjDORMBYtg77CYbcFkiJHw65PDa7-f_y" + + "jLxuCRPye5L7hncN0UZuuFoRJmHNRLSg5omBad9WTvQXmSyXEhEdk9fHwlI022AqAzl" + + "WbT79hldcDSKGGLLbQIs1c3JZIG8G5i6Uh5Vy0Z7tSNBcxbhqoI9i9je4f_x_OPIVc1" + + "9f04BE1LgWuHsftZzRgW9Sdqz53W83XxVdxlyHeywXOnstSWT11f8dkLyQUcHKTH-E6" + + "urbH-aiPLiRpYK8W7D9KTQA9kZ5JXaEuveBd5vJX7wakhbzAn8pWJU7GYIHNY38Ycok" + + "mivkU5pY8S2cKFMwY0b7ade3MComlir5P3ZYSjF-n6gRVsT96P-9mNfCu9gXt_f8XCy" + + "jKlH89kGwuJ7HhR8CuVdm0l-jYozVt6GsDy0hHYyn79NCCAEzP7ZbhBMR0T5Vrkl-TI" + + "GXoJH9WFiz4VxO-NnglF6dNQjDS5IzYLoFRXIK1f3cmQiEB4FZmL70l9HLrgwR-Xys8" + + "3xia79OqFDRezM", + // cSpell: enable + e: "AQAB", + key_ops: ["verify"], + ext: true, + }, + { "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" }, + true, + ["verify"], + ), +});