Skip to content

Commit

Permalink
RequestContext.getSignedKeyOwner() method
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Apr 11, 2024
1 parent 737e080 commit b323337
Show file tree
Hide file tree
Showing 10 changed files with 200 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions federation/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ export interface RequestContext<TContextData> extends Context<TContextData> {
* @since 0.7.0
*/
getSignedKey(): Promise<CryptographicKey | null>;

/**
* 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<Actor | null>;
}

/**
Expand Down
31 changes: 30 additions & 1 deletion federation/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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/"),
Expand All @@ -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();
Expand Down
9 changes: 8 additions & 1 deletion federation/middleware.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -392,6 +392,7 @@ export class Federation<TContextData> {
};
if (request == null) return context;
let signedKey: CryptographicKey | null | undefined = undefined;
let signedKeyOwner: Actor | null | undefined = undefined;
const reqCtx: RequestContext<TContextData> = {
...context,
request,
Expand All @@ -400,6 +401,12 @@ export class Federation<TContextData> {
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;
}
Expand Down
3 changes: 3 additions & 0 deletions httpsig/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 22 additions & 16 deletions httpsig/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Actor | null> {
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;
Expand Down
1 change: 1 addition & 0 deletions testing/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ export function createRequestContext<TContextData>(
request: args.request ?? new Request(args.url),
url: args.url,
getSignedKey: args.getSignedKey ?? (() => Promise.resolve(null)),
getSignedKeyOwner: args.getSignedKeyOwner ?? (() => Promise.resolve(null)),
};
}
7 changes: 7 additions & 0 deletions testing/fixtures/example.com/key3
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions testing/fixtures/example.com/person2
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
98 changes: 96 additions & 2 deletions testing/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down Expand Up @@ -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"],
),
});

0 comments on commit b323337

Please sign in to comment.