@saadnvd1/agent-os 0.3.697 → 0.3.700
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/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peer/ping/route.ts +10 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/__fixtures__/connect-transport.ts +93 -0
- package/lib/__fixtures__/peer-machine.ts +45 -0
- package/lib/__fixtures__/peer-rpc.ts +224 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +6 -0
- package/lib/done/archive.ts +2 -0
- package/lib/hosts/index.ts +28 -7
- package/lib/hosts/peer-socket.ts +3 -1
- package/lib/hosts/read-capped-bytes.ts +23 -0
- package/lib/hosts/remote-api.ts +112 -41
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +258 -0
- package/lib/peer/rpc-server.ts +369 -0
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +51 -6
- package/lib/security/gate.ts +38 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/sessions/end.ts +3 -0
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +21 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed, encrypted envelope linked machines call each other with
|
|
3
|
+
* (Node's crypto only).
|
|
4
|
+
*
|
|
5
|
+
* Request body: u32 header length ‖ header JSON ‖ hsig ‖ ciphertext ‖ sig.
|
|
6
|
+
* header {v:1, from, to, ts, nonce, epk}, epk a fresh X25519 key per call
|
|
7
|
+
* hsig Ed25519_A("agentos-peer-hdr-v1" ‖ header), so the receiver can
|
|
8
|
+
* prove the sender before it holds any of the body
|
|
9
|
+
* keys HKDF-SHA256(X25519(epk, B_box), salt=nonce,
|
|
10
|
+
* info="agentos-peer-v1|from|to") → k_req ‖ k_res
|
|
11
|
+
* ct ChaCha20-Poly1305(k_req, AAD=header) over the inner request
|
|
12
|
+
* sig Ed25519_A("agentos-peer-req-v1" ‖ header ‖ SHA256(ct))
|
|
13
|
+
*
|
|
14
|
+
* Response body: iv ‖ ct ‖ sig, where iv is 12 fresh random bytes, ct =
|
|
15
|
+
* ChaCha20-Poly1305(k_res, iv) over the inner response and sig =
|
|
16
|
+
* Ed25519_B("agentos-peer-res-v1" ‖ request nonce ‖ iv ‖ SHA256(ct)), so it
|
|
17
|
+
* only opens for the request it answers.
|
|
18
|
+
*
|
|
19
|
+
* k_req seals exactly one message, so the request uses a fixed all-zero IV.
|
|
20
|
+
* The response doesn't rely on that: should a request ever be answered twice,
|
|
21
|
+
* a fresh IV keeps the two answers from sharing a keystream.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto, { type KeyObject } from "crypto";
|
|
25
|
+
import { MACHINE_ID } from "../connect/control-api";
|
|
26
|
+
import { rawPublic } from "./keys";
|
|
27
|
+
import { type ReplayGuard } from "./replay";
|
|
28
|
+
|
|
29
|
+
export const RPC_PATH = "/api/peer/rpc";
|
|
30
|
+
export const MAX_REQUEST_BODY = 100 * 1024 * 1024;
|
|
31
|
+
export const MAX_RESPONSE_BODY = 80 * 1024 * 1024;
|
|
32
|
+
const OVERHEAD = 64 * 1024;
|
|
33
|
+
export const MAX_REQUEST_ENVELOPE = MAX_REQUEST_BODY + OVERHEAD;
|
|
34
|
+
export const MAX_RESPONSE_ENVELOPE = MAX_RESPONSE_BODY + OVERHEAD;
|
|
35
|
+
export const SKEW_MS = 120_000;
|
|
36
|
+
|
|
37
|
+
export const MAX_HEADER = 1024;
|
|
38
|
+
const MAX_META = 16 * 1024;
|
|
39
|
+
const SIG = 64;
|
|
40
|
+
const TAG = 16;
|
|
41
|
+
const ZERO_IV = Buffer.alloc(12);
|
|
42
|
+
const IV_LEN = 12;
|
|
43
|
+
export const HEADER_SIG = SIG;
|
|
44
|
+
const HDR_DOMAIN = Buffer.from("agentos-peer-hdr-v1");
|
|
45
|
+
const REQ_DOMAIN = Buffer.from("agentos-peer-req-v1");
|
|
46
|
+
const RES_DOMAIN = Buffer.from("agentos-peer-res-v1");
|
|
47
|
+
const B64_16 = /^[A-Za-z0-9_-]{22}$/;
|
|
48
|
+
const B64_32 = /^[A-Za-z0-9_-]{43}$/;
|
|
49
|
+
|
|
50
|
+
export interface PeerRequest {
|
|
51
|
+
method: string;
|
|
52
|
+
path: string;
|
|
53
|
+
/** URL query without the "?"; checked against the route's keys. */
|
|
54
|
+
query: string;
|
|
55
|
+
contentType: string;
|
|
56
|
+
body: Buffer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PeerResponse {
|
|
60
|
+
status: number;
|
|
61
|
+
contentType: string;
|
|
62
|
+
body: Buffer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RequestHeader {
|
|
66
|
+
v: 1;
|
|
67
|
+
from: string;
|
|
68
|
+
to: string;
|
|
69
|
+
ts: number;
|
|
70
|
+
nonce: string;
|
|
71
|
+
epk: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Why an envelope was refused before anything inside it was trusted. */
|
|
75
|
+
export type Refusal =
|
|
76
|
+
| "bad_envelope"
|
|
77
|
+
| "wrong_machine"
|
|
78
|
+
| "unknown_peer"
|
|
79
|
+
| "suspended"
|
|
80
|
+
| "bad_sig"
|
|
81
|
+
| "stale"
|
|
82
|
+
| "replay"
|
|
83
|
+
| "busy";
|
|
84
|
+
|
|
85
|
+
const sha256 = (b: Buffer) => crypto.createHash("sha256").update(b).digest();
|
|
86
|
+
|
|
87
|
+
function frame(meta: object, body: Buffer): Buffer {
|
|
88
|
+
const m = Buffer.from(JSON.stringify(meta));
|
|
89
|
+
const len = Buffer.alloc(4);
|
|
90
|
+
len.writeUInt32BE(m.length);
|
|
91
|
+
return Buffer.concat([len, m, body]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function unframe(buf: Buffer, max: number): { meta: unknown; rest: Buffer } {
|
|
95
|
+
if (buf.length < 4) throw new Error("short");
|
|
96
|
+
const len = buf.readUInt32BE(0);
|
|
97
|
+
if (len > max || 4 + len > buf.length) throw new Error("bad length");
|
|
98
|
+
return {
|
|
99
|
+
meta: JSON.parse(buf.subarray(4, 4 + len).toString("utf8")),
|
|
100
|
+
rest: buf.subarray(4 + len),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function keysFor(
|
|
105
|
+
shared: Buffer,
|
|
106
|
+
nonce: Buffer,
|
|
107
|
+
from: string,
|
|
108
|
+
to: string
|
|
109
|
+
): { req: Buffer; res: Buffer } {
|
|
110
|
+
const okm = Buffer.from(
|
|
111
|
+
crypto.hkdfSync(
|
|
112
|
+
"sha256",
|
|
113
|
+
shared,
|
|
114
|
+
nonce,
|
|
115
|
+
`agentos-peer-v1|${from}|${to}`,
|
|
116
|
+
64
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
return { req: okm.subarray(0, 32), res: okm.subarray(32) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function seal(
|
|
123
|
+
key: Buffer,
|
|
124
|
+
plain: Buffer,
|
|
125
|
+
o: { aad?: Buffer; iv?: Buffer } = {}
|
|
126
|
+
): Buffer {
|
|
127
|
+
const { aad, iv = ZERO_IV } = o;
|
|
128
|
+
const c = crypto.createCipheriv("chacha20-poly1305", key, iv, {
|
|
129
|
+
authTagLength: TAG,
|
|
130
|
+
});
|
|
131
|
+
if (aad) c.setAAD(aad, { plaintextLength: plain.length });
|
|
132
|
+
return Buffer.concat([c.update(plain), c.final(), c.getAuthTag()]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unseal(
|
|
136
|
+
key: Buffer,
|
|
137
|
+
ct: Buffer,
|
|
138
|
+
o: { aad?: Buffer; iv?: Buffer } = {}
|
|
139
|
+
): Buffer {
|
|
140
|
+
const { aad, iv = ZERO_IV } = o;
|
|
141
|
+
if (ct.length < TAG) throw new Error("short");
|
|
142
|
+
const d = crypto.createDecipheriv("chacha20-poly1305", key, iv, {
|
|
143
|
+
authTagLength: TAG,
|
|
144
|
+
});
|
|
145
|
+
d.setAuthTag(ct.subarray(ct.length - TAG));
|
|
146
|
+
if (aad) d.setAAD(aad, { plaintextLength: ct.length - TAG });
|
|
147
|
+
return Buffer.concat([d.update(ct.subarray(0, ct.length - TAG)), d.final()]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Sealed {
|
|
151
|
+
envelope: Buffer;
|
|
152
|
+
nonce: Buffer;
|
|
153
|
+
/** Opens the answer to this request, and nothing else. */
|
|
154
|
+
resKey: Buffer;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function sealRequest(o: {
|
|
158
|
+
from: string;
|
|
159
|
+
to: string;
|
|
160
|
+
signKey: KeyObject;
|
|
161
|
+
peerBox: KeyObject;
|
|
162
|
+
request: PeerRequest;
|
|
163
|
+
now?: number;
|
|
164
|
+
}): Sealed {
|
|
165
|
+
const nonce = crypto.randomBytes(16);
|
|
166
|
+
const eph = crypto.generateKeyPairSync("x25519");
|
|
167
|
+
const header: RequestHeader = {
|
|
168
|
+
v: 1,
|
|
169
|
+
from: o.from,
|
|
170
|
+
to: o.to,
|
|
171
|
+
ts: o.now ?? Date.now(),
|
|
172
|
+
nonce: nonce.toString("base64url"),
|
|
173
|
+
epk: rawPublic(eph.publicKey),
|
|
174
|
+
};
|
|
175
|
+
const headerBytes = Buffer.from(JSON.stringify(header));
|
|
176
|
+
const shared = crypto.diffieHellman({
|
|
177
|
+
privateKey: eph.privateKey,
|
|
178
|
+
publicKey: o.peerBox,
|
|
179
|
+
});
|
|
180
|
+
const keys = keysFor(shared, nonce, o.from, o.to);
|
|
181
|
+
const { body, ...meta } = o.request;
|
|
182
|
+
const ct = seal(keys.req, frame(meta, body), { aad: headerBytes });
|
|
183
|
+
const sig = crypto.sign(
|
|
184
|
+
null,
|
|
185
|
+
Buffer.concat([REQ_DOMAIN, headerBytes, sha256(ct)]),
|
|
186
|
+
o.signKey
|
|
187
|
+
);
|
|
188
|
+
const hsig = crypto.sign(
|
|
189
|
+
null,
|
|
190
|
+
Buffer.concat([HDR_DOMAIN, headerBytes]),
|
|
191
|
+
o.signKey
|
|
192
|
+
);
|
|
193
|
+
const len = Buffer.alloc(4);
|
|
194
|
+
len.writeUInt32BE(headerBytes.length);
|
|
195
|
+
return {
|
|
196
|
+
envelope: Buffer.concat([len, headerBytes, hsig, ct, sig]),
|
|
197
|
+
nonce,
|
|
198
|
+
resKey: keys.res,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function parseHeader(raw: unknown): RequestHeader | null {
|
|
203
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
204
|
+
const h = raw as Record<string, unknown>;
|
|
205
|
+
const keys = Object.keys(h).sort().join(",");
|
|
206
|
+
if (keys !== "epk,from,nonce,to,ts,v") return null;
|
|
207
|
+
if (h.v !== 1) return null;
|
|
208
|
+
if (typeof h.from !== "string" || !MACHINE_ID.test(h.from)) return null;
|
|
209
|
+
if (typeof h.to !== "string" || !MACHINE_ID.test(h.to)) return null;
|
|
210
|
+
if (typeof h.ts !== "number" || !Number.isSafeInteger(h.ts)) return null;
|
|
211
|
+
if (typeof h.nonce !== "string" || !B64_16.test(h.nonce)) return null;
|
|
212
|
+
if (typeof h.epk !== "string" || !B64_32.test(h.epk)) return null;
|
|
213
|
+
return h as unknown as RequestHeader;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The header alone, as soon as it has arrived (to refuse early). */
|
|
217
|
+
export function parseHeaderBytes(bytes: Buffer): RequestHeader | null {
|
|
218
|
+
try {
|
|
219
|
+
return parseHeader(JSON.parse(bytes.toString("utf8")));
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether the header alone is signed by `signKey`. */
|
|
226
|
+
export function verifyHeader(
|
|
227
|
+
headerBytes: Buffer,
|
|
228
|
+
hsig: Buffer,
|
|
229
|
+
signKey: KeyObject
|
|
230
|
+
): boolean {
|
|
231
|
+
try {
|
|
232
|
+
return crypto.verify(
|
|
233
|
+
null,
|
|
234
|
+
Buffer.concat([HDR_DOMAIN, headerBytes]),
|
|
235
|
+
signKey,
|
|
236
|
+
hsig
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface ParsedRequest {
|
|
244
|
+
header: RequestHeader;
|
|
245
|
+
headerBytes: Buffer;
|
|
246
|
+
hsig: Buffer;
|
|
247
|
+
ct: Buffer;
|
|
248
|
+
sig: Buffer;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The envelope's parts, or null when it isn't shaped like one. */
|
|
252
|
+
export function parseRequest(envelope: Buffer): ParsedRequest | null {
|
|
253
|
+
try {
|
|
254
|
+
if (envelope.length > MAX_REQUEST_ENVELOPE) return null;
|
|
255
|
+
const { meta, rest } = unframe(envelope, MAX_HEADER);
|
|
256
|
+
const header = parseHeader(meta);
|
|
257
|
+
if (!header || rest.length < SIG + TAG + SIG) return null;
|
|
258
|
+
const headerLen = envelope.readUInt32BE(0);
|
|
259
|
+
return {
|
|
260
|
+
header,
|
|
261
|
+
headerBytes: envelope.subarray(4, 4 + headerLen),
|
|
262
|
+
hsig: rest.subarray(0, SIG),
|
|
263
|
+
ct: rest.subarray(SIG, rest.length - SIG),
|
|
264
|
+
sig: rest.subarray(rest.length - SIG),
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface OpenDeps {
|
|
272
|
+
/** This machine's Connect id: an envelope for any other is refused. */
|
|
273
|
+
self: string;
|
|
274
|
+
boxKey: KeyObject;
|
|
275
|
+
/** The pinned signing key for a peer, or why there isn't one. */
|
|
276
|
+
signKeyFor: (machineId: string) => KeyObject | "unknown_peer" | "suspended";
|
|
277
|
+
replay: ReplayGuard;
|
|
278
|
+
now?: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type Opened =
|
|
282
|
+
| {
|
|
283
|
+
ok: true;
|
|
284
|
+
header: RequestHeader;
|
|
285
|
+
request: PeerRequest;
|
|
286
|
+
nonce: Buffer;
|
|
287
|
+
resKey: Buffer;
|
|
288
|
+
}
|
|
289
|
+
| { ok: false; refusal: Refusal; header?: RequestHeader };
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Checks, in order: shape, addressed to this machine, a pinned peer, its
|
|
293
|
+
* signature, the clock, a fresh nonce; then decrypts. A nonce is remembered
|
|
294
|
+
* only once the signature holds, so nobody else can burn a peer's nonces.
|
|
295
|
+
*/
|
|
296
|
+
export function openRequest(envelope: Buffer, deps: OpenDeps): Opened {
|
|
297
|
+
const parsed = parseRequest(envelope);
|
|
298
|
+
if (!parsed) return { ok: false, refusal: "bad_envelope" };
|
|
299
|
+
const { header, headerBytes, hsig, ct, sig } = parsed;
|
|
300
|
+
const fail = (refusal: Refusal): Opened => ({ ok: false, refusal, header });
|
|
301
|
+
if (header.to !== deps.self) return fail("wrong_machine");
|
|
302
|
+
const signKey = deps.signKeyFor(header.from);
|
|
303
|
+
if (typeof signKey === "string") return fail(signKey);
|
|
304
|
+
let verified = verifyHeader(headerBytes, hsig, signKey);
|
|
305
|
+
try {
|
|
306
|
+
verified &&= crypto.verify(
|
|
307
|
+
null,
|
|
308
|
+
Buffer.concat([REQ_DOMAIN, headerBytes, sha256(ct)]),
|
|
309
|
+
signKey,
|
|
310
|
+
sig
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
verified = false;
|
|
314
|
+
}
|
|
315
|
+
if (!verified) return fail("bad_sig");
|
|
316
|
+
const now = deps.now ?? Date.now();
|
|
317
|
+
const fresh = deps.replay.check(header.from, header.nonce, header.ts, now);
|
|
318
|
+
if (fresh !== "ok") return fail(fresh);
|
|
319
|
+
try {
|
|
320
|
+
const nonce = Buffer.from(header.nonce, "base64url");
|
|
321
|
+
const epk = crypto.createPublicKey({
|
|
322
|
+
key: { kty: "OKP", crv: "X25519", x: header.epk },
|
|
323
|
+
format: "jwk",
|
|
324
|
+
});
|
|
325
|
+
const shared = crypto.diffieHellman({
|
|
326
|
+
privateKey: deps.boxKey,
|
|
327
|
+
publicKey: epk,
|
|
328
|
+
});
|
|
329
|
+
const keys = keysFor(shared, nonce, header.from, header.to);
|
|
330
|
+
const { meta, rest } = unframe(
|
|
331
|
+
unseal(keys.req, ct, { aad: headerBytes }),
|
|
332
|
+
MAX_META
|
|
333
|
+
);
|
|
334
|
+
const request = parseInner(meta, rest);
|
|
335
|
+
if (!request) return fail("bad_envelope");
|
|
336
|
+
return { ok: true, header, request, nonce, resKey: keys.res };
|
|
337
|
+
} catch {
|
|
338
|
+
return fail("bad_envelope");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseInner(meta: unknown, body: Buffer): PeerRequest | null {
|
|
343
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
|
|
344
|
+
const m = meta as Record<string, unknown>;
|
|
345
|
+
if (
|
|
346
|
+
typeof m.method !== "string" ||
|
|
347
|
+
typeof m.path !== "string" ||
|
|
348
|
+
typeof m.query !== "string" ||
|
|
349
|
+
typeof m.contentType !== "string" ||
|
|
350
|
+
body.length > MAX_REQUEST_BODY
|
|
351
|
+
)
|
|
352
|
+
return null;
|
|
353
|
+
return {
|
|
354
|
+
method: m.method,
|
|
355
|
+
path: m.path,
|
|
356
|
+
query: m.query,
|
|
357
|
+
contentType: m.contentType,
|
|
358
|
+
body,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function sealResponse(o: {
|
|
363
|
+
resKey: Buffer;
|
|
364
|
+
nonce: Buffer;
|
|
365
|
+
signKey: KeyObject;
|
|
366
|
+
response: PeerResponse;
|
|
367
|
+
}): Buffer {
|
|
368
|
+
const { body, ...meta } = o.response;
|
|
369
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
370
|
+
const ct = seal(o.resKey, frame(meta, body), { iv });
|
|
371
|
+
const sig = crypto.sign(
|
|
372
|
+
null,
|
|
373
|
+
Buffer.concat([RES_DOMAIN, o.nonce, iv, sha256(ct)]),
|
|
374
|
+
o.signKey
|
|
375
|
+
);
|
|
376
|
+
return Buffer.concat([iv, ct, sig]);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The answer to the request sealed with this nonce and key, from that peer. */
|
|
380
|
+
export function openResponse(o: {
|
|
381
|
+
envelope: Buffer;
|
|
382
|
+
nonce: Buffer;
|
|
383
|
+
resKey: Buffer;
|
|
384
|
+
peerSign: KeyObject;
|
|
385
|
+
}): PeerResponse {
|
|
386
|
+
const { envelope } = o;
|
|
387
|
+
if (
|
|
388
|
+
envelope.length < IV_LEN + TAG + SIG ||
|
|
389
|
+
envelope.length > MAX_RESPONSE_ENVELOPE
|
|
390
|
+
)
|
|
391
|
+
throw new Error("its answer isn't an envelope");
|
|
392
|
+
const iv = envelope.subarray(0, IV_LEN);
|
|
393
|
+
const ct = envelope.subarray(IV_LEN, envelope.length - SIG);
|
|
394
|
+
const sig = envelope.subarray(envelope.length - SIG);
|
|
395
|
+
let ok = false;
|
|
396
|
+
try {
|
|
397
|
+
ok = crypto.verify(
|
|
398
|
+
null,
|
|
399
|
+
Buffer.concat([RES_DOMAIN, o.nonce, iv, sha256(ct)]),
|
|
400
|
+
o.peerSign,
|
|
401
|
+
sig
|
|
402
|
+
);
|
|
403
|
+
} catch {
|
|
404
|
+
ok = false;
|
|
405
|
+
}
|
|
406
|
+
if (!ok) throw new Error("its answer isn't signed by the linked machine");
|
|
407
|
+
let meta: unknown;
|
|
408
|
+
let body: Buffer;
|
|
409
|
+
try {
|
|
410
|
+
({ meta, rest: body } = unframe(unseal(o.resKey, ct, { iv }), MAX_META));
|
|
411
|
+
} catch {
|
|
412
|
+
throw new Error("its answer doesn't decrypt");
|
|
413
|
+
}
|
|
414
|
+
const m = (meta ?? {}) as Record<string, unknown>;
|
|
415
|
+
if (
|
|
416
|
+
typeof m.status !== "number" ||
|
|
417
|
+
!Number.isInteger(m.status) ||
|
|
418
|
+
m.status < 100 ||
|
|
419
|
+
m.status > 599 ||
|
|
420
|
+
typeof m.contentType !== "string"
|
|
421
|
+
)
|
|
422
|
+
throw new Error("its answer is malformed");
|
|
423
|
+
return { status: m.status, contentType: m.contentType, body };
|
|
424
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three public pairing routes: a body of at most 4 KiB, JSON, handed to
|
|
3
|
+
* the pairing code, and the answer never cached.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
7
|
+
import { readCapped } from "../security/read-capped";
|
|
8
|
+
import { HELLO_MAX_BYTES } from "./hello";
|
|
9
|
+
import type { Reply } from "./pairing";
|
|
10
|
+
|
|
11
|
+
export function helloRoute(
|
|
12
|
+
handle: (body: unknown, request: NextRequest) => Reply | Promise<Reply>
|
|
13
|
+
) {
|
|
14
|
+
return async (request: NextRequest) => {
|
|
15
|
+
const raw = await readCapped(request, HELLO_MAX_BYTES);
|
|
16
|
+
let reply: Reply;
|
|
17
|
+
if (raw === null) reply = { status: 413, body: { error: "too large" } };
|
|
18
|
+
else {
|
|
19
|
+
let body: unknown = null;
|
|
20
|
+
try {
|
|
21
|
+
body = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
// parsed as nothing: refused as a bad message
|
|
24
|
+
}
|
|
25
|
+
reply = await handle(body, request);
|
|
26
|
+
}
|
|
27
|
+
return NextResponse.json(reply.body, {
|
|
28
|
+
status: reply.status,
|
|
29
|
+
headers: { "Cache-Control": "no-store" },
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|