@saadnvd1/agent-os 0.3.697 → 0.3.698
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/peer/ping/route.ts +10 -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/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/types.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/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +229 -0
- package/lib/peer/rpc-server.ts +351 -0
- package/lib/security/auth.ts +42 -5
- package/lib/security/gate.ts +35 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- package/server.ts +18 -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-boot secret the rpc server's loopback hop carries, so the gate
|
|
3
|
+
* knows a request is a verified peer call and not this machine itself.
|
|
4
|
+
* Held on globalThis: server.ts and the gate must share one value.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
|
|
9
|
+
const g = globalThis as unknown as { __agentosPeerSecret?: string };
|
|
10
|
+
|
|
11
|
+
export function peerSecret(): string {
|
|
12
|
+
g.__agentosPeerSecret ??= crypto.randomBytes(32).toString("hex");
|
|
13
|
+
return g.__agentosPeerSecret;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Constant-time: how much of the secret matched never shows in timing. */
|
|
17
|
+
export function secretMatches(given: unknown, secret: string): boolean {
|
|
18
|
+
if (typeof given !== "string" || !secret) return false;
|
|
19
|
+
const a = Buffer.from(given);
|
|
20
|
+
const b = Buffer.from(secret);
|
|
21
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
22
|
+
}
|
package/lib/peer/keys.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys a machine uses with its linked peers. The signing key is
|
|
3
|
+
* Connect's machine.key (Ed25519); the box key (X25519) is peer.key next to
|
|
4
|
+
* it, made on first use, owner-only, and never sent to the Connect service.
|
|
5
|
+
* Public keys travel as base64url of their 32 raw bytes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto, { type KeyObject } from "crypto";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { connectDir, readConnect } from "../connect/config";
|
|
12
|
+
|
|
13
|
+
const RAW = /^[A-Za-z0-9_-]{43}$/;
|
|
14
|
+
|
|
15
|
+
export const rawPublic = (key: KeyObject): string => {
|
|
16
|
+
const jwk = key.export({ format: "jwk" });
|
|
17
|
+
if (typeof jwk.x !== "string") throw new Error("not an OKP key");
|
|
18
|
+
return jwk.x;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function publicFromRaw(raw: string, crv: "Ed25519" | "X25519"): KeyObject {
|
|
22
|
+
if (!RAW.test(raw)) throw new Error(`bad ${crv} public key`);
|
|
23
|
+
const key = crypto.createPublicKey({
|
|
24
|
+
key: { kty: "OKP", crv, x: raw },
|
|
25
|
+
format: "jwk",
|
|
26
|
+
});
|
|
27
|
+
if (key.asymmetricKeyType !== crv.toLowerCase())
|
|
28
|
+
throw new Error(`bad ${crv} public key`);
|
|
29
|
+
return key;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const signPublic = (raw: string) => publicFromRaw(raw, "Ed25519");
|
|
33
|
+
export const boxPublic = (raw: string) => publicFromRaw(raw, "X25519");
|
|
34
|
+
|
|
35
|
+
export interface BoxKey {
|
|
36
|
+
privateKey: KeyObject;
|
|
37
|
+
publicRaw: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Makes sure `p` is this user's and nobody else's: repairs group or other
|
|
42
|
+
* access, refuses one owned by someone else.
|
|
43
|
+
*/
|
|
44
|
+
function ownerOnly(p: string, mode: number) {
|
|
45
|
+
if (process.platform === "win32") return;
|
|
46
|
+
const st = fs.lstatSync(p);
|
|
47
|
+
if (st.isSymbolicLink()) throw new Error(`${p} is a symlink`);
|
|
48
|
+
if (typeof process.getuid === "function" && st.uid !== process.getuid())
|
|
49
|
+
throw new Error(`${p} belongs to another user`);
|
|
50
|
+
if (st.mode & 0o077) fs.chmodSync(p, mode);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** peer.key, created owner-only if it isn't there yet. */
|
|
54
|
+
export function loadBoxKey(dir = connectDir()): BoxKey {
|
|
55
|
+
const file = path.join(dir, "peer.key");
|
|
56
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
57
|
+
ownerOnly(dir, 0o700);
|
|
58
|
+
if (!fs.existsSync(file)) {
|
|
59
|
+
const { privateKey } = crypto.generateKeyPairSync("x25519");
|
|
60
|
+
const pem = privateKey.export({ format: "pem", type: "pkcs8" });
|
|
61
|
+
// Written whole under a temp name, then linked in: a crash never leaves
|
|
62
|
+
// a half-written key, and two starts racing keep whichever came first.
|
|
63
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
|
|
64
|
+
fs.writeFileSync(tmp, pem, { mode: 0o600, flag: "wx" });
|
|
65
|
+
try {
|
|
66
|
+
fs.linkSync(tmp, file);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(tmp, { force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
ownerOnly(file, 0o600);
|
|
74
|
+
const privateKey = crypto.createPrivateKey(fs.readFileSync(file, "utf8"));
|
|
75
|
+
if (privateKey.asymmetricKeyType !== "x25519")
|
|
76
|
+
throw new Error(`${file} is not an X25519 key`);
|
|
77
|
+
return {
|
|
78
|
+
privateKey,
|
|
79
|
+
publicRaw: rawPublic(crypto.createPublicKey(privateKey)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Identity {
|
|
84
|
+
machineId: string;
|
|
85
|
+
signKey: KeyObject;
|
|
86
|
+
signPub: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** This machine as Connect knows it, or null when it isn't enrolled. */
|
|
90
|
+
export function loadIdentity(dir = connectDir()): Identity | null {
|
|
91
|
+
const read = readConnect(dir);
|
|
92
|
+
if (!read.ok) return null;
|
|
93
|
+
const signKey = crypto.createPrivateKey(read.files.machineKey);
|
|
94
|
+
if (signKey.asymmetricKeyType !== "ed25519") return null;
|
|
95
|
+
return {
|
|
96
|
+
machineId: read.files.config.machineId,
|
|
97
|
+
signKey,
|
|
98
|
+
signPub: rawPublic(crypto.createPublicKey(signKey)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const g = globalThis as unknown as {
|
|
103
|
+
__agentosPeerIdentity?: {
|
|
104
|
+
at: number;
|
|
105
|
+
dir: string;
|
|
106
|
+
identity: Identity | null;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** loadIdentity, re-read at most every 30 s (it's on every peer call). */
|
|
111
|
+
export function currentIdentity(dir = connectDir()): Identity | null {
|
|
112
|
+
const c = g.__agentosPeerIdentity;
|
|
113
|
+
if (c && c.dir === dir && Date.now() - c.at < 30_000) return c.identity;
|
|
114
|
+
let identity: Identity | null = null;
|
|
115
|
+
try {
|
|
116
|
+
identity = loadIdentity(dir);
|
|
117
|
+
} catch {
|
|
118
|
+
identity = null;
|
|
119
|
+
}
|
|
120
|
+
g.__agentosPeerIdentity = { at: Date.now(), dir, identity };
|
|
121
|
+
return identity;
|
|
122
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned peers: a machine linked through Connect, by its Connect id, with
|
|
3
|
+
* the keys it paired with. Nothing here talks to the network.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getDb } from "../db";
|
|
7
|
+
|
|
8
|
+
export type PinState = "active" | "suspended";
|
|
9
|
+
|
|
10
|
+
export interface PeerPin {
|
|
11
|
+
hostId: string;
|
|
12
|
+
machineId: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
signPub: string;
|
|
15
|
+
boxPub: string;
|
|
16
|
+
state: PinState;
|
|
17
|
+
suspendedReason: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Row {
|
|
21
|
+
host_id: string;
|
|
22
|
+
machine_id: string;
|
|
23
|
+
hostname: string;
|
|
24
|
+
sign_pub: string;
|
|
25
|
+
box_pub: string;
|
|
26
|
+
state: string;
|
|
27
|
+
suspended_reason: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const toPin = (r: Row | undefined): PeerPin | null =>
|
|
31
|
+
r
|
|
32
|
+
? {
|
|
33
|
+
hostId: r.host_id,
|
|
34
|
+
machineId: r.machine_id,
|
|
35
|
+
hostname: r.hostname,
|
|
36
|
+
signPub: r.sign_pub,
|
|
37
|
+
boxPub: r.box_pub,
|
|
38
|
+
state: r.state === "active" ? "active" : "suspended",
|
|
39
|
+
suspendedReason: r.suspended_reason,
|
|
40
|
+
}
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
// Only a host created as a Connect machine counts: a pin on any other row
|
|
44
|
+
// would be a second way in that nothing shows.
|
|
45
|
+
const SELECT = `SELECT p.* FROM peer_links p JOIN hosts h ON h.id = p.host_id
|
|
46
|
+
WHERE h.kind = 'connect'`;
|
|
47
|
+
|
|
48
|
+
export const pinForMachine = (machineId: string): PeerPin | null =>
|
|
49
|
+
toPin(
|
|
50
|
+
getDb().prepare(`${SELECT} AND p.machine_id = ?`).get(machineId) as
|
|
51
|
+
Row | undefined
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** Whether any linked machine may call in at all. */
|
|
55
|
+
export const hasActivePin = (): boolean =>
|
|
56
|
+
getDb().prepare(`${SELECT} AND p.state = 'active' LIMIT 1`).get() !==
|
|
57
|
+
undefined;
|
|
58
|
+
|
|
59
|
+
export const pinForHost = (hostId: string): PeerPin | null =>
|
|
60
|
+
toPin(
|
|
61
|
+
getDb().prepare(`${SELECT} AND p.host_id = ?`).get(hostId) as
|
|
62
|
+
Row | undefined
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
|
|
66
|
+
export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
|
|
67
|
+
getDb()
|
|
68
|
+
.prepare(
|
|
69
|
+
`INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?)
|
|
71
|
+
ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
|
|
72
|
+
hostname = excluded.hostname, sign_pub = excluded.sign_pub,
|
|
73
|
+
box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
|
|
74
|
+
paired_at = datetime('now'), last_error = NULL`
|
|
75
|
+
)
|
|
76
|
+
.run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function suspendPin(hostId: string, reason: string) {
|
|
80
|
+
getDb()
|
|
81
|
+
.prepare(
|
|
82
|
+
`UPDATE peer_links SET state = 'suspended', suspended_reason = ? WHERE host_id = ?`
|
|
83
|
+
)
|
|
84
|
+
.run(reason, hostId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Last outcome of a call to that peer, for Machines. */
|
|
88
|
+
export function notePinResult(hostId: string, error: string | null) {
|
|
89
|
+
getDb()
|
|
90
|
+
.prepare(
|
|
91
|
+
error
|
|
92
|
+
? `UPDATE peer_links SET last_error = ? WHERE host_id = ?`
|
|
93
|
+
: `UPDATE peer_links SET last_ok_at = datetime('now'), last_error = NULL WHERE host_id = ?`
|
|
94
|
+
)
|
|
95
|
+
.run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
|
|
96
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay guard's nonces in SQLite, so they outlive a restart. One
|
|
3
|
+
* statement both checks and records, so two requests carrying the same nonce
|
|
4
|
+
* can't both pass.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getDb } from "../db";
|
|
8
|
+
import type { NonceStore } from "./replay";
|
|
9
|
+
|
|
10
|
+
export function sqliteNonceStore(db = getDb): NonceStore {
|
|
11
|
+
return {
|
|
12
|
+
claim: (peer, nonce, until, now) =>
|
|
13
|
+
db()
|
|
14
|
+
.prepare(
|
|
15
|
+
`INSERT INTO peer_nonces (machine_id, nonce, until) VALUES (?, ?, ?)
|
|
16
|
+
ON CONFLICT (machine_id, nonce) DO UPDATE SET until = excluded.until
|
|
17
|
+
WHERE peer_nonces.until < ?`
|
|
18
|
+
)
|
|
19
|
+
.run(peer.slice(0, 64), nonce, until, now).changes === 1,
|
|
20
|
+
prune: (now) =>
|
|
21
|
+
void db().prepare(`DELETE FROM peer_nonces WHERE until < ?`).run(now),
|
|
22
|
+
};
|
|
23
|
+
}
|