@agentproto/secrets 0.2.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-Q6K3AT3V.mjs → chunk-2FHFW2AU.mjs} +3 -3
- package/dist/{chunk-Q6K3AT3V.mjs.map → chunk-2FHFW2AU.mjs.map} +1 -1
- package/dist/chunk-3YRYO6LK.mjs +222 -0
- package/dist/chunk-3YRYO6LK.mjs.map +1 -0
- package/dist/chunk-432SC3XS.mjs +100 -0
- package/dist/chunk-432SC3XS.mjs.map +1 -0
- package/dist/chunk-5HUHYBPR.mjs +69 -0
- package/dist/chunk-5HUHYBPR.mjs.map +1 -0
- package/dist/{chunk-NLZ5HXGO.mjs → chunk-7V6U6DII.mjs} +4 -4
- package/dist/{chunk-NLZ5HXGO.mjs.map → chunk-7V6U6DII.mjs.map} +1 -1
- package/dist/chunk-JNTDSOZQ.mjs +623 -0
- package/dist/chunk-JNTDSOZQ.mjs.map +1 -0
- package/dist/chunk-NQX3KBPW.mjs +50 -0
- package/dist/chunk-NQX3KBPW.mjs.map +1 -0
- package/dist/chunk-PCFPJ47T.mjs +28 -0
- package/dist/chunk-PCFPJ47T.mjs.map +1 -0
- package/dist/cli.mjs +10 -7
- package/dist/cli.mjs.map +1 -1
- package/dist/core-CIn0-z-Y.d.ts +42 -0
- package/dist/core-D8zVNPDb.d.ts +58 -0
- package/dist/identity/index.d.ts +25 -46
- package/dist/identity/index.mjs +76 -1
- package/dist/identity/index.mjs.map +1 -1
- package/dist/pairing/browser.d.ts +18 -0
- package/dist/pairing/browser.mjs +6 -0
- package/dist/pairing/browser.mjs.map +1 -0
- package/dist/pairing/index.d.ts +45 -440
- package/dist/pairing/index.mjs +33 -465
- package/dist/pairing/index.mjs.map +1 -1
- package/dist/provision/index.mjs +5 -2
- package/dist/provision/recipe/index.mjs +6 -3
- package/dist/seal/index.d.ts +21 -31
- package/dist/seal/index.mjs +4 -1
- package/dist/types-B2ZfEhsU.d.ts +57 -0
- package/dist/webcrypto-BU57p0_g.d.ts +652 -0
- package/package.json +8 -2
- package/dist/chunk-2DL6W33G.mjs +0 -126
- package/dist/chunk-2DL6W33G.mjs.map +0 -1
- package/dist/chunk-MVHOJPML.mjs +0 -132
- package/dist/chunk-MVHOJPML.mjs.map +0 -1
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { identityFingerprint, verifyTranscript, signTranscript } from './chunk-NQX3KBPW.mjs';
|
|
2
|
+
import { seal, unseal, SealError } from './chunk-432SC3XS.mjs';
|
|
3
|
+
import { webCryptoProvider, compareBytes, concatBytes, utf8Encode, base64Encode, base64Decode, base64UrlEncode, utf8Decode, u64be, timingSafeEqualBytes } from './chunk-3YRYO6LK.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @agentproto/secrets v0.1.0-alpha
|
|
7
|
+
* AIP-19 SECRETS.md `defineSecrets` reference implementation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// src/pairing/derive.ts
|
|
11
|
+
var PAIR_ROOT_INFO = "agentproto/pair-root";
|
|
12
|
+
var RV_ROUTE_INFO = "agentproto/rv-route";
|
|
13
|
+
var RV_AUTH_INFO = "agentproto/rv-auth";
|
|
14
|
+
var RV_ROUTE_SALT = "agentproto/rv-route-salt";
|
|
15
|
+
var RV_AUTH_SALT = "agentproto/rv-auth-salt";
|
|
16
|
+
var OFFER_SALT = "agentproto/pair-offer";
|
|
17
|
+
var ROUTE_TOKEN_LEN = 16;
|
|
18
|
+
var AUTH_TOKEN_LEN = 32;
|
|
19
|
+
var PAIR_ROOT_LEN = 32;
|
|
20
|
+
var MS_PER_DAY = 864e5;
|
|
21
|
+
async function derivePairRoot(session, crypto = webCryptoProvider) {
|
|
22
|
+
const k1 = session.sendKey;
|
|
23
|
+
const k2 = session.recvKey;
|
|
24
|
+
const [lo, hi] = compareBytes(k1, k2) <= 0 ? [k1, k2] : [k2, k1];
|
|
25
|
+
const ikm = concatBytes(lo, hi);
|
|
26
|
+
const okm = await crypto.hkdfSha256(ikm, session.transcriptHash, utf8Encode(PAIR_ROOT_INFO), PAIR_ROOT_LEN);
|
|
27
|
+
return base64Encode(okm);
|
|
28
|
+
}
|
|
29
|
+
function currentEpoch(now = Date.now()) {
|
|
30
|
+
return Math.floor(now / MS_PER_DAY);
|
|
31
|
+
}
|
|
32
|
+
async function importPairRootKey(pairRoot) {
|
|
33
|
+
return globalThis.crypto.subtle.importKey(
|
|
34
|
+
"raw",
|
|
35
|
+
Uint8Array.from(base64Decode(pairRoot)),
|
|
36
|
+
"HKDF",
|
|
37
|
+
false,
|
|
38
|
+
["deriveBits"]
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
async function pairRootHkdf(pairRoot, salt, info, length, crypto) {
|
|
42
|
+
if (typeof pairRoot === "string") {
|
|
43
|
+
return crypto.hkdfSha256(base64Decode(pairRoot), salt, info, length);
|
|
44
|
+
}
|
|
45
|
+
const bits = await globalThis.crypto.subtle.deriveBits(
|
|
46
|
+
{ name: "HKDF", hash: "SHA-256", salt: Uint8Array.from(salt), info: Uint8Array.from(info) },
|
|
47
|
+
pairRoot,
|
|
48
|
+
length * 8
|
|
49
|
+
);
|
|
50
|
+
return new Uint8Array(bits);
|
|
51
|
+
}
|
|
52
|
+
function epochInfo(label, epoch) {
|
|
53
|
+
return concatBytes(utf8Encode(label), u64be(epoch));
|
|
54
|
+
}
|
|
55
|
+
async function deriveEpochRoutingToken(pairRoot, epoch, crypto = webCryptoProvider) {
|
|
56
|
+
const okm = await pairRootHkdf(
|
|
57
|
+
pairRoot,
|
|
58
|
+
utf8Encode(RV_ROUTE_SALT),
|
|
59
|
+
epochInfo(RV_ROUTE_INFO, epoch),
|
|
60
|
+
ROUTE_TOKEN_LEN,
|
|
61
|
+
crypto
|
|
62
|
+
);
|
|
63
|
+
return base64UrlEncode(okm);
|
|
64
|
+
}
|
|
65
|
+
async function deriveEpochAuthToken(pairRoot, epoch, crypto = webCryptoProvider) {
|
|
66
|
+
const okm = await pairRootHkdf(
|
|
67
|
+
pairRoot,
|
|
68
|
+
utf8Encode(RV_AUTH_SALT),
|
|
69
|
+
epochInfo(RV_AUTH_INFO, epoch),
|
|
70
|
+
AUTH_TOKEN_LEN,
|
|
71
|
+
crypto
|
|
72
|
+
);
|
|
73
|
+
return base64UrlEncode(okm);
|
|
74
|
+
}
|
|
75
|
+
async function deriveEpochTokens(pairRoot, epoch, crypto = webCryptoProvider) {
|
|
76
|
+
return {
|
|
77
|
+
route: await deriveEpochRoutingToken(pairRoot, epoch, crypto),
|
|
78
|
+
auth: await deriveEpochAuthToken(pairRoot, epoch, crypto)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function deriveOfferTokens(offerSecret, crypto = webCryptoProvider) {
|
|
82
|
+
const ikm = utf8Encode(offerSecret);
|
|
83
|
+
const salt = utf8Encode(OFFER_SALT);
|
|
84
|
+
const route = await crypto.hkdfSha256(ikm, salt, utf8Encode(RV_ROUTE_INFO), ROUTE_TOKEN_LEN);
|
|
85
|
+
const auth = await crypto.hkdfSha256(ikm, salt, utf8Encode(RV_AUTH_INFO), AUTH_TOKEN_LEN);
|
|
86
|
+
return { route: base64UrlEncode(route), auth: base64UrlEncode(auth) };
|
|
87
|
+
}
|
|
88
|
+
async function epochRoutingTokens(pairRoot, now = Date.now(), crypto = webCryptoProvider) {
|
|
89
|
+
const epoch = currentEpoch(now);
|
|
90
|
+
return [
|
|
91
|
+
{ epoch, ...await deriveEpochTokens(pairRoot, epoch, crypto) },
|
|
92
|
+
{ epoch: epoch - 1, ...await deriveEpochTokens(pairRoot, epoch - 1, crypto) }
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/pairing/handshake.ts
|
|
97
|
+
var PAIR_VERSION = 2;
|
|
98
|
+
var LEGACY_PAIR_VERSION = 1;
|
|
99
|
+
var PAIRING_PROTOCOL_OUTDATED_MESSAGE = "this pairing uses the retired pair/v1 protocol, which let the rendezvous broker authenticate as a client. Upgrade agentproto on both machines and re-pair: run `agentproto pair offer` on the daemon, then `agentproto pair accept` on the client";
|
|
100
|
+
function hkdfInfo(version) {
|
|
101
|
+
return utf8Encode(`agentproto/pair/v${version}`);
|
|
102
|
+
}
|
|
103
|
+
var KEY_LEN = 32;
|
|
104
|
+
var PairingError = class extends Error {
|
|
105
|
+
code;
|
|
106
|
+
constructor(code, message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = "PairingError";
|
|
109
|
+
this.code = code;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
async function x25519PublicKey(crypto, b64Der, what) {
|
|
113
|
+
const der = base64Decode(b64Der);
|
|
114
|
+
try {
|
|
115
|
+
await crypto.x25519ValidatePublicKey(der);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new PairingError("invalid_key", `invalid ${what} X25519 public key`);
|
|
118
|
+
}
|
|
119
|
+
return der;
|
|
120
|
+
}
|
|
121
|
+
function transcriptHash(crypto, ePubB64, ct0, dePubB64) {
|
|
122
|
+
return crypto.sha256(concatBytes(base64Decode(ePubB64), utf8Encode(ct0), base64Decode(dePubB64)));
|
|
123
|
+
}
|
|
124
|
+
async function deriveDirectionKeys(crypto, version, ecdhEphemeral, ecdhStatic, transcript) {
|
|
125
|
+
const ikm = concatBytes(ecdhEphemeral, ecdhStatic);
|
|
126
|
+
const okm = await crypto.hkdfSha256(ikm, transcript, hkdfInfo(version), KEY_LEN * 2);
|
|
127
|
+
return {
|
|
128
|
+
kc2d: okm.subarray(0, KEY_LEN),
|
|
129
|
+
kd2c: okm.subarray(KEY_LEN, KEY_LEN * 2)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function startClientHandshake(params, crypto = webCryptoProvider) {
|
|
133
|
+
const daemonStatic = await x25519PublicKey(crypto, params.daemonX25519Pub, "daemon");
|
|
134
|
+
const ephemeral = await crypto.x25519GenerateKeyPair();
|
|
135
|
+
const ePubB64 = base64Encode(ephemeral.publicKey);
|
|
136
|
+
const payload = {
|
|
137
|
+
clientPub: ePubB64,
|
|
138
|
+
clientName: params.clientName,
|
|
139
|
+
auth: params.authToken
|
|
140
|
+
};
|
|
141
|
+
const ct0 = await seal(JSON.stringify(payload), params.daemonX25519Pub, crypto);
|
|
142
|
+
const hello = { v: PAIR_VERSION, ePub: ePubB64, ct0 };
|
|
143
|
+
const complete = async (reply) => {
|
|
144
|
+
if (reply.v !== PAIR_VERSION) {
|
|
145
|
+
throw new PairingError("malformed_reply", `unsupported reply version ${reply.v}`);
|
|
146
|
+
}
|
|
147
|
+
const dePub = await x25519PublicKey(crypto, reply.dePub, "daemon ephemeral");
|
|
148
|
+
const transcript = await transcriptHash(crypto, ePubB64, ct0, reply.dePub);
|
|
149
|
+
if (!await verifyTranscript(params.daemonEd25519Pub, transcript, reply.sig, crypto)) {
|
|
150
|
+
throw new PairingError(
|
|
151
|
+
"bad_signature",
|
|
152
|
+
"daemon transcript signature did not verify against the offered key"
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const ecdhEphemeral = await crypto.x25519(ephemeral.privateKey, dePub);
|
|
156
|
+
const ecdhStatic = await crypto.x25519(ephemeral.privateKey, daemonStatic);
|
|
157
|
+
const { kc2d, kd2c } = await deriveDirectionKeys(crypto, PAIR_VERSION, ecdhEphemeral, ecdhStatic, transcript);
|
|
158
|
+
return {
|
|
159
|
+
sendKey: kc2d,
|
|
160
|
+
recvKey: kd2c,
|
|
161
|
+
peerFingerprint: await identityFingerprint(params.daemonX25519Pub, crypto),
|
|
162
|
+
transcriptHash: transcript,
|
|
163
|
+
clientName: params.clientName
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
return { hello, complete };
|
|
167
|
+
}
|
|
168
|
+
async function openHello(crypto, ePub, ct0, identity) {
|
|
169
|
+
const clientEphemeral = await x25519PublicKey(crypto, ePub, "client ephemeral");
|
|
170
|
+
let payloadJson;
|
|
171
|
+
try {
|
|
172
|
+
payloadJson = await unseal(ct0, identity.x25519.priv, crypto);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof SealError) {
|
|
175
|
+
throw new PairingError(
|
|
176
|
+
"unseal_failed",
|
|
177
|
+
"could not open sealed hello \u2014 wrong daemon key or tampered ct\u2080"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
let payload;
|
|
183
|
+
try {
|
|
184
|
+
payload = JSON.parse(payloadJson);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new PairingError("malformed_hello", "sealed hello payload was not valid JSON");
|
|
187
|
+
}
|
|
188
|
+
if (!isRecord(payload) || typeof payload["clientPub"] !== "string") {
|
|
189
|
+
throw new PairingError("malformed_hello", "sealed hello payload is missing required fields");
|
|
190
|
+
}
|
|
191
|
+
if (payload["clientPub"] !== ePub) {
|
|
192
|
+
throw new PairingError(
|
|
193
|
+
"ephemeral_mismatch",
|
|
194
|
+
"sealed clientPub does not match the hello ephemeral key"
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return { clientEphemeral, payload };
|
|
198
|
+
}
|
|
199
|
+
async function completeDaemonSide(crypto, version, identity, ePub, ct0, clientEphemeral) {
|
|
200
|
+
const daemonEphemeral = await crypto.x25519GenerateKeyPair();
|
|
201
|
+
const dePubB64 = base64Encode(daemonEphemeral.publicKey);
|
|
202
|
+
const transcript = await transcriptHash(crypto, ePub, ct0, dePubB64);
|
|
203
|
+
const sig = await signTranscript(identity.ed25519.priv, transcript, crypto);
|
|
204
|
+
const daemonStaticPriv = base64Decode(identity.x25519.priv);
|
|
205
|
+
const ecdhEphemeral = await crypto.x25519(daemonEphemeral.privateKey, clientEphemeral);
|
|
206
|
+
let ecdhStatic;
|
|
207
|
+
try {
|
|
208
|
+
ecdhStatic = await crypto.x25519(daemonStaticPriv, clientEphemeral);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new PairingError("invalid_key", "invalid daemon static X25519 private key");
|
|
211
|
+
}
|
|
212
|
+
const { kc2d, kd2c } = await deriveDirectionKeys(crypto, version, ecdhEphemeral, ecdhStatic, transcript);
|
|
213
|
+
return { dePub: dePubB64, sig, sendKey: kd2c, recvKey: kc2d, transcript };
|
|
214
|
+
}
|
|
215
|
+
async function respondToHandshake(hello, params, crypto = webCryptoProvider) {
|
|
216
|
+
if (hello.v !== PAIR_VERSION) {
|
|
217
|
+
throw new PairingError("malformed_hello", `unsupported hello version ${hello.v}`);
|
|
218
|
+
}
|
|
219
|
+
const { clientEphemeral, payload: raw } = await openHello(crypto, hello.ePub, hello.ct0, params.identity);
|
|
220
|
+
const payload = parseHelloPayload(raw);
|
|
221
|
+
if (!await params.verifyAuthToken(payload.auth)) {
|
|
222
|
+
throw new PairingError("offer_rejected", "auth token was rejected (unknown, expired, spent, or wrong)");
|
|
223
|
+
}
|
|
224
|
+
const d = await completeDaemonSide(crypto, PAIR_VERSION, params.identity, hello.ePub, hello.ct0, clientEphemeral);
|
|
225
|
+
return {
|
|
226
|
+
reply: { v: PAIR_VERSION, dePub: d.dePub, sig: d.sig },
|
|
227
|
+
session: {
|
|
228
|
+
sendKey: d.sendKey,
|
|
229
|
+
recvKey: d.recvKey,
|
|
230
|
+
peerFingerprint: await identityFingerprint(payload.clientPub, crypto),
|
|
231
|
+
transcriptHash: d.transcript,
|
|
232
|
+
clientName: payload.clientName
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async function respondToLegacyHandshake(helloBytes, identity, crypto = webCryptoProvider) {
|
|
237
|
+
const parsed = parseJson(helloBytes, "malformed_hello");
|
|
238
|
+
if (!isRecord(parsed) || parsed["v"] !== LEGACY_PAIR_VERSION || typeof parsed["ePub"] !== "string" || typeof parsed["ct0"] !== "string") {
|
|
239
|
+
throw new PairingError("malformed_hello", "not a pair/v1 hello");
|
|
240
|
+
}
|
|
241
|
+
const ePub = parsed["ePub"];
|
|
242
|
+
const ct0 = parsed["ct0"];
|
|
243
|
+
const { clientEphemeral, payload } = await openHello(crypto, ePub, ct0, identity);
|
|
244
|
+
if (typeof payload["offerToken"] !== "string") {
|
|
245
|
+
throw new PairingError("malformed_hello", "not a pair/v1 hello payload");
|
|
246
|
+
}
|
|
247
|
+
const d = await completeDaemonSide(crypto, LEGACY_PAIR_VERSION, identity, ePub, ct0, clientEphemeral);
|
|
248
|
+
return {
|
|
249
|
+
reply: utf8Encode(JSON.stringify({ v: LEGACY_PAIR_VERSION, dePub: d.dePub, sig: d.sig })),
|
|
250
|
+
keys: { sendKey: d.sendKey, recvKey: d.recvKey }
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function isRecord(v) {
|
|
254
|
+
return typeof v === "object" && v !== null;
|
|
255
|
+
}
|
|
256
|
+
function parseHelloPayload(parsed) {
|
|
257
|
+
if (typeof parsed["clientPub"] !== "string" || typeof parsed["clientName"] !== "string" || typeof parsed["auth"] !== "string") {
|
|
258
|
+
throw new PairingError("malformed_hello", "sealed hello payload is missing required fields");
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
clientPub: parsed["clientPub"],
|
|
262
|
+
clientName: parsed["clientName"],
|
|
263
|
+
auth: parsed["auth"]
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function encodePairingMessage(message) {
|
|
267
|
+
return utf8Encode(JSON.stringify(message));
|
|
268
|
+
}
|
|
269
|
+
function decodePairingHello(bytes) {
|
|
270
|
+
const parsed = parseJson(bytes, "malformed_hello");
|
|
271
|
+
if (isRecord(parsed) && parsed["v"] === LEGACY_PAIR_VERSION) {
|
|
272
|
+
throw new PairingError("pairing_protocol_outdated", PAIRING_PROTOCOL_OUTDATED_MESSAGE);
|
|
273
|
+
}
|
|
274
|
+
if (!isRecord(parsed) || parsed["v"] !== PAIR_VERSION || typeof parsed["ePub"] !== "string" || typeof parsed["ct0"] !== "string") {
|
|
275
|
+
throw new PairingError("malformed_hello", "hello message is malformed");
|
|
276
|
+
}
|
|
277
|
+
return { v: PAIR_VERSION, ePub: parsed["ePub"], ct0: parsed["ct0"] };
|
|
278
|
+
}
|
|
279
|
+
function decodePairingReply(bytes) {
|
|
280
|
+
const parsed = parseJson(bytes, "malformed_reply");
|
|
281
|
+
if (isRecord(parsed) && parsed["v"] === LEGACY_PAIR_VERSION) {
|
|
282
|
+
throw new PairingError("pairing_protocol_outdated", PAIRING_PROTOCOL_OUTDATED_MESSAGE);
|
|
283
|
+
}
|
|
284
|
+
if (!isRecord(parsed) || parsed["v"] !== PAIR_VERSION || typeof parsed["dePub"] !== "string" || typeof parsed["sig"] !== "string") {
|
|
285
|
+
throw new PairingError("malformed_reply", "reply message is malformed");
|
|
286
|
+
}
|
|
287
|
+
return { v: PAIR_VERSION, dePub: parsed["dePub"], sig: parsed["sig"] };
|
|
288
|
+
}
|
|
289
|
+
function parseJson(bytes, code) {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(utf8Decode(bytes));
|
|
292
|
+
} catch {
|
|
293
|
+
throw new PairingError(code, "message was not valid JSON (truncated or corrupt)");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/pairing/offer-url.ts
|
|
298
|
+
var OFFER_URL_SCHEME = "agentproto:";
|
|
299
|
+
var OFFER_URL_HOST = "pair";
|
|
300
|
+
var OFFER_VERSION = 2;
|
|
301
|
+
var PAIR_WEB_URL = "https://cli.agentproto.sh/pair";
|
|
302
|
+
var PAIR_PAGE_FP_PLACEHOLDER = "{fp}";
|
|
303
|
+
var PAIR_WEB_URL_TEMPLATE_CLOUD = "https://{fp}.agentproto.cloud/pair";
|
|
304
|
+
var DEFAULT_PAIR_PAGE = PAIR_WEB_URL_TEMPLATE_CLOUD;
|
|
305
|
+
function b64ToB64url(b64) {
|
|
306
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
307
|
+
}
|
|
308
|
+
function b64urlToB64(b64url) {
|
|
309
|
+
const replaced = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
310
|
+
const pad = replaced.length % 4;
|
|
311
|
+
return pad === 0 ? replaced : replaced + "=".repeat(4 - pad);
|
|
312
|
+
}
|
|
313
|
+
function isB64url(s) {
|
|
314
|
+
return s.length > 0 && /^[A-Za-z0-9_-]+$/.test(s);
|
|
315
|
+
}
|
|
316
|
+
function encodeOfferUrl(offer) {
|
|
317
|
+
const params = new URLSearchParams();
|
|
318
|
+
params.set("v", String(OFFER_VERSION));
|
|
319
|
+
params.set("rv", offer.rendezvousUrl);
|
|
320
|
+
params.set("id", offer.fingerprint);
|
|
321
|
+
params.set("pk", b64ToB64url(offer.daemonX25519Pub));
|
|
322
|
+
params.set("sk", b64ToB64url(offer.daemonEd25519Pub));
|
|
323
|
+
params.set("s", offer.secret);
|
|
324
|
+
params.set("exp", String(offer.exp));
|
|
325
|
+
return `${OFFER_URL_SCHEME}//${OFFER_URL_HOST}?${params.toString()}`;
|
|
326
|
+
}
|
|
327
|
+
var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
328
|
+
function badPage(message) {
|
|
329
|
+
return new PairingError("malformed_offer", `pair page: ${message}`);
|
|
330
|
+
}
|
|
331
|
+
function resolvePairPageUrl(templateOrUrl, fingerprint) {
|
|
332
|
+
let resolved = templateOrUrl;
|
|
333
|
+
if (templateOrUrl.includes(PAIR_PAGE_FP_PLACEHOLDER)) {
|
|
334
|
+
const m = /^(https?:\/\/)([^/?#]*)(.*)$/i.exec(templateOrUrl);
|
|
335
|
+
if (!m) throw badPage("a {fp} template must be an http(s) URL");
|
|
336
|
+
const [, scheme, authority, rest] = m;
|
|
337
|
+
if (rest.includes(PAIR_PAGE_FP_PLACEHOLDER)) {
|
|
338
|
+
throw badPage("{fp} is only allowed in the hostname");
|
|
339
|
+
}
|
|
340
|
+
if (authority.includes("@")) throw badPage("userinfo is not allowed in a pair page URL");
|
|
341
|
+
const colon = authority.lastIndexOf(":");
|
|
342
|
+
const port = colon >= 0 && !authority.includes("]") ? authority.slice(colon) : "";
|
|
343
|
+
if (port.includes(PAIR_PAGE_FP_PLACEHOLDER)) throw badPage("{fp} is only allowed in the hostname");
|
|
344
|
+
const fp = fingerprint.toLowerCase();
|
|
345
|
+
if (!/^[0-9a-f]+$/.test(fp) || !DNS_LABEL.test(fp)) {
|
|
346
|
+
throw badPage(`daemon fingerprint "${fingerprint}" is not a valid DNS label`);
|
|
347
|
+
}
|
|
348
|
+
resolved = `${scheme}${authority.split(PAIR_PAGE_FP_PLACEHOLDER).join(fp)}${rest}`;
|
|
349
|
+
}
|
|
350
|
+
if (/[{}]/.test(resolved)) {
|
|
351
|
+
throw badPage("unknown placeholder (only {fp}, in the hostname, is supported)");
|
|
352
|
+
}
|
|
353
|
+
let page;
|
|
354
|
+
try {
|
|
355
|
+
page = new URL(resolved);
|
|
356
|
+
} catch {
|
|
357
|
+
throw badPage("not a valid URL");
|
|
358
|
+
}
|
|
359
|
+
if (page.protocol !== "https:" && page.protocol !== "http:" || page.hash !== "" || resolved.includes("#")) {
|
|
360
|
+
throw badPage("must be an http(s) URL without a fragment");
|
|
361
|
+
}
|
|
362
|
+
return resolved;
|
|
363
|
+
}
|
|
364
|
+
function expectedPairHost(templateOrUrl, fingerprint) {
|
|
365
|
+
return new URL(resolvePairPageUrl(templateOrUrl, fingerprint)).host;
|
|
366
|
+
}
|
|
367
|
+
function encodeOfferWebUrl(offerUrl, pageUrl = DEFAULT_PAIR_PAGE) {
|
|
368
|
+
const q = offerUrl.indexOf("?");
|
|
369
|
+
if (!offerUrl.startsWith(`${OFFER_URL_SCHEME}//${OFFER_URL_HOST}?`) || q < 0) {
|
|
370
|
+
throw new PairingError("malformed_offer", `expected an ${OFFER_URL_SCHEME}//${OFFER_URL_HOST}?\u2026 offer URL`);
|
|
371
|
+
}
|
|
372
|
+
const query = offerUrl.slice(q + 1);
|
|
373
|
+
const fingerprint = new URLSearchParams(query).get("id") ?? "";
|
|
374
|
+
if (pageUrl.includes(PAIR_PAGE_FP_PLACEHOLDER) && !fingerprint) {
|
|
375
|
+
throw new PairingError("malformed_offer", "offer has no daemon fingerprint (`id`) for the {fp} pair page");
|
|
376
|
+
}
|
|
377
|
+
return `${resolvePairPageUrl(pageUrl, fingerprint)}#${query}`;
|
|
378
|
+
}
|
|
379
|
+
function fromWebForm(url) {
|
|
380
|
+
if (!/^https?:\/\//i.test(url)) return url;
|
|
381
|
+
const hash = url.indexOf("#");
|
|
382
|
+
if (hash < 0 || hash === url.length - 1) {
|
|
383
|
+
throw new PairingError("malformed_offer", "web offer URL carries no offer in its fragment");
|
|
384
|
+
}
|
|
385
|
+
return `${OFFER_URL_SCHEME}//${OFFER_URL_HOST}?${url.slice(hash + 1)}`;
|
|
386
|
+
}
|
|
387
|
+
async function parseOfferUrl(url, opts = {}, crypto = webCryptoProvider) {
|
|
388
|
+
let parsed;
|
|
389
|
+
try {
|
|
390
|
+
parsed = new URL(fromWebForm(url));
|
|
391
|
+
} catch {
|
|
392
|
+
throw new PairingError("malformed_offer", "offer is not a valid URL");
|
|
393
|
+
}
|
|
394
|
+
if (parsed.protocol !== OFFER_URL_SCHEME) {
|
|
395
|
+
throw new PairingError(
|
|
396
|
+
"malformed_offer",
|
|
397
|
+
`offer scheme must be "${OFFER_URL_SCHEME}//" (got "${parsed.protocol}//")`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const host = parsed.host || parsed.pathname.replace(/^\/+/, "");
|
|
401
|
+
if (host !== OFFER_URL_HOST) {
|
|
402
|
+
throw new PairingError("malformed_offer", `offer host must be "${OFFER_URL_HOST}"`);
|
|
403
|
+
}
|
|
404
|
+
const q = parsed.searchParams;
|
|
405
|
+
const v = q.get("v");
|
|
406
|
+
if (v === "1") {
|
|
407
|
+
throw new PairingError(
|
|
408
|
+
"pairing_protocol_outdated",
|
|
409
|
+
"this offer uses the retired pair/v1 protocol, which let the rendezvous broker authenticate as a client. Upgrade agentproto on the daemon and mint a new offer: run `agentproto pair offer`"
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
if (v !== String(OFFER_VERSION)) {
|
|
413
|
+
throw new PairingError("malformed_offer", `unsupported offer version "${v ?? "(absent)"}"`);
|
|
414
|
+
}
|
|
415
|
+
const rendezvousUrl = req(q, "rv");
|
|
416
|
+
let rvParsed;
|
|
417
|
+
try {
|
|
418
|
+
rvParsed = new URL(rendezvousUrl);
|
|
419
|
+
} catch {
|
|
420
|
+
throw new PairingError("malformed_offer", "offer `rv` is not a valid URL");
|
|
421
|
+
}
|
|
422
|
+
if (rvParsed.protocol !== "ws:" && rvParsed.protocol !== "wss:") {
|
|
423
|
+
throw new PairingError("malformed_offer", "offer `rv` must be a ws:// or wss:// URL");
|
|
424
|
+
}
|
|
425
|
+
const fingerprint = req(q, "id");
|
|
426
|
+
if (!/^[0-9a-f]{32}$/.test(fingerprint)) {
|
|
427
|
+
throw new PairingError("malformed_offer", "offer `id` is not a 32-hex fingerprint");
|
|
428
|
+
}
|
|
429
|
+
const pkUrl = req(q, "pk");
|
|
430
|
+
const skUrl = req(q, "sk");
|
|
431
|
+
if (!isB64url(pkUrl) || !isB64url(skUrl)) {
|
|
432
|
+
throw new PairingError("malformed_offer", "offer `pk`/`sk` must be base64url");
|
|
433
|
+
}
|
|
434
|
+
const daemonX25519Pub = b64urlToB64(pkUrl);
|
|
435
|
+
const daemonEd25519Pub = b64urlToB64(skUrl);
|
|
436
|
+
const secret = req(q, "s");
|
|
437
|
+
if (!isB64url(secret)) {
|
|
438
|
+
throw new PairingError("malformed_offer", "offer `s` (secret) must be base64url");
|
|
439
|
+
}
|
|
440
|
+
const expRaw = req(q, "exp");
|
|
441
|
+
const exp = Number(expRaw);
|
|
442
|
+
if (!Number.isInteger(exp) || exp <= 0) {
|
|
443
|
+
throw new PairingError("malformed_offer", "offer `exp` is not a positive unix timestamp");
|
|
444
|
+
}
|
|
445
|
+
if (await identityFingerprint(daemonX25519Pub, crypto) !== fingerprint) {
|
|
446
|
+
throw new PairingError(
|
|
447
|
+
"malformed_offer",
|
|
448
|
+
"offer `id` does not match fingerprint(pk) \u2014 tampered or corrupt offer"
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (opts.now !== void 0 && exp * 1e3 <= opts.now) {
|
|
452
|
+
throw new PairingError("offer_expired", "offer has expired");
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
v: OFFER_VERSION,
|
|
456
|
+
rendezvousUrl,
|
|
457
|
+
fingerprint,
|
|
458
|
+
daemonX25519Pub,
|
|
459
|
+
daemonEd25519Pub,
|
|
460
|
+
secret,
|
|
461
|
+
exp
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function req(q, key) {
|
|
465
|
+
const v = q.get(key);
|
|
466
|
+
if (v === null || v === "") {
|
|
467
|
+
throw new PairingError("malformed_offer", `offer is missing required param "${key}"`);
|
|
468
|
+
}
|
|
469
|
+
return v;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/pairing/tunnel-handshake.ts
|
|
473
|
+
var TUNNEL_E2E_VERSION = 1;
|
|
474
|
+
var SESSION_INFO = "agentproto/tunnel-e2e/v1";
|
|
475
|
+
var AUTH_INFO = "agentproto/tunnel-e2e/auth/v1";
|
|
476
|
+
var OFFER_MAC_LABEL = "offer/v1";
|
|
477
|
+
var ACCEPT_MAC_LABEL = "accept/v1";
|
|
478
|
+
var KEY_LEN2 = 32;
|
|
479
|
+
var AUTH_KEY_LEN = 32;
|
|
480
|
+
var TunnelHandshakeError = class extends Error {
|
|
481
|
+
code;
|
|
482
|
+
constructor(code, message) {
|
|
483
|
+
super(message);
|
|
484
|
+
this.name = "TunnelHandshakeError";
|
|
485
|
+
this.code = code;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
function tokenSalt(crypto, token) {
|
|
489
|
+
return crypto.sha256(utf8Encode(token));
|
|
490
|
+
}
|
|
491
|
+
function deriveAuthKey(crypto, token, salt) {
|
|
492
|
+
return crypto.hkdfSha256(utf8Encode(token), salt, utf8Encode(AUTH_INFO), AUTH_KEY_LEN);
|
|
493
|
+
}
|
|
494
|
+
function computeMac(crypto, authKey, label, ...parts) {
|
|
495
|
+
return crypto.hmacSha256(authKey, concatBytes(utf8Encode(label), ...parts));
|
|
496
|
+
}
|
|
497
|
+
function macMatches(expected, receivedB64) {
|
|
498
|
+
return timingSafeEqualBytes(base64Decode(receivedB64), expected);
|
|
499
|
+
}
|
|
500
|
+
async function x25519PublicKey2(crypto, b64Der) {
|
|
501
|
+
const der = base64Decode(b64Der);
|
|
502
|
+
try {
|
|
503
|
+
await crypto.x25519ValidatePublicKey(der);
|
|
504
|
+
} catch {
|
|
505
|
+
throw new TunnelHandshakeError("invalid_key", "peer ephemeral X25519 public key is invalid");
|
|
506
|
+
}
|
|
507
|
+
return der;
|
|
508
|
+
}
|
|
509
|
+
function transcriptHash2(crypto, ePubDRaw, ePubHRaw) {
|
|
510
|
+
return crypto.sha256(concatBytes(utf8Encode(SESSION_INFO), ePubDRaw, ePubHRaw));
|
|
511
|
+
}
|
|
512
|
+
async function deriveDirectionKeys2(crypto, ecdh, salt) {
|
|
513
|
+
const okm = await crypto.hkdfSha256(ecdh, salt, utf8Encode(SESSION_INFO), KEY_LEN2 * 2);
|
|
514
|
+
return { kd2h: okm.subarray(0, KEY_LEN2), kh2d: okm.subarray(KEY_LEN2, KEY_LEN2 * 2) };
|
|
515
|
+
}
|
|
516
|
+
async function generateEphemeral(crypto) {
|
|
517
|
+
const kp = await crypto.x25519GenerateKeyPair();
|
|
518
|
+
return { priv: kp.privateKey, pubB64: base64Encode(kp.publicKey), pubRaw: kp.publicKey };
|
|
519
|
+
}
|
|
520
|
+
async function startTunnelHandshake(token, crypto = webCryptoProvider) {
|
|
521
|
+
const salt = await tokenSalt(crypto, token);
|
|
522
|
+
const authKey = await deriveAuthKey(crypto, token, salt);
|
|
523
|
+
const eph = await generateEphemeral(crypto);
|
|
524
|
+
const offer = {
|
|
525
|
+
v: TUNNEL_E2E_VERSION,
|
|
526
|
+
ePub: eph.pubB64,
|
|
527
|
+
mac: base64Encode(await computeMac(crypto, authKey, OFFER_MAC_LABEL, eph.pubRaw))
|
|
528
|
+
};
|
|
529
|
+
const complete = async (accept) => {
|
|
530
|
+
if (accept.v !== TUNNEL_E2E_VERSION) {
|
|
531
|
+
throw new TunnelHandshakeError(
|
|
532
|
+
"unsupported_version",
|
|
533
|
+
`unsupported accept version ${String(accept.v)}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
const hostPubRaw = await x25519PublicKey2(crypto, accept.ePub);
|
|
537
|
+
const expected = await computeMac(crypto, authKey, ACCEPT_MAC_LABEL, eph.pubRaw, hostPubRaw);
|
|
538
|
+
if (!macMatches(expected, accept.mac)) {
|
|
539
|
+
throw new TunnelHandshakeError(
|
|
540
|
+
"bad_auth",
|
|
541
|
+
"host accept did not authenticate under the shared tunnel token"
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
const ecdh = await crypto.x25519(eph.priv, hostPubRaw);
|
|
545
|
+
const { kd2h, kh2d } = await deriveDirectionKeys2(crypto, ecdh, salt);
|
|
546
|
+
return {
|
|
547
|
+
// Daemon SENDS daemon→host (kd2h) and RECEIVES host→daemon (kh2d).
|
|
548
|
+
sendKey: kd2h,
|
|
549
|
+
recvKey: kh2d,
|
|
550
|
+
transcriptHash: await transcriptHash2(crypto, eph.pubRaw, hostPubRaw)
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
return { offer, complete };
|
|
554
|
+
}
|
|
555
|
+
async function respondToTunnelHandshake(offer, token, crypto = webCryptoProvider) {
|
|
556
|
+
if (offer.v !== TUNNEL_E2E_VERSION) {
|
|
557
|
+
throw new TunnelHandshakeError(
|
|
558
|
+
"unsupported_version",
|
|
559
|
+
`unsupported offer version ${String(offer.v)}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const salt = await tokenSalt(crypto, token);
|
|
563
|
+
const authKey = await deriveAuthKey(crypto, token, salt);
|
|
564
|
+
const daemonPubRaw = await x25519PublicKey2(crypto, offer.ePub);
|
|
565
|
+
const expected = await computeMac(crypto, authKey, OFFER_MAC_LABEL, daemonPubRaw);
|
|
566
|
+
if (!macMatches(expected, offer.mac)) {
|
|
567
|
+
throw new TunnelHandshakeError(
|
|
568
|
+
"bad_auth",
|
|
569
|
+
"daemon offer did not authenticate under the shared tunnel token"
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const eph = await generateEphemeral(crypto);
|
|
573
|
+
const accept = {
|
|
574
|
+
v: TUNNEL_E2E_VERSION,
|
|
575
|
+
ePub: eph.pubB64,
|
|
576
|
+
mac: base64Encode(await computeMac(crypto, authKey, ACCEPT_MAC_LABEL, daemonPubRaw, eph.pubRaw))
|
|
577
|
+
};
|
|
578
|
+
const ecdh = await crypto.x25519(eph.priv, daemonPubRaw);
|
|
579
|
+
const { kd2h, kh2d } = await deriveDirectionKeys2(crypto, ecdh, salt);
|
|
580
|
+
return {
|
|
581
|
+
accept,
|
|
582
|
+
session: {
|
|
583
|
+
// Host SENDS host→daemon (kh2d) and RECEIVES daemon→host (kd2h).
|
|
584
|
+
sendKey: kh2d,
|
|
585
|
+
recvKey: kd2h,
|
|
586
|
+
transcriptHash: await transcriptHash2(crypto, daemonPubRaw, eph.pubRaw)
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function isRecord2(v) {
|
|
591
|
+
return typeof v === "object" && v !== null;
|
|
592
|
+
}
|
|
593
|
+
function encodeTunnelMessage(message) {
|
|
594
|
+
return utf8Encode(JSON.stringify(message));
|
|
595
|
+
}
|
|
596
|
+
function parseJson2(bytes, code) {
|
|
597
|
+
try {
|
|
598
|
+
return JSON.parse(utf8Decode(bytes));
|
|
599
|
+
} catch {
|
|
600
|
+
throw new TunnelHandshakeError(code, "message was not valid JSON (truncated or corrupt)");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function decodeTunnelOffer(bytes) {
|
|
604
|
+
const parsed = parseJson2(bytes, "malformed_offer");
|
|
605
|
+
if (!isRecord2(parsed) || parsed["v"] !== TUNNEL_E2E_VERSION || typeof parsed["ePub"] !== "string" || typeof parsed["mac"] !== "string") {
|
|
606
|
+
throw new TunnelHandshakeError("malformed_offer", "offer message is malformed");
|
|
607
|
+
}
|
|
608
|
+
return { v: TUNNEL_E2E_VERSION, ePub: parsed["ePub"], mac: parsed["mac"] };
|
|
609
|
+
}
|
|
610
|
+
function decodeTunnelAccept(bytes) {
|
|
611
|
+
const parsed = parseJson2(bytes, "malformed_accept");
|
|
612
|
+
if (!isRecord2(parsed) || parsed["v"] !== TUNNEL_E2E_VERSION || typeof parsed["ePub"] !== "string" || typeof parsed["mac"] !== "string") {
|
|
613
|
+
throw new TunnelHandshakeError("malformed_accept", "accept message is malformed");
|
|
614
|
+
}
|
|
615
|
+
return { v: TUNNEL_E2E_VERSION, ePub: parsed["ePub"], mac: parsed["mac"] };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/pairing/rendezvous.ts
|
|
619
|
+
var HOSTED_RENDEZVOUS_URL = "wss://rdv.agentproto.sh/v1";
|
|
620
|
+
|
|
621
|
+
export { DEFAULT_PAIR_PAGE, HOSTED_RENDEZVOUS_URL, LEGACY_PAIR_VERSION, OFFER_URL_HOST, OFFER_URL_SCHEME, OFFER_VERSION, PAIRING_PROTOCOL_OUTDATED_MESSAGE, PAIR_PAGE_FP_PLACEHOLDER, PAIR_VERSION, PAIR_WEB_URL, PAIR_WEB_URL_TEMPLATE_CLOUD, PairingError, TUNNEL_E2E_VERSION, TunnelHandshakeError, currentEpoch, decodePairingHello, decodePairingReply, decodeTunnelAccept, decodeTunnelOffer, deriveEpochAuthToken, deriveEpochRoutingToken, deriveEpochTokens, deriveOfferTokens, derivePairRoot, encodeOfferUrl, encodeOfferWebUrl, encodePairingMessage, encodeTunnelMessage, epochRoutingTokens, expectedPairHost, importPairRootKey, parseOfferUrl, resolvePairPageUrl, respondToHandshake, respondToLegacyHandshake, respondToTunnelHandshake, startClientHandshake, startTunnelHandshake };
|
|
622
|
+
//# sourceMappingURL=chunk-JNTDSOZQ.mjs.map
|
|
623
|
+
//# sourceMappingURL=chunk-JNTDSOZQ.mjs.map
|