@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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pairing messages between two machines, and the code both show.
|
|
3
|
+
* Everything here is public data, signed by its sender with its Connect
|
|
4
|
+
* machine key; each signature covers a domain string, both machine ids, a
|
|
5
|
+
* timestamp and a nonce, so no message stands in for another or for another
|
|
6
|
+
* pair of machines.
|
|
7
|
+
*
|
|
8
|
+
* A → B hello {from, to, ts, nonce, sign_pub, box_pub, commit}
|
|
9
|
+
* commit = SHA-256(a_box ‖ a_sign ‖ a_nonce)
|
|
10
|
+
* B → A ack {request_id, sign_pub, box_pub, b_nonce} (bound to the hello)
|
|
11
|
+
* A → B reveal {request_id, a_nonce}
|
|
12
|
+
* A → B status {request_id}
|
|
13
|
+
* B → A answer {state} (bound to that call's nonce)
|
|
14
|
+
*
|
|
15
|
+
* The code is HKDF over both machines' keys and both nonces. A has fixed
|
|
16
|
+
* its keys and nonce before it sees b_nonce, and B's nonce is fresh, so
|
|
17
|
+
* whoever substitutes keys gets one guess in a million per attempt.
|
|
18
|
+
*
|
|
19
|
+
* Two codes come from that transcript. A shows the first and a person types
|
|
20
|
+
* it on B, which proves to B that A's keys are the ones it holds. B then
|
|
21
|
+
* shows the second (a different HKDF info) and the person types it on A:
|
|
22
|
+
* that proves B's keys to A, so a control plane that lists a key of its own
|
|
23
|
+
* for B gets nothing pinned on either side.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto, { type KeyObject } from "crypto";
|
|
27
|
+
import { MACHINE_ID } from "../connect/control-api";
|
|
28
|
+
import { SKEW_MS } from "./envelope";
|
|
29
|
+
import { signPublic } from "./keys";
|
|
30
|
+
|
|
31
|
+
export const HELLO_MAX_BYTES = 4096;
|
|
32
|
+
export { SKEW_MS };
|
|
33
|
+
|
|
34
|
+
const B64_16 = /^[A-Za-z0-9_-]{22}$/;
|
|
35
|
+
const B64_32 = /^[A-Za-z0-9_-]{43}$/;
|
|
36
|
+
const B64_SIG = /^[A-Za-z0-9_-]{86}$/;
|
|
37
|
+
|
|
38
|
+
export type LinkState = "pending" | "accepted" | "declined" | "expired";
|
|
39
|
+
const STATES: readonly LinkState[] = [
|
|
40
|
+
"pending",
|
|
41
|
+
"accepted",
|
|
42
|
+
"declined",
|
|
43
|
+
"expired",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const D = {
|
|
47
|
+
hello: "agentos-peer-hello-v1",
|
|
48
|
+
ack: "agentos-peer-hello-ack-v1",
|
|
49
|
+
reveal: "agentos-peer-reveal-v1",
|
|
50
|
+
status: "agentos-peer-status-v1",
|
|
51
|
+
answer: "agentos-peer-answer-v1",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
const b64 = (b: Buffer) => b.toString("base64url");
|
|
55
|
+
const raw = (s: string) => Buffer.from(s, "base64url");
|
|
56
|
+
export const randomNonce = (bytes: 16 | 32) => b64(crypto.randomBytes(bytes));
|
|
57
|
+
|
|
58
|
+
// Every field is checked against a pattern with no newline before it's
|
|
59
|
+
// signed or verified, so joining with "\n" is unambiguous.
|
|
60
|
+
const message = (domain: string, fields: (string | number)[]) =>
|
|
61
|
+
Buffer.from([domain, ...fields.map(String)].join("\n"));
|
|
62
|
+
|
|
63
|
+
const signFields = (
|
|
64
|
+
key: KeyObject,
|
|
65
|
+
domain: string,
|
|
66
|
+
fields: (string | number)[]
|
|
67
|
+
) => b64(crypto.sign(null, message(domain, fields), key));
|
|
68
|
+
|
|
69
|
+
function verifyFields(
|
|
70
|
+
pub: string,
|
|
71
|
+
sig: string,
|
|
72
|
+
domain: string,
|
|
73
|
+
fields: (string | number)[]
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!B64_SIG.test(sig)) return false;
|
|
76
|
+
try {
|
|
77
|
+
return crypto.verify(
|
|
78
|
+
null,
|
|
79
|
+
message(domain, fields),
|
|
80
|
+
signPublic(pub),
|
|
81
|
+
raw(sig)
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const commitTo = (boxPub: string, signPub: string, nonce: string) =>
|
|
89
|
+
b64(
|
|
90
|
+
crypto
|
|
91
|
+
.createHash("sha256")
|
|
92
|
+
.update(Buffer.concat([raw(boxPub), raw(signPub), raw(nonce)]))
|
|
93
|
+
.digest()
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export interface Transcript {
|
|
97
|
+
from: string;
|
|
98
|
+
to: string;
|
|
99
|
+
aSign: string;
|
|
100
|
+
aBox: string;
|
|
101
|
+
bSign: string;
|
|
102
|
+
bBox: string;
|
|
103
|
+
aNonce: string;
|
|
104
|
+
bNonce: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const INFO = { sas: "agentos-peer-sas-v1", confirm: "agentos-peer-confirm-v1" };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Six digits from both sides' keys and nonces: `sas` is shown on A and typed
|
|
111
|
+
* on B, `confirm` is shown on B once that matched and typed on A.
|
|
112
|
+
*/
|
|
113
|
+
export function deriveCode(
|
|
114
|
+
o: Transcript,
|
|
115
|
+
purpose: keyof typeof INFO = "sas"
|
|
116
|
+
): string {
|
|
117
|
+
const ikm = Buffer.concat(
|
|
118
|
+
[o.aSign, o.aBox, o.bSign, o.bBox, o.aNonce, o.bNonce].map(raw)
|
|
119
|
+
);
|
|
120
|
+
const okm = Buffer.from(
|
|
121
|
+
crypto.hkdfSync(
|
|
122
|
+
"sha256",
|
|
123
|
+
ikm,
|
|
124
|
+
Buffer.alloc(0),
|
|
125
|
+
`${INFO[purpose]}|${o.from}|${o.to}`,
|
|
126
|
+
8
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
return (okm.readBigUInt64BE(0) % BigInt(1_000_000))
|
|
130
|
+
.toString()
|
|
131
|
+
.padStart(6, "0");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { showCode } from "./code";
|
|
135
|
+
|
|
136
|
+
/** What a person typed, as six digits, or null. */
|
|
137
|
+
export function typedCode(input: unknown): string | null {
|
|
138
|
+
if (typeof input !== "string" || input.length > 32) return null;
|
|
139
|
+
const digits = input.replace(/[\s-]/g, "");
|
|
140
|
+
return /^\d{6}$/.test(digits) ? digits : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const sameCode = (a: string, b: string) =>
|
|
144
|
+
a.length === b.length &&
|
|
145
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
146
|
+
|
|
147
|
+
// ----- the messages -----
|
|
148
|
+
|
|
149
|
+
interface Signed {
|
|
150
|
+
from: string;
|
|
151
|
+
to: string;
|
|
152
|
+
ts: number;
|
|
153
|
+
nonce: string;
|
|
154
|
+
sig: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface Hello extends Signed {
|
|
158
|
+
v: 1;
|
|
159
|
+
sign_pub: string;
|
|
160
|
+
box_pub: string;
|
|
161
|
+
commit: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface Ack {
|
|
165
|
+
request_id: string;
|
|
166
|
+
sign_pub: string;
|
|
167
|
+
box_pub: string;
|
|
168
|
+
b_nonce: string;
|
|
169
|
+
sig: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface Reveal extends Signed {
|
|
173
|
+
v: 1;
|
|
174
|
+
request_id: string;
|
|
175
|
+
a_nonce: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface StatusAsk extends Signed {
|
|
179
|
+
v: 1;
|
|
180
|
+
request_id: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface Answer {
|
|
184
|
+
state: LinkState;
|
|
185
|
+
sig: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const obj = (v: unknown): Record<string, unknown> | null =>
|
|
189
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
190
|
+
? (v as Record<string, unknown>)
|
|
191
|
+
: null;
|
|
192
|
+
|
|
193
|
+
function parseSigned(v: unknown): (Record<string, unknown> & Signed) | null {
|
|
194
|
+
const o = obj(v);
|
|
195
|
+
if (!o || o.v !== 1) return null;
|
|
196
|
+
if (typeof o.from !== "string" || !MACHINE_ID.test(o.from)) return null;
|
|
197
|
+
if (typeof o.to !== "string" || !MACHINE_ID.test(o.to)) return null;
|
|
198
|
+
if (typeof o.ts !== "number" || !Number.isSafeInteger(o.ts)) return null;
|
|
199
|
+
if (typeof o.nonce !== "string" || !B64_16.test(o.nonce)) return null;
|
|
200
|
+
if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
|
|
201
|
+
return o as Record<string, unknown> & Signed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const is32 = (v: unknown): v is string =>
|
|
205
|
+
typeof v === "string" && B64_32.test(v);
|
|
206
|
+
const is16 = (v: unknown): v is string =>
|
|
207
|
+
typeof v === "string" && B64_16.test(v);
|
|
208
|
+
|
|
209
|
+
export function parseHello(v: unknown): Hello | null {
|
|
210
|
+
const o = parseSigned(v);
|
|
211
|
+
if (!o || !is32(o.sign_pub) || !is32(o.box_pub) || !is32(o.commit))
|
|
212
|
+
return null;
|
|
213
|
+
return o as unknown as Hello;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function parseReveal(v: unknown): Reveal | null {
|
|
217
|
+
const o = parseSigned(v);
|
|
218
|
+
if (!o || !is16(o.request_id) || !is32(o.a_nonce)) return null;
|
|
219
|
+
return o as unknown as Reveal;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function parseStatusAsk(v: unknown): StatusAsk | null {
|
|
223
|
+
const o = parseSigned(v);
|
|
224
|
+
if (!o || !is16(o.request_id)) return null;
|
|
225
|
+
return o as unknown as StatusAsk;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parseAck(v: unknown): Ack | null {
|
|
229
|
+
const o = obj(v);
|
|
230
|
+
if (!o || !is16(o.request_id) || !is32(o.sign_pub) || !is32(o.box_pub))
|
|
231
|
+
return null;
|
|
232
|
+
if (!is32(o.b_nonce) || typeof o.sig !== "string" || !B64_SIG.test(o.sig))
|
|
233
|
+
return null;
|
|
234
|
+
return o as unknown as Ack;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function parseAnswer(v: unknown): Answer | null {
|
|
238
|
+
const o = obj(v);
|
|
239
|
+
if (!o || !STATES.includes(o.state as LinkState)) return null;
|
|
240
|
+
if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
|
|
241
|
+
return o as unknown as Answer;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const helloFields = (h: Omit<Hello, "sig" | "v">) => [
|
|
245
|
+
h.from,
|
|
246
|
+
h.to,
|
|
247
|
+
h.ts,
|
|
248
|
+
h.nonce,
|
|
249
|
+
h.sign_pub,
|
|
250
|
+
h.box_pub,
|
|
251
|
+
h.commit,
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
export function signHello(key: KeyObject, h: Omit<Hello, "sig" | "v">): Hello {
|
|
255
|
+
return { v: 1, ...h, sig: signFields(key, D.hello, helloFields(h)) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const verifyHello = (h: Hello) =>
|
|
259
|
+
verifyFields(h.sign_pub, h.sig, D.hello, helloFields(h));
|
|
260
|
+
|
|
261
|
+
// The ack names both machines and the hello it answers.
|
|
262
|
+
const ackFields = (a: Omit<Ack, "sig">, hello: Hello) => [
|
|
263
|
+
hello.to,
|
|
264
|
+
hello.from,
|
|
265
|
+
hello.nonce,
|
|
266
|
+
a.request_id,
|
|
267
|
+
a.sign_pub,
|
|
268
|
+
a.box_pub,
|
|
269
|
+
a.b_nonce,
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
export function signAck(
|
|
273
|
+
key: KeyObject,
|
|
274
|
+
a: Omit<Ack, "sig">,
|
|
275
|
+
hello: Hello
|
|
276
|
+
): Ack {
|
|
277
|
+
return { ...a, sig: signFields(key, D.ack, ackFields(a, hello)) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const verifyAck = (a: Ack, hello: Hello, bSign: string) =>
|
|
281
|
+
a.sign_pub === bSign &&
|
|
282
|
+
verifyFields(bSign, a.sig, D.ack, ackFields(a, hello));
|
|
283
|
+
|
|
284
|
+
const revealFields = (r: Omit<Reveal, "sig" | "v">) => [
|
|
285
|
+
r.from,
|
|
286
|
+
r.to,
|
|
287
|
+
r.ts,
|
|
288
|
+
r.nonce,
|
|
289
|
+
r.request_id,
|
|
290
|
+
r.a_nonce,
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
export function signReveal(
|
|
294
|
+
key: KeyObject,
|
|
295
|
+
r: Omit<Reveal, "sig" | "v">
|
|
296
|
+
): Reveal {
|
|
297
|
+
return { v: 1, ...r, sig: signFields(key, D.reveal, revealFields(r)) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const verifyReveal = (r: Reveal, aSign: string) =>
|
|
301
|
+
verifyFields(aSign, r.sig, D.reveal, revealFields(r));
|
|
302
|
+
|
|
303
|
+
const statusFields = (s: Omit<StatusAsk, "sig" | "v">) => [
|
|
304
|
+
s.from,
|
|
305
|
+
s.to,
|
|
306
|
+
s.ts,
|
|
307
|
+
s.nonce,
|
|
308
|
+
s.request_id,
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
export function signStatusAsk(
|
|
312
|
+
key: KeyObject,
|
|
313
|
+
s: Omit<StatusAsk, "sig" | "v">
|
|
314
|
+
): StatusAsk {
|
|
315
|
+
return { v: 1, ...s, sig: signFields(key, D.status, statusFields(s)) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const verifyStatusAsk = (s: StatusAsk, aSign: string) =>
|
|
319
|
+
verifyFields(aSign, s.sig, D.status, statusFields(s));
|
|
320
|
+
|
|
321
|
+
/** B's answer to a reveal or a status call, bound to that call. */
|
|
322
|
+
const answerFields = (state: LinkState, ask: Reveal | StatusAsk) => [
|
|
323
|
+
ask.to,
|
|
324
|
+
ask.from,
|
|
325
|
+
ask.request_id,
|
|
326
|
+
ask.nonce,
|
|
327
|
+
state,
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
export const signAnswer = (
|
|
331
|
+
key: KeyObject,
|
|
332
|
+
state: LinkState,
|
|
333
|
+
ask: Reveal | StatusAsk
|
|
334
|
+
): Answer => ({
|
|
335
|
+
state,
|
|
336
|
+
sig: signFields(key, D.answer, answerFields(state, ask)),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const verifyAnswer = (
|
|
340
|
+
a: Answer,
|
|
341
|
+
ask: Reveal | StatusAsk,
|
|
342
|
+
bSign: string
|
|
343
|
+
) => verifyFields(bSign, a.sig, D.answer, answerFields(a.state, ask));
|
|
344
|
+
|
|
345
|
+
export const withinSkew = (ts: number, now: number) =>
|
|
346
|
+
Math.abs(now - ts) <= SKEW_MS;
|
|
@@ -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
|
+
}
|