@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.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +87 -7
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +2 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +83 -6
- package/dist/src/client.js.map +1 -1
- package/dist/src/remotes.d.ts +2 -0
- package/dist/src/remotes.d.ts.map +1 -1
- package/dist/src/remotes.js +8 -1
- package/dist/src/remotes.js.map +1 -1
- package/dist/src/routes.d.ts +1 -0
- package/dist/src/routes.d.ts.map +1 -1
- package/dist/src/routes.js +1 -0
- package/dist/src/routes.js.map +1 -1
- package/dist/src/server.d.ts +8 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +83 -20
- package/dist/src/server.js.map +1 -1
- package/dist/src/signed-request.d.ts +77 -3
- package/dist/src/signed-request.d.ts.map +1 -1
- package/dist/src/signed-request.js +420 -39
- package/dist/src/signed-request.js.map +1 -1
- package/dist/src/trust.d.ts.map +1 -1
- package/dist/src/trust.js +1 -0
- package/dist/src/trust.js.map +1 -1
- package/dist/test/api.spec.js +359 -3
- package/dist/test/api.spec.js.map +1 -1
- package/dist/test/cli.spec.js +90 -1
- package/dist/test/cli.spec.js.map +1 -1
- package/dist/test/remotes.spec.js +65 -1
- package/dist/test/remotes.spec.js.map +1 -1
- package/dist/test/signed-request.spec.js +204 -23
- package/dist/test/signed-request.spec.js.map +1 -1
- package/dist/test/trust.spec.d.ts +2 -0
- package/dist/test/trust.spec.d.ts.map +1 -0
- package/dist/test/trust.spec.js +29 -0
- package/dist/test/trust.spec.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/ui/assets/{index-CjZwCewh.js → index-DS8hXn9k.js} +6 -6
- package/dist/ui/index.html +1 -1
- package/package.json +6 -6
- package/src/cli.ts +102 -8
- package/src/client.ts +98 -10
- package/src/remotes.ts +12 -1
- package/src/routes.ts +1 -0
- package/src/server.ts +123 -21
- package/src/signed-request.ts +564 -50
- package/src/trust.ts +1 -0
package/dist/ui/index.html
CHANGED
|
@@ -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-
|
|
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": "
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
486
|
-
apis.map((
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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 {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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]
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
410
|
-
req.
|
|
411
|
-
req.url
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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(
|
|
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());
|