@saadnvd1/agent-os 0.3.694 → 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/components/Settings/NoApprovalsSection.tsx +1 -1
- 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/approvals/enable.ts +1 -21
- package/lib/approvals/no-approvals.ts +1 -1
- 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/models/plan-step.ts +5 -13
- 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/tasks/contract/plan.ts +1 -14
- package/lib/worktree-sweep/testTemp.ts +3 -0
- package/package.json +1 -1
- package/scripts/install-deps +6 -1
- package/scripts/lib/commands.sh +1 -1
- package/scripts/lib/wsl.test.ts +51 -1
- package/server.ts +18 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hostApi's transport for a machine linked through Connect: each call is a
|
|
3
|
+
* fresh envelope (envelope.ts) to POST https://<its m- name>/api/peer/rpc,
|
|
4
|
+
* and the answer only counts if it is signed by the pinned key and bound to
|
|
5
|
+
* this request's nonce. No token is ever sent or stored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { matchPeerRoute } from "./allowlist";
|
|
9
|
+
import { recordPeerCall, type AuditEntry, type Outcome } from "./audit";
|
|
10
|
+
import {
|
|
11
|
+
MAX_REQUEST_BODY,
|
|
12
|
+
MAX_RESPONSE_ENVELOPE,
|
|
13
|
+
RPC_PATH,
|
|
14
|
+
openResponse,
|
|
15
|
+
sealRequest,
|
|
16
|
+
} from "./envelope";
|
|
17
|
+
import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
|
|
18
|
+
import { notePinResult } from "./links";
|
|
19
|
+
import type { HostLink } from "../hosts/remote-api";
|
|
20
|
+
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A failed peer call. `refused`: the other machine (or this one, before
|
|
24
|
+
* sending) said no, so nothing happened there.
|
|
25
|
+
*/
|
|
26
|
+
export class PeerCallError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
readonly refused: boolean,
|
|
30
|
+
readonly status?: number
|
|
31
|
+
) {
|
|
32
|
+
super(message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Only a Connect machine's own name, so a lying account list can't aim this
|
|
40
|
+
* machine at an internal address. AGENTOS_CONNECT_PEER_DOMAIN is for a
|
|
41
|
+
* staging service.
|
|
42
|
+
*/
|
|
43
|
+
export function peerHostAllowed(hostname: string, env = process.env): boolean {
|
|
44
|
+
const configured = env.AGENTOS_CONNECT_PEER_DOMAIN ?? "";
|
|
45
|
+
const domain = DOMAIN.test(configured) ? configured : "runagentos.com";
|
|
46
|
+
const escaped = domain.replace(/\./g, "\\.");
|
|
47
|
+
return new RegExp(`^m-[0-9a-f]{16}\\.${escaped}$`).test(hostname);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The fixed refusals rpc-server.ts sends before anything is decrypted.
|
|
51
|
+
const REFUSALS: Record<string, Outcome> = {
|
|
52
|
+
"bad signature": "bad_sig",
|
|
53
|
+
"bad envelope": "bad_sig",
|
|
54
|
+
stale: "stale",
|
|
55
|
+
replay: "replay",
|
|
56
|
+
busy: "replay",
|
|
57
|
+
"too large": "bad_sig",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const HINTS: Record<string, string> = {
|
|
61
|
+
"bad signature":
|
|
62
|
+
"it doesn't know this machine, the link is suspended there, or the keys don't match",
|
|
63
|
+
stale: "the clocks on the two machines differ by more than 2 minutes",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface ClientDeps {
|
|
67
|
+
identity: () => Identity | null;
|
|
68
|
+
audit: (e: AuditEntry) => void;
|
|
69
|
+
noteResult: (hostId: string, error: string | null) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const realDeps: ClientDeps = {
|
|
73
|
+
identity: () => currentIdentity(),
|
|
74
|
+
audit: recordPeerCall,
|
|
75
|
+
noteResult: notePinResult,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export async function peerCall(
|
|
79
|
+
link: HostLink,
|
|
80
|
+
target: string,
|
|
81
|
+
opts: { method?: string; body?: unknown; timeout?: number },
|
|
82
|
+
deps: ClientDeps = realDeps
|
|
83
|
+
): Promise<{ status: number; text: string }> {
|
|
84
|
+
const peer = link.connect;
|
|
85
|
+
const refuse = (msg: string): never => {
|
|
86
|
+
throw new PeerCallError(msg, true);
|
|
87
|
+
};
|
|
88
|
+
if (!peer) return refuse("isn't linked through Connect");
|
|
89
|
+
if (peer.state !== "active")
|
|
90
|
+
return refuse(
|
|
91
|
+
`the link is suspended (${peer.suspendedReason ?? "no reason given"})`
|
|
92
|
+
);
|
|
93
|
+
if (!peerHostAllowed(peer.hostname))
|
|
94
|
+
return refuse("its address isn't a Connect machine's");
|
|
95
|
+
const method = (
|
|
96
|
+
opts.method ?? (opts.body === undefined ? "GET" : "POST")
|
|
97
|
+
).toUpperCase();
|
|
98
|
+
const q = target.indexOf("?");
|
|
99
|
+
const match = matchPeerRoute(
|
|
100
|
+
method,
|
|
101
|
+
q < 0 ? target : target.slice(0, q),
|
|
102
|
+
q < 0 ? "" : target.slice(q + 1)
|
|
103
|
+
);
|
|
104
|
+
if (!match.ok) return refuse("that call isn't open to linked machines");
|
|
105
|
+
const identity = deps.identity();
|
|
106
|
+
if (!identity)
|
|
107
|
+
return refuse("this machine isn't connected to AgentOS Connect");
|
|
108
|
+
const body =
|
|
109
|
+
opts.body === undefined
|
|
110
|
+
? Buffer.alloc(0)
|
|
111
|
+
: Buffer.from(JSON.stringify(opts.body));
|
|
112
|
+
if (body.length > MAX_REQUEST_BODY) return refuse("the request is too large");
|
|
113
|
+
|
|
114
|
+
const started = Date.now();
|
|
115
|
+
const audit = (outcome: Outcome, status: number, bytes: number) =>
|
|
116
|
+
deps.audit({
|
|
117
|
+
hostId: link.hostId,
|
|
118
|
+
machineId: peer.machineId,
|
|
119
|
+
direction: "out",
|
|
120
|
+
method,
|
|
121
|
+
route: match.route,
|
|
122
|
+
status,
|
|
123
|
+
ms: Date.now() - started,
|
|
124
|
+
bytes,
|
|
125
|
+
outcome,
|
|
126
|
+
});
|
|
127
|
+
const sealed = sealRequest({
|
|
128
|
+
from: identity.machineId,
|
|
129
|
+
to: peer.machineId,
|
|
130
|
+
signKey: identity.signKey,
|
|
131
|
+
peerBox: boxPublic(peer.boxPub),
|
|
132
|
+
request: {
|
|
133
|
+
method,
|
|
134
|
+
path: match.path,
|
|
135
|
+
query: match.query,
|
|
136
|
+
contentType: body.length ? "application/json" : "",
|
|
137
|
+
body,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
let res: Response;
|
|
141
|
+
let raw: Buffer;
|
|
142
|
+
try {
|
|
143
|
+
res = await fetch(`https://${peer.hostname}${RPC_PATH}`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
146
|
+
body: new Uint8Array(sealed.envelope),
|
|
147
|
+
signal: AbortSignal.timeout(opts.timeout ?? 15000),
|
|
148
|
+
redirect: "error",
|
|
149
|
+
});
|
|
150
|
+
raw = await readCappedBytes(res, MAX_RESPONSE_ENVELOPE);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
153
|
+
deps.noteResult(link.hostId, message);
|
|
154
|
+
throw new PeerCallError(message, false);
|
|
155
|
+
}
|
|
156
|
+
if (res.status !== 200) {
|
|
157
|
+
const text = raw.subarray(0, 64).toString("utf8").trim();
|
|
158
|
+
const outcome = REFUSALS[text];
|
|
159
|
+
if (outcome && res.status >= 400 && res.status < 500) {
|
|
160
|
+
audit(outcome, res.status, sealed.envelope.length);
|
|
161
|
+
const message = HINTS[text] ? `${text}: ${HINTS[text]}` : text;
|
|
162
|
+
deps.noteResult(link.hostId, message);
|
|
163
|
+
// A refusal is unsigned: anyone on the path could forge one. A read
|
|
164
|
+
// changes nothing either way, but a write may have happened.
|
|
165
|
+
throw new PeerCallError(message, method === "GET", res.status);
|
|
166
|
+
}
|
|
167
|
+
deps.noteResult(link.hostId, `HTTP ${res.status}`);
|
|
168
|
+
throw new PeerCallError(`HTTP ${res.status}`, false, res.status);
|
|
169
|
+
}
|
|
170
|
+
let answer;
|
|
171
|
+
try {
|
|
172
|
+
answer = openResponse({
|
|
173
|
+
envelope: raw,
|
|
174
|
+
nonce: sealed.nonce,
|
|
175
|
+
resKey: sealed.resKey,
|
|
176
|
+
peerSign: signPublic(peer.signPub),
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// It may have run there; the answer just can't be trusted.
|
|
180
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
181
|
+
audit("bad_sig", 200, sealed.envelope.length + raw.length);
|
|
182
|
+
deps.noteResult(link.hostId, message);
|
|
183
|
+
throw new PeerCallError(message, false);
|
|
184
|
+
}
|
|
185
|
+
audit("ok", answer.status, body.length + answer.body.length);
|
|
186
|
+
deps.noteResult(link.hostId, null);
|
|
187
|
+
return { status: answer.status, text: answer.body.toString("utf8") };
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|