@peerbit/server 7.0.1 → 8.0.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.
Files changed (49) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +87 -7
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +2 -0
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +83 -6
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/remotes.d.ts +2 -0
  9. package/dist/src/remotes.d.ts.map +1 -1
  10. package/dist/src/remotes.js +8 -1
  11. package/dist/src/remotes.js.map +1 -1
  12. package/dist/src/routes.d.ts +1 -0
  13. package/dist/src/routes.d.ts.map +1 -1
  14. package/dist/src/routes.js +1 -0
  15. package/dist/src/routes.js.map +1 -1
  16. package/dist/src/server.d.ts +8 -0
  17. package/dist/src/server.d.ts.map +1 -1
  18. package/dist/src/server.js +83 -20
  19. package/dist/src/server.js.map +1 -1
  20. package/dist/src/signed-request.d.ts +77 -3
  21. package/dist/src/signed-request.d.ts.map +1 -1
  22. package/dist/src/signed-request.js +420 -39
  23. package/dist/src/signed-request.js.map +1 -1
  24. package/dist/src/trust.d.ts.map +1 -1
  25. package/dist/src/trust.js +1 -0
  26. package/dist/src/trust.js.map +1 -1
  27. package/dist/test/api.spec.js +359 -3
  28. package/dist/test/api.spec.js.map +1 -1
  29. package/dist/test/cli.spec.js +90 -1
  30. package/dist/test/cli.spec.js.map +1 -1
  31. package/dist/test/remotes.spec.js +65 -1
  32. package/dist/test/remotes.spec.js.map +1 -1
  33. package/dist/test/signed-request.spec.js +204 -23
  34. package/dist/test/signed-request.spec.js.map +1 -1
  35. package/dist/test/trust.spec.d.ts +2 -0
  36. package/dist/test/trust.spec.d.ts.map +1 -0
  37. package/dist/test/trust.spec.js +29 -0
  38. package/dist/test/trust.spec.js.map +1 -0
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/dist/ui/assets/{index-CjZwCewh.js → index-DS8hXn9k.js} +6 -6
  41. package/dist/ui/index.html +1 -1
  42. package/package.json +6 -6
  43. package/src/cli.ts +102 -8
  44. package/src/client.ts +98 -10
  45. package/src/remotes.ts +12 -1
  46. package/src/routes.ts +1 -0
  47. package/src/server.ts +123 -21
  48. package/src/signed-request.ts +564 -50
  49. package/src/trust.ts +1 -0
@@ -23,7 +23,7 @@
23
23
  Learn how to configure a non-root public URL by running `npm run build`.
24
24
  -->
25
25
  <title>Peerbit</title>
26
- <script type="module" crossorigin src="/assets/index-CjZwCewh.js"></script>
26
+ <script type="module" crossorigin src="/assets/index-DS8hXn9k.js"></script>
27
27
  <link rel="stylesheet" crossorigin href="/assets/index-CIfVvUo9.css">
28
28
  </head>
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peerbit/server",
3
- "version": "7.0.1",
3
+ "version": "8.0.0",
4
4
  "author": "Peerbit contributors",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,7 +66,7 @@
66
66
  "shx": "^0.3.4",
67
67
  "uuid": "^11.1.1",
68
68
  "@peerbit/test-lib": "0.0.1",
69
- "@peerbit/test-utils": "3.1.8"
69
+ "@peerbit/test-utils": "3.1.10"
70
70
  },
71
71
  "dependencies": {
72
72
  "@dao-xyz/borsh": "^6.0.0",
@@ -89,12 +89,12 @@
89
89
  "tty-table": "^4.2.1",
90
90
  "uint8arrays": "^5.1.0",
91
91
  "yargs": "^17.7.2",
92
+ "@peerbit/program": "6.0.39",
92
93
  "@peerbit/crypto": "3.1.4",
94
+ "@peerbit/blocks": "4.2.6",
95
+ "@peerbit/pubsub": "5.3.4",
93
96
  "@peerbit/time": "3.0.1",
94
- "@peerbit/pubsub": "5.3.2",
95
- "@peerbit/blocks": "4.2.4",
96
- "@peerbit/program": "6.0.37",
97
- "peerbit": "5.3.8"
97
+ "peerbit": "5.3.10"
98
98
  },
99
99
  "bugs": {
100
100
  "url": "https://github.com/dao-xyz/peerbit/issues"
package/src/cli.ts CHANGED
@@ -482,13 +482,51 @@ export const cli = async (args?: string[]) => {
482
482
  createClient(await getKeypair(args.directory), remote),
483
483
  ),
484
484
  );
485
- const resolvedOrRejected = await Promise.allSettled(
486
- apis.map((x) => x.peer.id.get()),
485
+ const identityStatuses = await Promise.all(
486
+ apis.map(async (api, index) => {
487
+ let observed: string;
488
+ try {
489
+ observed = peerIdFromString(
490
+ await api.peer.id.get(),
491
+ ).toString();
492
+ } catch {
493
+ return "offline" as const;
494
+ }
495
+ const pinned = all[index].peerId;
496
+ if (!pinned) return "unpinned" as const;
497
+ let expected: string;
498
+ try {
499
+ expected = peerIdFromString(pinned).toString();
500
+ } catch {
501
+ return "mismatch" as const;
502
+ }
503
+ if (observed !== expected) return "mismatch" as const;
504
+ try {
505
+ const verified = peerIdFromString(
506
+ await api.peer.id.verify(),
507
+ ).toString();
508
+ return verified === expected
509
+ ? ("verified" as const)
510
+ : ("mismatch" as const);
511
+ } catch {
512
+ return "unverified" as const;
513
+ }
514
+ }),
487
515
  );
488
516
 
489
517
  if (all.length > 0) {
490
518
  const rows: string[][] = [];
491
519
  for (const [ix, remote] of all.entries()) {
520
+ const status =
521
+ identityStatuses[ix] === "verified"
522
+ ? chalk.green("Y")
523
+ : identityStatuses[ix] === "unpinned"
524
+ ? chalk.yellow("Y (unpinned)")
525
+ : identityStatuses[ix] === "mismatch"
526
+ ? chalk.red("ID MISMATCH")
527
+ : identityStatuses[ix] === "unverified"
528
+ ? chalk.red("ID UNVERIFIED")
529
+ : chalk.red("N");
492
530
  const row = [
493
531
  remote.name,
494
532
  remote.group || "",
@@ -497,15 +535,13 @@ export const cli = async (args?: string[]) => {
497
535
  : remote.origin?.type === "hetzner"
498
536
  ? `hetzner\n${remote.origin.location}\n${remote.origin.serverId}`
499
537
  : "",
500
- resolvedOrRejected[ix].status === "fulfilled"
501
- ? chalk.green("Y")
502
- : chalk.red("N"),
538
+ status,
503
539
  remote.address,
504
540
  ];
505
541
  rows.push(row);
506
542
  }
507
543
  const table = Table(
508
- ["Name", "Group", "Origin", "Online", "Address"].map((x) => {
544
+ ["Name", "Group", "Origin", "Status", "Address"].map((x) => {
509
545
  return { value: x, align: "left" };
510
546
  }),
511
547
  rows,
@@ -537,6 +573,11 @@ export const cli = async (args?: string[]) => {
537
573
  alias: "g",
538
574
  default: DEFAULT_REMOTE_GROUP,
539
575
  })
576
+ .option("peer-id", {
577
+ describe:
578
+ "Expected server peer ID for first enrollment (an existing pin cannot be replaced)",
579
+ type: "string",
580
+ })
540
581
  .option("directory", {
541
582
  describe: "Peerbit directory",
542
583
  defaultDescription: "~.peerbit",
@@ -551,8 +592,40 @@ export const cli = async (args?: string[]) => {
551
592
  if (args.name === "localhost") {
552
593
  throw new Error("Remote can not be named 'localhost'");
553
594
  }
554
- const api = await createClient(await getKeypair(args.directory), {
595
+ const remotes = new Remotes(getRemotesPath(args.directory));
596
+ const existing = remotes.getByName(args.name);
597
+ const pinnedPeerId = existing?.peerId
598
+ ? peerIdFromString(existing.peerId).toString()
599
+ : undefined;
600
+ const suppliedPeerId = args["peer-id"]
601
+ ? peerIdFromString(args["peer-id"]).toString()
602
+ : undefined;
603
+ if (
604
+ pinnedPeerId &&
605
+ suppliedPeerId &&
606
+ pinnedPeerId !== suppliedPeerId
607
+ ) {
608
+ throw new Error(
609
+ `Remote '${args.name}' is already pinned to ${pinnedPeerId}. Remove it before enrolling a different server identity.`,
610
+ );
611
+ }
612
+ const keypair = await getKeypair(args.directory);
613
+ const discovery = await createClient(keypair, {
614
+ address: args.address,
615
+ });
616
+ const discoveredPeerId = peerIdFromString(
617
+ await discovery.peer.id.get(),
618
+ ).toString();
619
+ const expectedPeerId =
620
+ pinnedPeerId ?? suppliedPeerId ?? discoveredPeerId;
621
+ if (expectedPeerId !== discoveredPeerId) {
622
+ throw new Error(
623
+ `Server peer ID mismatch: expected ${expectedPeerId}, received ${discoveredPeerId}`,
624
+ );
625
+ }
626
+ const api = await createClient(keypair, {
555
627
  address: args.address,
628
+ peerId: expectedPeerId,
556
629
  });
557
630
  try {
558
631
  await api.program.list();
@@ -562,12 +635,13 @@ export const cli = async (args?: string[]) => {
562
635
  if (!fs.existsSync(args.directory)) {
563
636
  fs.mkdirSync(args.directory, { recursive: true });
564
637
  }
565
- const remotes = new Remotes(getRemotesPath(args.directory));
566
638
  remotes.add({
567
639
  name: args.name,
568
640
  address: args.address,
569
641
  group: args.group,
642
+ peerId: expectedPeerId,
570
643
  });
644
+ console.log(`Pinned server peer ID: ${expectedPeerId}`);
571
645
  },
572
646
  })
573
647
  .command({
@@ -666,6 +740,7 @@ export const cli = async (args?: string[]) => {
666
740
  address: "http://localhost:" + LOCAL_API_PORT,
667
741
  name: "localhost",
668
742
  group: DEFAULT_REMOTE_GROUP,
743
+ peerId: (await keypair.publicKey.toPeerId()).toString(),
669
744
  });
670
745
  } else {
671
746
  const remote = remotes.getByName(name);
@@ -686,6 +761,19 @@ export const cli = async (args?: string[]) => {
686
761
  }
687
762
  }
688
763
 
764
+ const newlyPinned: RemoteObject[] = [];
765
+ for (const remote of selectedRemotes) {
766
+ if (remote.peerId) continue;
767
+ const discovery = await createClient(keypair, {
768
+ address: remote.address,
769
+ origin: remote.origin,
770
+ });
771
+ remote.peerId = peerIdFromString(
772
+ await discovery.peer.id.get(),
773
+ ).toString();
774
+ newlyPinned.push(remote);
775
+ }
776
+
689
777
  const maxNameLength = selectedRemotes
690
778
  .map((x) => x.name.length)
691
779
  .reduce((prev, c, i) => {
@@ -731,6 +819,12 @@ export const cli = async (args?: string[]) => {
731
819
  );
732
820
  }
733
821
  }
822
+ for (const remote of newlyPinned) {
823
+ remotes.add(remote);
824
+ console.warn(
825
+ `Pinned existing remote '${remote.name}' to server peer ID ${remote.peerId}`,
826
+ );
827
+ }
734
828
 
735
829
  const rl = readline.createInterface({
736
830
  input: process.stdin,
package/src/client.ts CHANGED
@@ -12,6 +12,7 @@ import { waitForResolved } from "@peerbit/time";
12
12
  import { type RemoteOrigin, getRetiredAWSManagementError } from "./remotes.js";
13
13
  import {
14
14
  ADDRESS_PATH,
15
+ AUTH_PATH,
15
16
  BOOTSTRAP_PATH,
16
17
  INSTALL_PATH,
17
18
  LOCAL_API_PORT,
@@ -28,13 +29,18 @@ import {
28
29
  TRUST_PATH,
29
30
  VERSIONS_PATH,
30
31
  } from "./routes.js";
31
- import { signRequest } from "./signed-request.js";
32
+ import {
33
+ type AuthDescriptor,
34
+ signRequest,
35
+ verifyAuthDescriptor,
36
+ } from "./signed-request.js";
32
37
  import type { InstallDependency, StartProgram } from "./types.js";
33
38
 
34
39
  export const createClient = async (
35
40
  keypair: Identity<Ed25519PublicKey>,
36
- remote: { address: string; origin?: RemoteOrigin } = {
41
+ remote: { address: string; origin?: RemoteOrigin; peerId?: string } = {
37
42
  address: "http://localhost:" + LOCAL_API_PORT,
43
+ peerId: keypair.publicKey.toPeerId().toString(),
38
44
  },
39
45
  ) => {
40
46
  // Add missing protocol
@@ -58,18 +64,100 @@ export const createClient = async (
58
64
  }
59
65
 
60
66
  const { default: axios } = await import("axios");
61
- const axiosInstance = axios.create();
67
+ const axiosInstance = axios.create({
68
+ adapter: ["http", "fetch"],
69
+ maxRedirects: 0,
70
+ fetchOptions: { redirect: "manual" },
71
+ // The signature covers these exact bytes, so no later Axios transform may
72
+ // serialize or otherwise rewrite the request body.
73
+ transformRequest: [(data) => data],
74
+ });
75
+ const unsignedAxiosInstance = axios.create({
76
+ adapter: ["http", "fetch"],
77
+ maxRedirects: 0,
78
+ fetchOptions: { redirect: "manual" },
79
+ });
80
+ const endpointUrl = (path: string) => new URL(path, endpoint).toString();
81
+ let descriptorPromise: Promise<AuthDescriptor> | undefined;
82
+ const getAuthDescriptor = () => {
83
+ if (!remote.peerId) {
84
+ throw new Error(
85
+ "A pinned server peer ID is required for authenticated requests",
86
+ );
87
+ }
88
+ if (!descriptorPromise) {
89
+ descriptorPromise = unsignedAxiosInstance
90
+ .get(endpointUrl(AUTH_PATH), {
91
+ timeout: 5000,
92
+ maxContentLength: 16 * 1024,
93
+ maxBodyLength: 16 * 1024,
94
+ validateStatus: (status) => status === 200,
95
+ })
96
+ .then((response) => verifyAuthDescriptor(response.data, remote.peerId!))
97
+ .catch((error) => {
98
+ descriptorPromise = undefined;
99
+ throw new Error(
100
+ "Server does not support signed-request v2 or its identity does not match: " +
101
+ error?.toString(),
102
+ );
103
+ });
104
+ }
105
+ return descriptorPromise;
106
+ };
62
107
  axiosInstance.interceptors.request.use(async (config) => {
63
- const url = new URL(config.url!);
108
+ const url = new URL(config.url!, config.baseURL);
109
+ const method = config.method!.toUpperCase();
110
+ const target = url.pathname + url.search;
111
+ config.method = method;
112
+ if (
113
+ method === "GET" &&
114
+ (target === PEER_ID_PATH ||
115
+ target === ADDRESS_PATH ||
116
+ target === AUTH_PATH)
117
+ ) {
118
+ return config;
119
+ }
120
+ let signedBody: string | Uint8Array | undefined;
121
+ if (config.data == null) {
122
+ config.data = undefined;
123
+ signedBody = undefined;
124
+ } else if (typeof config.data === "string") {
125
+ signedBody = config.data;
126
+ } else if (config.data instanceof Uint8Array) {
127
+ // Copy only this view (not its possibly larger backing buffer), then use a
128
+ // standalone ArrayBuffer accepted unchanged by both HTTP and fetch adapters.
129
+ signedBody = Uint8Array.from(config.data);
130
+ config.data = signedBody.buffer;
131
+ } else {
132
+ throw new Error("Authenticated request bodies must be strings or bytes");
133
+ }
134
+ const descriptor = await getAuthDescriptor();
64
135
  await signRequest(
65
- config.headers,
66
- config.method!,
67
- url.pathname + url.search,
68
- config.data,
136
+ config.headers as unknown as Record<string, string>,
137
+ method,
138
+ target,
139
+ signedBody,
69
140
  keypair,
141
+ {
142
+ serverPeerId: descriptor.serverPeerId,
143
+ bootId: descriptor.bootId,
144
+ },
70
145
  );
71
146
  return config;
72
147
  });
148
+ axiosInstance.interceptors.response.use(
149
+ (response) => response,
150
+ (error) => {
151
+ if (error?.response?.status === 401 || error?.response == null) {
152
+ // A server restart changes the boot audience. Do not replay the failed
153
+ // operation; only force the next operation to discover a new descriptor.
154
+ // Network failure is included because an old keep-alive connection can be
155
+ // the first observable symptom of a process restart.
156
+ descriptorPromise = undefined;
157
+ }
158
+ return Promise.reject(error);
159
+ },
160
+ );
73
161
 
74
162
  const validateStatus = (status: number) => {
75
163
  return (status >= 200 && status < 300) || status === 404;
@@ -82,8 +170,6 @@ export const createClient = async (
82
170
  return resp;
83
171
  };
84
172
 
85
- const endpointUrl = (path: string) => new URL(path, endpoint).toString();
86
-
87
173
  const getId = async () =>
88
174
  (
89
175
  await waitForResolved(
@@ -102,6 +188,7 @@ export const createClient = async (
102
188
  },
103
189
  )
104
190
  ).toString();
191
+ const verifyId = async () => (await getAuthDescriptor()).serverPeerId;
105
192
 
106
193
  const close = async (address: string) => {
107
194
  return throwIfNot200(
@@ -133,6 +220,7 @@ export const createClient = async (
133
220
  peer: {
134
221
  id: {
135
222
  get: getId,
223
+ verify: verifyId,
136
224
  },
137
225
  addresses: {
138
226
  get: async () => {
package/src/remotes.ts CHANGED
@@ -25,6 +25,8 @@ export interface RemoteObject {
25
25
  address: string;
26
26
  name: string;
27
27
  group: string;
28
+ /** Stable server identity pinned when the remote is enrolled. */
29
+ peerId?: string;
28
30
  origin?: RemoteOrigin;
29
31
  }
30
32
 
@@ -66,7 +68,16 @@ export class Remotes {
66
68
  add(remote: RemoteObject) {
67
69
  const existing = this.data.remotes.findIndex((x) => x.name === remote.name);
68
70
  if (existing >= 0) {
69
- this.data.remotes[existing] = remote;
71
+ const current = this.data.remotes[existing];
72
+ if (current.peerId && remote.peerId && current.peerId !== remote.peerId) {
73
+ throw new Error(
74
+ `Remote '${remote.name}' is pinned to a different server identity. Remove it before replacing the pin.`,
75
+ );
76
+ }
77
+ this.data.remotes[existing] = {
78
+ ...remote,
79
+ peerId: current.peerId ?? remote.peerId,
80
+ };
70
81
  } else {
71
82
  this.data.remotes.push(remote);
72
83
  }
package/src/routes.ts CHANGED
@@ -14,6 +14,7 @@ export const LOCAL_API_PORT = 8082;
14
14
  export const TRUST_PATH = "/trust";
15
15
  export const PEER_ID_PATH = "/peer/id";
16
16
  export const ADDRESS_PATH = "/peer/address";
17
+ export const AUTH_PATH = "/peer/auth";
17
18
  export const STATS_PATH = "/peer/stats";
18
19
  export const PROGRAM_PATH = "/program";
19
20
  export const PROGRAMS_PATH = "/programs";
package/src/server.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  /* eslint-disable no-console */
2
2
  import { deserialize, getSchema } from "@dao-xyz/borsh";
3
3
  import { peerIdFromString } from "@libp2p/peer-id";
4
- import { fromBase64, getPublicKeyFromPeerId } from "@peerbit/crypto";
4
+ import {
5
+ fromBase64,
6
+ getPublicKeyFromPeerId,
7
+ randomBytes,
8
+ toBase64,
9
+ } from "@peerbit/crypto";
5
10
  import {
6
11
  Program,
7
12
  getProgramFromVariant,
@@ -25,6 +30,7 @@ import { getKeypair, getNodePath, getTrustPath } from "./config.js";
25
30
  import { create } from "./peerbit.js";
26
31
  import {
27
32
  ADDRESS_PATH,
33
+ AUTH_PATH,
28
34
  BOOTSTRAP_PATH,
29
35
  INSTALL_PATH,
30
36
  LOCAL_API_PORT,
@@ -41,7 +47,14 @@ import {
41
47
  VERSIONS_PATH,
42
48
  } from "./routes.js";
43
49
  import { JSONArgs, Session } from "./session.js";
44
- import { getBody, verifyRequest } from "./signed-request.js";
50
+ import {
51
+ ReplayCapacityError,
52
+ RequestAuthenticator,
53
+ RequestBodyTooLargeError,
54
+ createAuthDescriptor,
55
+ getBody,
56
+ verifyRequestBody,
57
+ } from "./signed-request.js";
45
58
  import { Trust } from "./trust.js";
46
59
  import type {
47
60
  InstallDependency,
@@ -51,6 +64,7 @@ import type {
51
64
  } from "./types.js";
52
65
 
53
66
  const MAX_LISTENER_LIMIT = 1e5;
67
+ const AUTH_DESCRIPTOR_CACHE_MS = 30_000;
54
68
 
55
69
  const getInstallDir = (): string | null => {
56
70
  return (
@@ -332,6 +346,14 @@ export const startApiServer = async (
332
346
  trust: Trust;
333
347
  session?: Session;
334
348
  port?: number;
349
+ signedRequests?: {
350
+ maxPastAgeSeconds?: number;
351
+ maxFutureSkewSeconds?: number;
352
+ maxBodyBytes?: number;
353
+ maxReplayEntries?: number;
354
+ wallClockMs?: () => number;
355
+ monotonicClockMs?: () => number;
356
+ };
335
357
  },
336
358
  ): Promise<http.Server> => {
337
359
  const port = properties?.port ?? LOCAL_API_PORT;
@@ -371,22 +393,69 @@ export const startApiServer = async (
371
393
  if (!client.peerId.equals(await client.identity.publicKey.toPeerId())) {
372
394
  throw new Error("Expecting node identity to equal peerId");
373
395
  }
396
+ const audience = {
397
+ serverPeerId: client.peerId.toString(),
398
+ bootId: toBase64(randomBytes(32)),
399
+ };
400
+ const wallClockMs = properties.signedRequests?.wallClockMs ?? Date.now;
401
+ let cachedAuthDescriptor:
402
+ | {
403
+ descriptor: Awaited<ReturnType<typeof createAuthDescriptor>>;
404
+ at: number;
405
+ }
406
+ | undefined;
407
+ let authDescriptorPromise:
408
+ | Promise<Awaited<ReturnType<typeof createAuthDescriptor>>>
409
+ | undefined;
410
+ const getAuthDescriptor = () => {
411
+ const now = wallClockMs();
412
+ if (
413
+ cachedAuthDescriptor &&
414
+ now >= cachedAuthDescriptor.at &&
415
+ now - cachedAuthDescriptor.at < AUTH_DESCRIPTOR_CACHE_MS
416
+ ) {
417
+ return Promise.resolve(cachedAuthDescriptor.descriptor);
418
+ }
419
+ if (!authDescriptorPromise) {
420
+ authDescriptorPromise = createAuthDescriptor(
421
+ client.identity,
422
+ audience,
423
+ now,
424
+ )
425
+ .then((descriptor) => {
426
+ cachedAuthDescriptor = { descriptor, at: now };
427
+ return descriptor;
428
+ })
429
+ .finally(() => {
430
+ authDescriptorPromise = undefined;
431
+ });
432
+ }
433
+ return authDescriptorPromise;
434
+ };
435
+ const authenticator = new RequestAuthenticator({
436
+ ...audience,
437
+ ...properties.signedRequests,
438
+ isTrusted: (key) =>
439
+ key.equals(client.identity.publicKey) ||
440
+ properties.trust.isTrusted(key.hashcode()),
441
+ });
374
442
 
375
443
  const getVerifiedBody = async (req: http.IncomingMessage) => {
376
- const body = await getBody(req);
377
- const result = await verifyRequest(
444
+ const verified = await authenticator.verify(
378
445
  req.headers,
379
446
  req.method!,
380
447
  req.url!,
381
- body,
382
448
  );
383
- if (result.equals(client.identity.publicKey)) {
384
- return body;
385
- }
386
- if (properties.trust.isTrusted(result.hashcode())) {
387
- return body;
388
- }
389
- throw new Error("Not trusted");
449
+ const body = await getBody(req, verified.bodyLength);
450
+ const decoded = verifyRequestBody(verified, body);
451
+ // Consume only after the signed bytes have arrived and matched. This keeps a
452
+ // raced bad body from burning the nonce while remaining atomic before dispatch.
453
+ authenticator.consume(verified);
454
+ return decoded;
455
+ };
456
+ const closeResponseConnection = (res: http.ServerResponse) => {
457
+ res.shouldKeepAlive = false;
458
+ res.setHeader("Connection", "close");
390
459
  };
391
460
 
392
461
  const e404 = "404";
@@ -406,14 +475,41 @@ export const startApiServer = async (
406
475
  if (req.url) {
407
476
  let body: string;
408
477
  try {
409
- body =
410
- req.url.startsWith(PEER_ID_PATH) ||
411
- req.url.startsWith(ADDRESS_PATH)
412
- ? await getBody(req)
413
- : await getVerifiedBody(req);
478
+ const isPublicRequest =
479
+ req.method === "GET" &&
480
+ (req.url === PEER_ID_PATH ||
481
+ req.url === ADDRESS_PATH ||
482
+ req.url === AUTH_PATH);
483
+ if (
484
+ isPublicRequest &&
485
+ (req.headers["transfer-encoding"] != null ||
486
+ (req.headers["content-length"] != null &&
487
+ req.headers["content-length"] !== "0"))
488
+ ) {
489
+ closeResponseConnection(res);
490
+ res.writeHead(400);
491
+ res.end("Public GET endpoints do not accept a request body");
492
+ return;
493
+ }
494
+ body = isPublicRequest ? "" : await getVerifiedBody(req);
414
495
  } catch (error: any) {
415
- res.writeHead(401);
416
- res.end("Not authorized: " + error.toString());
496
+ const status =
497
+ error instanceof RequestBodyTooLargeError
498
+ ? 413
499
+ : error instanceof ReplayCapacityError
500
+ ? 503
501
+ : 401;
502
+ // Authentication can fail before the body is read. Disable keep-alive so
503
+ // an attacker cannot retain this connection by continuing to stream it.
504
+ closeResponseConnection(res);
505
+ res.writeHead(status);
506
+ res.end(
507
+ status === 413
508
+ ? "Request body is too large"
509
+ : status === 503
510
+ ? "Authentication is temporarily unavailable"
511
+ : "Not authorized",
512
+ );
417
513
  return;
418
514
  }
419
515
 
@@ -884,8 +980,8 @@ export const startApiServer = async (
884
980
  }
885
981
  case "DELETE": {
886
982
  const removed = properties.trust.remove(getPathValue(req, 1));
887
- res.writeHead(200);
888
- res.end(removed);
983
+ res.writeHead(removed ? 200 : 404);
984
+ res.end();
889
985
  break;
890
986
  }
891
987
  default:
@@ -996,6 +1092,12 @@ export const startApiServer = async (
996
1092
  } else {
997
1093
  r404();
998
1094
  }
1095
+ } else if (req.url === AUTH_PATH && req.method === "GET") {
1096
+ const authDescriptor = await getAuthDescriptor();
1097
+ res.setHeader("Cache-Control", "no-store");
1098
+ res.setHeader("Content-Type", "application/json");
1099
+ res.writeHead(200);
1100
+ res.end(JSON.stringify(authDescriptor));
999
1101
  } else if (req.url.startsWith(PEER_ID_PATH)) {
1000
1102
  res.writeHead(200);
1001
1103
  res.end(client.peerId.toString());