summary refs log tree commit diff
path: root/src/activitypub/federation/HttpSig.ts
blob: ce34b98e65d0c2d24f54b757dcd9f165b57a90aa (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { Config, FederationKey, OrmUtils } from "@spacebar/util";
import { APActivity } from "activitypub-types";
import crypto from "crypto";
import { IncomingHttpHeaders } from "http";
import { RequestInit } from "node-fetch";
import { APError, fetchFederatedUser, fetchOpts } from "./utils";

export class HttpSig {
	private static getSignString<T extends IncomingHttpHeaders>(
		target: string,
		method: string,
		headers: T,
		names: string[],
	) {
		const requestTarget = `${method.toLowerCase()} ${target}`;
		headers = {
			...headers,
			"(request-target)": requestTarget,
		};

		return names
			.map((header) => `${header.toLowerCase()}: ${headers[header]}`)
			.join("\n");
	}

	public static async validate(
		target: string,
		activity: APActivity,
		requestHeaders: IncomingHttpHeaders,
	) {
		const sigheader = requestHeaders["signature"]?.toString();
		if (!sigheader) throw new APError("Missing signature");
		const sigopts: { [key: string]: string | undefined } = Object.assign(
			{},
			...sigheader.split(",").flatMap((keyval) => {
				const split = keyval.split("=");
				return {
					[split[0]]: split[1].replaceAll('"', ""),
				};
			}),
		);

		const { signature, headers, keyId, algorithm } = sigopts;

		if (!signature || !headers || !keyId)
			throw new APError("Invalid signature");

		const ALLOWED_ALGO = "rsa-sha256";

		// If it's provided, check it. otherwise just assume it's sha256
		if (algorithm && algorithm != ALLOWED_ALGO)
			throw new APError(`Unsupported encryption algorithm ${algorithm}`);

		const url = new URL(keyId);
		const actorId = `${url.origin}${url.pathname}`; // likely wrong

		const remoteUser = await fetchFederatedUser(actorId);

		const expected = this.getSignString(
			target,
			"post",
			requestHeaders,
			headers.split(/\s+/),
		);

		const verifier = crypto.createVerify(
			algorithm?.toUpperCase() || ALLOWED_ALGO,
		);
		verifier.write(expected);
		verifier.end();

		return verifier.verify(
			remoteUser.keys.publicKey,
			Buffer.from(signature, "base64"),
		);
	}

	/**
	 * Returns a signed request that can be passed to fetch
	 * ```
	 * const signed = await signActivity(receiver.inbox, sender, activity);
	 * await fetch(receiver.inbox, signed);
	 * ```
	 */
	public static sign(
		inbox: string,
		sender: FederationKey,
		message: APActivity,
	) {
		if (!sender.privateKey)
			throw new APError("cannot sign without private key");

		const digest = crypto
			.createHash("sha256")
			.update(JSON.stringify(message))
			.digest("base64");
		const signer = crypto.createSign("sha256");
		const now = new Date();

		const url = new URL(inbox);
		const inboxFrag = url.pathname;
		const toSign =
			`(request-target): post ${inboxFrag}\n` +
			`host: ${url.hostname}\n` +
			`date: ${now.toUTCString()}\n` +
			`digest: SHA-256=${digest}`;

		signer.update(toSign);
		signer.end();

		const signature = signer.sign(sender.privateKey);
		const sig_b64 = signature.toString("base64");

		const { host } = Config.get().federation;
		const header =
			`keyId="https://${host}/federation/${sender.type}/${sender.actorId}",` +
			`headers="(request-target) host date digest",` +
			`signature=${sig_b64}`;

		return OrmUtils.mergeDeep(fetchOpts, {
			method: "POST",
			body: JSON.stringify(message),
			headers: {
				Host: url.hostname,
				Date: now.toUTCString(),
				Digest: `SHA-256=${digest}`,
				Signature: header,
			},
		} as RequestInit);
	}
}