@dwk/activitypub 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +135 -0
- package/dist/as2.d.ts +117 -0
- package/dist/as2.d.ts.map +1 -0
- package/dist/as2.js +174 -0
- package/dist/as2.js.map +1 -0
- package/dist/config.d.ts +148 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +142 -0
- package/dist/config.js.map +1 -0
- package/dist/delivery.d.ts +43 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +131 -0
- package/dist/delivery.js.map +1 -0
- package/dist/handler.d.ts +21 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +293 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +57 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +53 -0
- package/dist/log.js.map +1 -0
- package/dist/nodeinfo.d.ts +33 -0
- package/dist/nodeinfo.d.ts.map +1 -0
- package/dist/nodeinfo.js +61 -0
- package/dist/nodeinfo.js.map +1 -0
- package/dist/object.d.ts +21 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/object.js +722 -0
- package/dist/object.js.map +1 -0
- package/dist/signature.d.ts +108 -0
- package/dist/signature.d.ts.map +1 -0
- package/dist/signature.js +234 -0
- package/dist/signature.js.map +1 -0
- package/package.json +50 -0
- package/src/as2.ts +257 -0
- package/src/config.ts +291 -0
- package/src/delivery.ts +155 -0
- package/src/handler.ts +370 -0
- package/src/index.ts +90 -0
- package/src/log.ts +62 -0
- package/src/nodeinfo.ts +91 -0
- package/src/object.ts +883 -0
- package/src/signature.ts +355 -0
package/src/signature.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Message Signatures for ActivityPub server-to-server traffic.
|
|
3
|
+
*
|
|
4
|
+
* The fediverse authenticates `POST /inbox` deliveries with the legacy
|
|
5
|
+
* `draft-cavage-http-signatures` "Signature" profile (RSA-SHA256 over a covered
|
|
6
|
+
* header set, with body integrity carried by a `Digest` header). This module
|
|
7
|
+
* implements **sign** and **verify** for that profile over WebCrypto, with an
|
|
8
|
+
* RSA-only algorithm allow-list mirroring the `@dwk/dpop` hardening posture (no
|
|
9
|
+
* `none`, no symmetric algorithms).
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately small and self-contained so the package functions and
|
|
12
|
+
* federates today; it is structured behind the `verifyInboxSignature` config
|
|
13
|
+
* seam (see `config.ts`) so the forthcoming cross-standard `@dwk/http-signatures`
|
|
14
|
+
* package (RFC 9421 + draft-cavage; issue #59) can be swapped in unchanged once
|
|
15
|
+
* it lands.
|
|
16
|
+
*
|
|
17
|
+
* Inputs are plain data — method, URL, headers, body bytes, and a resolved key —
|
|
18
|
+
* so the crypto unit-tests without any ActivityPub assumptions.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The only signature algorithm v1 accepts (RSASSA-PKCS1-v1_5 + SHA-256). */
|
|
22
|
+
const ALGORITHM = "rsa-sha256";
|
|
23
|
+
|
|
24
|
+
const RSA_PARAMS = {
|
|
25
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
26
|
+
hash: "SHA-256",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Default tolerance (seconds) for clock skew on the signed `Date` header. */
|
|
30
|
+
export const DEFAULT_CLOCK_SKEW_SECONDS = 300;
|
|
31
|
+
|
|
32
|
+
/** Decode a base64 string to bytes. */
|
|
33
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
34
|
+
const binary = atob(b64.trim());
|
|
35
|
+
const bytes = new Uint8Array(binary.length);
|
|
36
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encode bytes to a base64 string. */
|
|
41
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
42
|
+
let binary = "";
|
|
43
|
+
for (let i = 0; i < bytes.length; i++)
|
|
44
|
+
binary += String.fromCharCode(bytes[i] as number);
|
|
45
|
+
return btoa(binary);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strip a PEM envelope (`-----BEGIN …-----`) to its base64 DER payload. */
|
|
49
|
+
function pemBody(pem: string): string {
|
|
50
|
+
return pem
|
|
51
|
+
.replace(/-----BEGIN [^-]+-----/, "")
|
|
52
|
+
.replace(/-----END [^-]+-----/, "")
|
|
53
|
+
.replace(/\s+/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Import an SPKI (public) RSA key from PEM for signature verification. */
|
|
57
|
+
export async function importPublicKey(pem: string): Promise<CryptoKey> {
|
|
58
|
+
const der = base64ToBytes(pemBody(pem));
|
|
59
|
+
return crypto.subtle.importKey(
|
|
60
|
+
"spki",
|
|
61
|
+
der as BufferSource,
|
|
62
|
+
RSA_PARAMS,
|
|
63
|
+
false,
|
|
64
|
+
["verify"],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Import a PKCS#8 (private) RSA key from PEM for signing. */
|
|
69
|
+
export async function importPrivateKey(pem: string): Promise<CryptoKey> {
|
|
70
|
+
const der = base64ToBytes(pemBody(pem));
|
|
71
|
+
return crypto.subtle.importKey(
|
|
72
|
+
"pkcs8",
|
|
73
|
+
der as BufferSource,
|
|
74
|
+
RSA_PARAMS,
|
|
75
|
+
false,
|
|
76
|
+
["sign"],
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Compute the `Digest` header value (`SHA-256=<base64>`) for a body. */
|
|
81
|
+
export async function digestHeader(body: Uint8Array): Promise<string> {
|
|
82
|
+
const hash = await crypto.subtle.digest("SHA-256", body as BufferSource);
|
|
83
|
+
return `SHA-256=${bytesToBase64(new Uint8Array(hash))}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse the comma-separated `key="value"` pairs of a `Signature` header. */
|
|
87
|
+
export function parseSignatureHeader(header: string): Record<string, string> {
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
// Values are quoted strings; split on commas that are followed by `key=`.
|
|
90
|
+
const re = /([a-zA-Z]+)="([^"]*)"/g;
|
|
91
|
+
let match: RegExpExecArray | null;
|
|
92
|
+
while ((match = re.exec(header)) !== null) {
|
|
93
|
+
const key = match[1] as string;
|
|
94
|
+
const value = match[2] as string;
|
|
95
|
+
out[key] = value;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reconstruct the draft-cavage signing string from the covered-component list.
|
|
102
|
+
* `(request-target)` expands to `"<method-lowercase> <path-and-query>"`; every
|
|
103
|
+
* other token is the lowercased request header's value. A listed component with
|
|
104
|
+
* no corresponding header value makes the base unconstructable (`null`).
|
|
105
|
+
*/
|
|
106
|
+
export function buildSigningString(
|
|
107
|
+
coveredHeaders: readonly string[],
|
|
108
|
+
parts: {
|
|
109
|
+
readonly method: string;
|
|
110
|
+
readonly path: string;
|
|
111
|
+
readonly headers: Headers;
|
|
112
|
+
},
|
|
113
|
+
): string | null {
|
|
114
|
+
const lines: string[] = [];
|
|
115
|
+
for (const name of coveredHeaders) {
|
|
116
|
+
if (name === "(request-target)") {
|
|
117
|
+
lines.push(
|
|
118
|
+
`(request-target): ${parts.method.toLowerCase()} ${parts.path}`,
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const value = parts.headers.get(name);
|
|
123
|
+
if (value === null) return null;
|
|
124
|
+
lines.push(`${name}: ${value}`);
|
|
125
|
+
}
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A resolved verification key plus the actor IRI that owns it. */
|
|
130
|
+
export interface ResolvedKey {
|
|
131
|
+
/** The actor IRI that owns the key (`publicKey.owner`). */
|
|
132
|
+
readonly owner: string;
|
|
133
|
+
/** PEM-encoded SPKI public key. */
|
|
134
|
+
readonly publicKeyPem: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resolve a `keyId` to its owning actor + PEM public key (caller-supplied). */
|
|
138
|
+
export type KeyResolver = (
|
|
139
|
+
keyId: string,
|
|
140
|
+
) => Promise<ResolvedKey | null> | ResolvedKey | null;
|
|
141
|
+
|
|
142
|
+
/** Machine-readable reason an inbound signature was rejected. */
|
|
143
|
+
export type VerifyFailureReason =
|
|
144
|
+
| "missing_signature"
|
|
145
|
+
| "unsupported_algorithm"
|
|
146
|
+
| "missing_covered_header"
|
|
147
|
+
| "unconstructable_base"
|
|
148
|
+
| "missing_digest"
|
|
149
|
+
| "digest_mismatch"
|
|
150
|
+
| "stale_date"
|
|
151
|
+
| "key_unresolved"
|
|
152
|
+
| "bad_key"
|
|
153
|
+
| "signature_invalid";
|
|
154
|
+
|
|
155
|
+
/** Outcome of {@link verifyInboxSignature}. */
|
|
156
|
+
export type VerifyResult =
|
|
157
|
+
| { readonly ok: true; readonly keyId: string; readonly actor: string }
|
|
158
|
+
| { readonly ok: false; readonly reason: VerifyFailureReason };
|
|
159
|
+
|
|
160
|
+
/** What {@link verifyInboxSignature} needs about the inbound request. */
|
|
161
|
+
export interface InboxRequest {
|
|
162
|
+
readonly method: string;
|
|
163
|
+
/** Path + query the signer covered via `(request-target)`. */
|
|
164
|
+
readonly path: string;
|
|
165
|
+
readonly headers: Headers;
|
|
166
|
+
/** The already-buffered request body (inbox bodies are small). */
|
|
167
|
+
readonly body: Uint8Array;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Tunables for {@link verifyInboxSignature}. */
|
|
171
|
+
export interface VerifyOptions {
|
|
172
|
+
readonly clockSkewSeconds?: number;
|
|
173
|
+
readonly now?: () => number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Verify a draft-cavage HTTP signature on an inbound `POST /inbox`.
|
|
178
|
+
*
|
|
179
|
+
* Checks, in order: a supported algorithm; that `(request-target)`, `host`,
|
|
180
|
+
* `date`, and `digest` are all covered (so neither the target nor the body can
|
|
181
|
+
* be swapped under the signature); that the `Digest` matches the body; that the
|
|
182
|
+
* signed `Date` is within the skew window (replay bound); then the RSA
|
|
183
|
+
* signature itself against the resolved key. On success it returns the verified
|
|
184
|
+
* `keyId` and its owning actor IRI for the front door to hand to the DO.
|
|
185
|
+
*/
|
|
186
|
+
export async function verifyInboxSignature(
|
|
187
|
+
request: InboxRequest,
|
|
188
|
+
resolveKey: KeyResolver,
|
|
189
|
+
options: VerifyOptions = {},
|
|
190
|
+
): Promise<VerifyResult> {
|
|
191
|
+
const sigHeader = request.headers.get("signature");
|
|
192
|
+
if (!sigHeader) return { ok: false, reason: "missing_signature" };
|
|
193
|
+
|
|
194
|
+
const fields = parseSignatureHeader(sigHeader);
|
|
195
|
+
const keyId = fields.keyId;
|
|
196
|
+
const signatureB64 = fields.signature;
|
|
197
|
+
if (!keyId || !signatureB64)
|
|
198
|
+
return { ok: false, reason: "missing_signature" };
|
|
199
|
+
|
|
200
|
+
const algorithm = (fields.algorithm ?? ALGORITHM).toLowerCase();
|
|
201
|
+
// `hs2019` is the RFC-era opaque label; we still only do RSA-SHA256 underneath.
|
|
202
|
+
if (algorithm !== ALGORITHM && algorithm !== "hs2019")
|
|
203
|
+
return { ok: false, reason: "unsupported_algorithm" };
|
|
204
|
+
|
|
205
|
+
const covered = (fields.headers ?? "(request-target) host date")
|
|
206
|
+
.toLowerCase()
|
|
207
|
+
.split(/\s+/)
|
|
208
|
+
.filter((h) => h.length > 0);
|
|
209
|
+
|
|
210
|
+
// The covered set MUST bind the target, the body, and a timestamp; otherwise a
|
|
211
|
+
// valid signature could be lifted onto a different request or a stale one.
|
|
212
|
+
for (const required of ["(request-target)", "host", "date", "digest"]) {
|
|
213
|
+
if (!covered.includes(required))
|
|
214
|
+
return { ok: false, reason: "missing_covered_header" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const expectedDigest = await digestHeader(request.body);
|
|
218
|
+
const presentedDigest = request.headers.get("digest");
|
|
219
|
+
if (!presentedDigest) return { ok: false, reason: "missing_digest" };
|
|
220
|
+
if (!digestsEqual(presentedDigest, expectedDigest))
|
|
221
|
+
return { ok: false, reason: "digest_mismatch" };
|
|
222
|
+
|
|
223
|
+
const dateHeader = request.headers.get("date");
|
|
224
|
+
if (!dateHeader || !dateWithinSkew(dateHeader, options))
|
|
225
|
+
return { ok: false, reason: "stale_date" };
|
|
226
|
+
|
|
227
|
+
const signingString = buildSigningString(covered, request);
|
|
228
|
+
if (signingString === null)
|
|
229
|
+
return { ok: false, reason: "unconstructable_base" };
|
|
230
|
+
|
|
231
|
+
const resolved = await resolveKey(keyId);
|
|
232
|
+
if (!resolved) return { ok: false, reason: "key_unresolved" };
|
|
233
|
+
|
|
234
|
+
let key: CryptoKey;
|
|
235
|
+
try {
|
|
236
|
+
key = await importPublicKey(resolved.publicKeyPem);
|
|
237
|
+
} catch {
|
|
238
|
+
return { ok: false, reason: "bad_key" };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let signature: Uint8Array;
|
|
242
|
+
try {
|
|
243
|
+
signature = base64ToBytes(signatureB64);
|
|
244
|
+
} catch {
|
|
245
|
+
return { ok: false, reason: "signature_invalid" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const verified = await crypto.subtle.verify(
|
|
249
|
+
RSA_PARAMS.name,
|
|
250
|
+
key,
|
|
251
|
+
signature as BufferSource,
|
|
252
|
+
new TextEncoder().encode(signingString) as BufferSource,
|
|
253
|
+
);
|
|
254
|
+
if (!verified) return { ok: false, reason: "signature_invalid" };
|
|
255
|
+
|
|
256
|
+
return { ok: true, keyId, actor: resolved.owner };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A signed outbound request: the headers to send plus the body to send them with. */
|
|
260
|
+
export interface SignedRequest {
|
|
261
|
+
readonly headers: Record<string, string>;
|
|
262
|
+
readonly body: Uint8Array;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Material needed to sign an outbound delivery. */
|
|
266
|
+
export interface SignerKey {
|
|
267
|
+
/** The `keyId` published in the actor document (`<actor>#main-key`). */
|
|
268
|
+
readonly keyId: string;
|
|
269
|
+
/** PEM-encoded PKCS#8 private key. */
|
|
270
|
+
readonly privateKeyPem: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sign an outbound `POST` (a delivery to a remote inbox) with the draft-cavage
|
|
275
|
+
* profile. Computes the body `Digest`, covers
|
|
276
|
+
* `(request-target) host date digest content-type`, and returns the full header
|
|
277
|
+
* set — `Host`, `Date`, `Digest`, `Content-Type`, and `Signature` — to send.
|
|
278
|
+
*/
|
|
279
|
+
export async function signRequest(
|
|
280
|
+
url: string,
|
|
281
|
+
body: Uint8Array,
|
|
282
|
+
signer: SignerKey,
|
|
283
|
+
options: { readonly now?: () => number; readonly contentType?: string } = {},
|
|
284
|
+
): Promise<SignedRequest> {
|
|
285
|
+
const now = options.now ?? (() => Date.now());
|
|
286
|
+
const target = new URL(url);
|
|
287
|
+
const path = `${target.pathname}${target.search}`;
|
|
288
|
+
const contentType = options.contentType ?? "application/activity+json";
|
|
289
|
+
const date = new Date(now()).toUTCString();
|
|
290
|
+
const digest = await digestHeader(body);
|
|
291
|
+
|
|
292
|
+
const headers = new Headers({
|
|
293
|
+
host: target.host,
|
|
294
|
+
date,
|
|
295
|
+
digest,
|
|
296
|
+
"content-type": contentType,
|
|
297
|
+
});
|
|
298
|
+
const covered = [
|
|
299
|
+
"(request-target)",
|
|
300
|
+
"host",
|
|
301
|
+
"date",
|
|
302
|
+
"digest",
|
|
303
|
+
"content-type",
|
|
304
|
+
];
|
|
305
|
+
const signingString = buildSigningString(covered, {
|
|
306
|
+
method: "post",
|
|
307
|
+
path,
|
|
308
|
+
headers,
|
|
309
|
+
});
|
|
310
|
+
// The covered headers are all set above, so the base is always constructable.
|
|
311
|
+
const key = await importPrivateKey(signer.privateKeyPem);
|
|
312
|
+
const raw = await crypto.subtle.sign(
|
|
313
|
+
RSA_PARAMS.name,
|
|
314
|
+
key,
|
|
315
|
+
new TextEncoder().encode(signingString as string) as BufferSource,
|
|
316
|
+
);
|
|
317
|
+
const signatureB64 = bytesToBase64(new Uint8Array(raw));
|
|
318
|
+
const signatureHeader =
|
|
319
|
+
`keyId="${signer.keyId}",algorithm="${ALGORITHM}",` +
|
|
320
|
+
`headers="${covered.join(" ")}",signature="${signatureB64}"`;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
headers: {
|
|
324
|
+
Host: target.host,
|
|
325
|
+
Date: date,
|
|
326
|
+
Digest: digest,
|
|
327
|
+
"Content-Type": contentType,
|
|
328
|
+
Signature: signatureHeader,
|
|
329
|
+
},
|
|
330
|
+
body,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Compare two `Digest` header values for the `SHA-256` algorithm. */
|
|
335
|
+
function digestsEqual(presented: string, expected: string): boolean {
|
|
336
|
+
// A peer may send multiple algorithms; match the SHA-256 component only.
|
|
337
|
+
const want = expected.slice("SHA-256=".length);
|
|
338
|
+
for (const part of presented.split(",")) {
|
|
339
|
+
const trimmed = part.trim();
|
|
340
|
+
if (/^sha-256=/i.test(trimmed)) {
|
|
341
|
+
return trimmed.slice("SHA-256=".length) === want;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Whether a signed `Date` header is within the accepted skew of now. */
|
|
348
|
+
function dateWithinSkew(dateHeader: string, options: VerifyOptions): boolean {
|
|
349
|
+
const signed = Date.parse(dateHeader);
|
|
350
|
+
if (Number.isNaN(signed)) return false;
|
|
351
|
+
const now = (options.now ?? (() => Date.now()))();
|
|
352
|
+
const skewMs =
|
|
353
|
+
(options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS) * 1000;
|
|
354
|
+
return Math.abs(now - signed) <= skewMs;
|
|
355
|
+
}
|