@agentproto/secrets 0.1.0 → 0.2.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.
@@ -0,0 +1,455 @@
1
+ import { DaemonIdentity } from '../identity/index.js';
2
+
3
+ /**
4
+ * @agentproto/secrets/pairing — the `pair/v1` session handshake
5
+ * (design: DESIGN §4).
6
+ *
7
+ * A Noise-flavoured, two-message handshake that lets a client and a daemon —
8
+ * connected only through an untrusted rendezvous that byte-splices their
9
+ * sockets — derive per-direction AEAD keys such that the rendezvous can neither
10
+ * read nor forge the session:
11
+ *
12
+ * ```
13
+ * client → daemon: e_pub // ephemeral X25519
14
+ * ct₀ = Seal(to = daemon_x25519,
15
+ * {clientPub: e_pub, clientName, offerToken})
16
+ * daemon → client: d_e_pub, sig = Ed25519(daemon_ed25519,
17
+ * transcript = sha256(e_pub ‖ ct₀ ‖ d_e_pub))
18
+ * both: K = HKDF-SHA256(ECDH(e, d_e) ‖ ECDH(e, daemon_x25519),
19
+ * salt = transcript, info = "agentproto/pair/v1")
20
+ * → K_c2d ‖ K_d2c (two AES-256-GCM keys)
21
+ * ```
22
+ *
23
+ * Why this shape:
24
+ * - The client learned the daemon's static public keys out-of-band (the offer
25
+ * URL / QR). Verifying `sig` against the offer's Ed25519 key proves the peer
26
+ * is the daemon the human scanned, not a rendezvous impersonating it — MITM
27
+ * protection without a CA.
28
+ * - The daemon proves the client is authorised by opening `ct₀` (only the
29
+ * daemon's X25519 private key can) and checking the one-time `offerToken`.
30
+ * - `sig` covers the whole transcript and the transcript salts the key
31
+ * schedule, so any tampering with `e_pub`, `ct₀`, or `d_e_pub` in flight
32
+ * makes either the signature or the derived keys disagree — the session
33
+ * fails closed, never continuing with attacker-chosen material.
34
+ *
35
+ * This module is deliberately **transport-agnostic**: it produces and consumes
36
+ * plain messages (`encode*`/`decode*` give byte arrays). The code that pumps
37
+ * those bytes over a `FrameSink` lives in `@agentproto/acp/tunnel`, which stays
38
+ * free of any dependency on this package — it receives only the derived keys.
39
+ * All crypto stays here so the acp layer never touches key material beyond the
40
+ * two symmetric session keys.
41
+ */
42
+
43
+ /** Wire version of the handshake. Bumped if the message shape or key schedule
44
+ * changes; both sides refuse a version they don't recognise. */
45
+ declare const PAIR_VERSION: 1;
46
+ /** Stable, machine-readable failure codes. Every rejection maps to one of
47
+ * these so callers (and tests) branch on a code, not a message string. */
48
+ type PairingErrorCode = "malformed_hello" | "malformed_reply" | "invalid_key" | "unseal_failed" | "ephemeral_mismatch" | "offer_rejected" | "bad_signature" | "malformed_offer" | "offer_expired";
49
+ /** Raised for every handshake failure. Never carries key material or
50
+ * plaintext; the `code` is the contract, the message is for humans. */
51
+ declare class PairingError extends Error {
52
+ readonly code: PairingErrorCode;
53
+ constructor(code: PairingErrorCode, message: string);
54
+ }
55
+ /** Client → daemon. `ePub` is the client ephemeral X25519 public key (base64
56
+ * SPKI DER); `ct0` is the sealed hello payload (a `seal()` envelope string). */
57
+ interface PairingHello {
58
+ v: typeof PAIR_VERSION;
59
+ ePub: string;
60
+ ct0: string;
61
+ }
62
+ /** Daemon → client. `dePub` is the daemon ephemeral X25519 public key (base64
63
+ * SPKI DER); `sig` is the Ed25519 transcript signature (base64). */
64
+ interface PairingReply {
65
+ v: typeof PAIR_VERSION;
66
+ dePub: string;
67
+ sig: string;
68
+ }
69
+ /**
70
+ * The result of a completed handshake, on either side. `sendKey`/`recvKey` are
71
+ * already role-adjusted (a client's `sendKey` is a daemon's `recvKey`), so the
72
+ * consumer — `wrapE2E` — never has to know which side it is.
73
+ */
74
+ interface PairingSession {
75
+ /** AES-256-GCM key for frames THIS side sends. 32 bytes. */
76
+ sendKey: Uint8Array;
77
+ /** AES-256-GCM key for frames THIS side receives. 32 bytes. */
78
+ recvKey: Uint8Array;
79
+ /** Fingerprint of the peer's static identity (the daemon's X25519 key on the
80
+ * client side; in P1, the client's ephemeral key on the daemon side, since
81
+ * clients have no persisted identity until P2's pairings store). */
82
+ peerFingerprint: string;
83
+ /** `sha256(e_pub ‖ ct₀ ‖ d_e_pub)` — the exact transcript both sides bound
84
+ * to. A later phase channel-binds reconnect tokens to this. */
85
+ transcriptHash: Uint8Array;
86
+ /**
87
+ * The human-facing client label carried in the sealed hello. On the daemon
88
+ * side this is the name the peer chose (surfaced in `pairings.json` and on
89
+ * `pair accept`); on the client side it echoes the name this side supplied.
90
+ * Optional so pre-P2 callers constructing a session literal are unaffected —
91
+ * P2 (pairing-registry) reads it to name a persisted pairing without opening
92
+ * the seal a second time. Populated by both handshake entry points below.
93
+ */
94
+ clientName?: string;
95
+ }
96
+ /** Everything the client learned from the offer URL, plus its chosen name. */
97
+ interface ClientHandshakeParams {
98
+ /** Daemon static X25519 public key (base64 SPKI DER) — the seal recipient
99
+ * and one ECDH input. From the offer's `pk`. */
100
+ daemonX25519Pub: string;
101
+ /** Daemon static Ed25519 public key (base64 SPKI DER) — verifies `sig`.
102
+ * From the offer's `sk`. */
103
+ daemonEd25519Pub: string;
104
+ /** One-time offer token. From the offer's `t`. */
105
+ offerToken: string;
106
+ /** Human-facing client label the daemon displays on accept. */
107
+ clientName: string;
108
+ }
109
+ /** A started client handshake: send `hello`, then feed the daemon's reply to
110
+ * `complete` to derive the session. */
111
+ interface StartedClientHandshake {
112
+ hello: PairingHello;
113
+ /** Verify the daemon reply and derive the session. Throws `PairingError` on
114
+ * a bad signature, malformed reply, or invalid key — never returns partial
115
+ * state. */
116
+ complete(reply: PairingReply): PairingSession;
117
+ }
118
+ /**
119
+ * Begin a client handshake. Generates the client ephemeral keypair, seals the
120
+ * hello payload to the daemon's static key, and returns the `hello` to send
121
+ * plus a `complete` to run once the daemon replies.
122
+ */
123
+ declare function startClientHandshake(params: ClientHandshakeParams): StartedClientHandshake;
124
+ /** What the daemon brings to the handshake: its identity, and a predicate that
125
+ * validates (and, in P2, spends) the one-time offer token. */
126
+ interface DaemonHandshakeParams {
127
+ identity: DaemonIdentity;
128
+ /**
129
+ * Validate the presented offer token. Returning false rejects the handshake
130
+ * with `offer_rejected`. The daemon owns single-use + expiry policy here so
131
+ * this module never needs to know about the offer store — a stale or already
132
+ * spent token simply returns false.
133
+ */
134
+ verifyOfferToken: (token: string) => boolean;
135
+ }
136
+ /** A completed daemon handshake: send `reply`, keep `session`. */
137
+ interface DaemonHandshakeResult {
138
+ reply: PairingReply;
139
+ session: PairingSession;
140
+ }
141
+ /**
142
+ * Respond to a client hello. Opens the sealed payload with the daemon's X25519
143
+ * private key, checks the offer token and the ephemeral-key binding, signs the
144
+ * transcript, and derives the session. Throws `PairingError` on any failure —
145
+ * a tampered `ct₀`, a swapped `ePub`, a rejected token — before producing any
146
+ * reply, so a rejected client learns nothing and gets no session.
147
+ */
148
+ declare function respondToHandshake(hello: PairingHello, params: DaemonHandshakeParams): DaemonHandshakeResult;
149
+ /** Serialize a handshake message to bytes for transport. */
150
+ declare function encodePairingMessage(message: PairingHello | PairingReply): Uint8Array;
151
+ /** Parse + validate a client hello from raw bytes. Truncated or malformed
152
+ * input throws `PairingError("malformed_hello")` — never a partial object. */
153
+ declare function decodePairingHello(bytes: Uint8Array): PairingHello;
154
+ /** Parse + validate a daemon reply from raw bytes. Truncated or malformed
155
+ * input throws `PairingError("malformed_reply")`. */
156
+ declare function decodePairingReply(bytes: Uint8Array): PairingReply;
157
+
158
+ /**
159
+ * The pairing offer URL codec (design: DESIGN §2).
160
+ *
161
+ * `agentproto pair offer` prints a single URL (also renderable as a QR):
162
+ *
163
+ * ```
164
+ * agentproto://pair?v=1
165
+ * &rv=<rendezvous ws/wss url> // where both sides meet
166
+ * &id=<fingerprint> // daemon identity fingerprint (16 hex)
167
+ * &pk=<b64url x25519 SPKI DER> // daemon static encryption key
168
+ * &sk=<b64url ed25519 SPKI DER> // daemon signing key
169
+ * &t=<one-time offer token> // rendezvous routing + first-contact proof
170
+ * &exp=<unix seconds> // offer expiry
171
+ * ```
172
+ *
173
+ * The URL **is** the bootstrap secret. It carries the daemon's public keys, so
174
+ * a client that scans it can pin the daemon and detect a man-in-the-middle
175
+ * rendezvous (verifying the handshake signature against `sk`); and it carries a
176
+ * one-time, short-TTL token so a stranger who never saw the URL can't pair.
177
+ *
178
+ * This module is a **pure codec** — it validates structure and echoes bytes; it
179
+ * performs no I/O and no network calls, so it is safe to run on either side
180
+ * (the daemon builds it, the client parses it). It lives in `@agentproto/secrets`
181
+ * beside the handshake so both sides share one authority on the format.
182
+ *
183
+ * Key material travels **base64url** in the URL (no `+`/`/`/`=` to percent-
184
+ * escape). The handshake, however, speaks standard base64 SPKI DER, so
185
+ * `parseOfferUrl` returns `daemonX25519Pub`/`daemonEd25519Pub` already converted
186
+ * back to standard base64 — feed them straight into `startClientHandshake`.
187
+ */
188
+ /** URL scheme + host for offer URLs. */
189
+ declare const OFFER_URL_SCHEME: "agentproto:";
190
+ declare const OFFER_URL_HOST: "pair";
191
+ /** Offer-format version. Bumped if the param set changes. */
192
+ declare const OFFER_VERSION: 1;
193
+ /**
194
+ * A parsed, structurally-valid pairing offer. `daemonX25519Pub` /
195
+ * `daemonEd25519Pub` are standard-base64 SPKI DER (handshake-ready). `token` is
196
+ * the opaque one-time offer token verbatim (the daemon checks it against its
197
+ * offer store). `exp` is unix **seconds**.
198
+ */
199
+ interface PairingOffer {
200
+ v: typeof OFFER_VERSION;
201
+ /** Rendezvous endpoint both sides dial (ws:// or wss://). */
202
+ rendezvousUrl: string;
203
+ /** Daemon identity fingerprint (16 hex) — must equal fingerprint(pk). */
204
+ fingerprint: string;
205
+ /** Daemon static X25519 public key, standard base64 SPKI DER. */
206
+ daemonX25519Pub: string;
207
+ /** Daemon static Ed25519 public key, standard base64 SPKI DER. */
208
+ daemonEd25519Pub: string;
209
+ /** One-time offer token (routing + first-contact proof). */
210
+ token: string;
211
+ /** Offer expiry, unix seconds. */
212
+ exp: number;
213
+ }
214
+ /**
215
+ * Build the offer URL from an offer. The public keys come in as standard
216
+ * base64 (the shape the identity file + handshake use) and are emitted as
217
+ * base64url. `token` is emitted verbatim (callers mint it as base64url).
218
+ */
219
+ declare function encodeOfferUrl(offer: PairingOffer): string;
220
+ interface ParseOfferOptions {
221
+ /**
222
+ * When set, the parser rejects an offer whose `exp` is at or before this
223
+ * instant (unix **milliseconds**) with `PairingError("offer_expired")`. Omit
224
+ * to parse structure only and let the caller decide when to check expiry
225
+ * (the daemon's offer store is the authoritative single-use + expiry gate).
226
+ */
227
+ now?: number;
228
+ }
229
+ /**
230
+ * Parse + strictly validate an offer URL. Throws `PairingError` — never returns
231
+ * a partial object — on any structural problem:
232
+ *
233
+ * - `malformed_offer`: wrong scheme/host, unknown version, missing/blank
234
+ * params, non-base64url keys/token, non-integer `exp`, or a `fingerprint`
235
+ * that does not match `fingerprint(pk)` (tamper detection: a rendezvous or
236
+ * link-mangler that swaps the daemon key can't keep `id` consistent).
237
+ * - `offer_expired`: only when `opts.now` is supplied and `exp` has passed.
238
+ */
239
+ declare function parseOfferUrl(url: string, opts?: ParseOfferOptions): PairingOffer;
240
+
241
+ /**
242
+ * The hosted rendezvous broker — the meeting point `pair offer` defaults to
243
+ * when neither `--rendezvous` nor `pairing.rendezvous` is configured.
244
+ *
245
+ * ## Why it lives here
246
+ *
247
+ * It sits in `@agentproto/secrets/pairing`, beside `offer-url.ts` — the codec
248
+ * that bakes this endpoint into every offer's `rv=` param. The daemon runtime
249
+ * already depends on `@agentproto/secrets`, so it reaches this constant without
250
+ * `@agentproto/runtime` gaining a dependency on `@agentproto/rendezvous`. That
251
+ * matters: the broker package is a deliberately lean, standalone container (its
252
+ * only dep is `ws`), and nothing on the daemon/CLI side should have to pull it
253
+ * in just to learn the default endpoint's URL.
254
+ *
255
+ * ## What the broker sees
256
+ *
257
+ * The hosted broker only ever relays **ciphertext** — it learns the routing
258
+ * token, the peers' IPs, ciphertext sizes, and timing; never plaintext, and it
259
+ * cannot inject or alter frames (the pairing handshake is transcript-bound and
260
+ * every frame is AEAD-sealed). See `docs/cli/concepts/pairing.md` for the full
261
+ * threat model.
262
+ *
263
+ * ## Pointing elsewhere / self-hosting
264
+ *
265
+ * The broker is self-hostable (`agentproto rendezvous serve`). To route through
266
+ * your own instead of the hosted default, set `pairing.rendezvous` in
267
+ * `config.json` (or pass `--rendezvous` for a single offer). To disable the
268
+ * default entirely — so a daemon never reaches the hosted broker unless an
269
+ * endpoint is named explicitly — set `pairing.rendezvous: ""` (an explicit
270
+ * opt-out; `pair offer` then requires `--rendezvous`).
271
+ */
272
+ declare const HOSTED_RENDEZVOUS_URL: "wss://rdv.agentproto.sh/v1";
273
+
274
+ /**
275
+ * Pairing-derived key material (design: DESIGN §3/§4).
276
+ *
277
+ * Two derivations, both HKDF-SHA256 over secret session material so the
278
+ * untrusted rendezvous — which sees the handshake transcript in the clear —
279
+ * can never reproduce them:
280
+ *
281
+ * - **pair root** `K_pair = HKDF(session, "pair-root")`: the long-term shared
282
+ * secret persisted by both sides (daemon `pairings.json`, client
283
+ * `credentials.json`). Everything a reconnect needs derives from it.
284
+ * - **epoch routing token** `t' = HKDF(K_pair, "rv-route" ‖ epoch)`: the
285
+ * rendezvous routing token for a reconnect, rotated per UTC day so the
286
+ * broker can't link a pairing's sessions across days. Both sides derive the
287
+ * same token for the same epoch without any further communication.
288
+ *
289
+ * ## Why the pair root is derived order-independently
290
+ *
291
+ * A `PairingSession` exposes `sendKey`/`recvKey`, which are **role-swapped**
292
+ * between the two peers (the client's `sendKey` is the daemon's `recvKey`). To
293
+ * get an identical root on both sides without threading a "which side am I"
294
+ * flag, we sort the two keys byte-wise before mixing them: the *set* {sendKey,
295
+ * recvKey} is identical on both sides, so the sorted concatenation — and thus
296
+ * the HKDF output — is identical. Both keys are secret ECDH-derived material the
297
+ * rendezvous never sees, so the root (and every epoch token) stays secret.
298
+ *
299
+ * This uses the shipped P1 `PairingSession` verbatim (no change to the key
300
+ * schedule) — it only reads the two direction keys and the transcript hash.
301
+ */
302
+
303
+ /**
304
+ * Derive the long-term pair root from a completed handshake session. Returns
305
+ * standard base64 (persisted in `pairings.json` / `credentials.json`). Both
306
+ * peers, despite role-swapped direction keys, produce the identical root.
307
+ */
308
+ declare function derivePairRoot(session: PairingSession): string;
309
+ /** The current pairing epoch — the UTC day number. Injectable `now` (ms) for
310
+ * tests; defaults to the wall clock. */
311
+ declare function currentEpoch(now?: number): number;
312
+ /**
313
+ * Derive the rendezvous routing token for a pairing at a given epoch:
314
+ * `t' = HKDF(pairRoot, "rv-route" ‖ epoch)`. Returned as base64url so it drops
315
+ * straight into a `?t=` upgrade param. Deterministic — both sides derive the
316
+ * same token for the same `(pairRoot, epoch)`.
317
+ */
318
+ declare function deriveEpochRoutingToken(pairRoot: string, epoch: number): string;
319
+ /**
320
+ * The set of epoch routing tokens a peer should accept/dial to bridge clock
321
+ * skew around a day boundary: the current epoch and the previous one (design:
322
+ * PLAN "accept current and previous epoch"). The daemon parks on both so a
323
+ * client whose clock sits on either side of midnight still finds it; the client
324
+ * likewise tries both when reconnecting.
325
+ */
326
+ declare function epochRoutingTokens(pairRoot: string, now?: number): {
327
+ epoch: number;
328
+ token: string;
329
+ }[];
330
+
331
+ /**
332
+ * @agentproto/secrets/pairing — the `tunnel-e2e/v1` handshake.
333
+ *
334
+ * The reverse tunnel (`agentproto serve --connect <host>`) is a DIFFERENT trust
335
+ * relationship from the client↔daemon pairing in ./handshake.ts:
336
+ *
337
+ * - There is no offer URL, no QR, no PKI. The daemon and the host already
338
+ * share one pre-provisioned secret — the `apt_` **tunnel token** the daemon
339
+ * presents as its WS bearer. Both ends hold it before the socket opens.
340
+ * - So instead of authenticating with a static-key seal + Ed25519 signature,
341
+ * both ends authenticate by proving knowledge of that shared token, and get
342
+ * forward secrecy from a fresh ephemeral X25519 exchange.
343
+ *
344
+ * ```
345
+ * daemon → host: offer = { ePub_d, mac_d = HMAC(K_auth, "offer/v1" ‖ ePub_d) }
346
+ * host → daemon: accept = { ePub_h, mac_h = HMAC(K_auth, "accept/v1" ‖ ePub_d ‖ ePub_h) }
347
+ * both: K_auth = HKDF(ikm = utf8(token),
348
+ * salt = sha256(token), info = "…/auth/v1") // token-only MAC key
349
+ * K = HKDF(ikm = ECDH(e_d, e_h),
350
+ * salt = sha256(token), info = "…/v1") // → K_d2h ‖ K_h2d
351
+ * ```
352
+ *
353
+ * Why this shape:
354
+ * - **Mutual authentication at handshake time.** `mac_d` binds the daemon
355
+ * ephemeral to the token; the host verifies it and refuses (`bad_auth`) if
356
+ * the token differs — no partial session, no plaintext, before the daemon's
357
+ * first byte. `mac_h` binds BOTH ephemerals to the token; the daemon
358
+ * verifies it symmetrically. A man-in-the-middle without the token cannot
359
+ * forge either MAC for its own ephemeral, and cannot reuse a recorded one
360
+ * (it lacks the matching private key to finish the ECDH). So the confirm is
361
+ * a **transcript/confirm step that fails a token mismatch AT handshake time,
362
+ * not mid-stream** — exactly what a wrong `tunnel.token` on one side needs.
363
+ * - **Forward secrecy.** The session keys mix ONLY the ephemeral ECDH output;
364
+ * the long-term token merely salts them. A later token compromise can't
365
+ * decrypt a recorded past session.
366
+ * - **Salt-binds the token.** Because `sha256(token)` salts the session-key
367
+ * HKDF too, even if the MACs were somehow bypassed the derived AEAD keys
368
+ * still disagree under a mismatched token → the channel fails closed.
369
+ *
370
+ * Like ./handshake.ts, this module is deliberately **transport-agnostic**: it
371
+ * produces and consumes plain byte messages (`encode*`/`decode*`). The code that
372
+ * pumps those bytes over a `FrameSink` and wraps the channel lives in
373
+ * `@agentproto/acp/tunnel`, which never depends on this package — it receives
374
+ * only the two derived symmetric keys. Everything crypto stays here.
375
+ */
376
+ /** Wire version. Bumped if the message shape or key schedule changes; both
377
+ * sides refuse a version they don't recognise. */
378
+ declare const TUNNEL_E2E_VERSION: 1;
379
+ /** Stable, machine-readable failure codes. Every rejection maps to one of these
380
+ * so callers (and tests) branch on a code, not a message string. */
381
+ type TunnelHandshakeErrorCode = "malformed_offer" | "malformed_accept" | "invalid_key" | "bad_auth" | "unsupported_version";
382
+ /** Raised for every tunnel-handshake failure. Never carries key material or the
383
+ * token; the `code` is the contract, the message is for humans. */
384
+ declare class TunnelHandshakeError extends Error {
385
+ readonly code: TunnelHandshakeErrorCode;
386
+ constructor(code: TunnelHandshakeErrorCode, message: string);
387
+ }
388
+ /** Daemon → host. `ePub` is the daemon ephemeral X25519 public key (base64 SPKI
389
+ * DER); `mac` authenticates it under the shared tunnel token. */
390
+ interface TunnelOffer {
391
+ v: typeof TUNNEL_E2E_VERSION;
392
+ ePub: string;
393
+ mac: string;
394
+ }
395
+ /** Host → daemon. `ePub` is the host ephemeral X25519 public key (base64 SPKI
396
+ * DER); `mac` authenticates both ephemerals under the shared tunnel token. */
397
+ interface TunnelAccept {
398
+ v: typeof TUNNEL_E2E_VERSION;
399
+ ePub: string;
400
+ mac: string;
401
+ }
402
+ /**
403
+ * A completed tunnel handshake, on either side. `sendKey`/`recvKey` are already
404
+ * role-adjusted (the daemon's `sendKey` is the host's `recvKey`), so the
405
+ * consumer — `wrapE2E` — never has to know which side it is.
406
+ */
407
+ interface TunnelE2ESession {
408
+ /** AES-256-GCM key for frames THIS side sends. 32 bytes. */
409
+ sendKey: Uint8Array;
410
+ /** AES-256-GCM key for frames THIS side receives. 32 bytes. */
411
+ recvKey: Uint8Array;
412
+ /** `sha256(SESSION_INFO ‖ ePub_d ‖ ePub_h)` — the exact transcript both sides
413
+ * bound to. Exposed for parity with `PairingSession`; not required to use. */
414
+ transcriptHash: Uint8Array;
415
+ }
416
+ /** A started daemon handshake: send `offer`, then feed the host's `accept` to
417
+ * `complete` to derive the session. */
418
+ interface StartedTunnelHandshake {
419
+ offer: TunnelOffer;
420
+ /**
421
+ * Verify the host accept and derive the session. Throws `TunnelHandshakeError`
422
+ * on a bad MAC (`bad_auth` — the host holds a different token), a malformed
423
+ * accept, or an invalid key — never returns partial state.
424
+ */
425
+ complete(accept: TunnelAccept): TunnelE2ESession;
426
+ }
427
+ /**
428
+ * Begin the daemon (initiator) side. Generates the daemon ephemeral keypair,
429
+ * MACs it under the token, and returns the `offer` to send plus a `complete` to
430
+ * run once the host replies with its `accept`.
431
+ */
432
+ declare function startTunnelHandshake(token: string): StartedTunnelHandshake;
433
+ /** A completed host handshake: send `accept`, keep `session`. */
434
+ interface TunnelHandshakeResult {
435
+ accept: TunnelAccept;
436
+ session: TunnelE2ESession;
437
+ }
438
+ /**
439
+ * Respond to a daemon offer (host side). Verifies the offer MAC under the shared
440
+ * token, generates the host ephemeral, MACs both ephemerals, and derives the
441
+ * session. Throws `TunnelHandshakeError` on a bad MAC (`bad_auth`), malformed
442
+ * offer, or invalid key BEFORE producing any accept — so a mismatched token
443
+ * yields no session and no reply, failing closed at handshake time.
444
+ */
445
+ declare function respondToTunnelHandshake(offer: TunnelOffer, token: string): TunnelHandshakeResult;
446
+ /** Serialize a handshake message to bytes for transport. */
447
+ declare function encodeTunnelMessage(message: TunnelOffer | TunnelAccept): Uint8Array;
448
+ /** Parse + validate a daemon offer from raw bytes. Truncated or malformed input
449
+ * throws `TunnelHandshakeError("malformed_offer")` — never a partial object. */
450
+ declare function decodeTunnelOffer(bytes: Uint8Array): TunnelOffer;
451
+ /** Parse + validate a host accept from raw bytes. Truncated or malformed input
452
+ * throws `TunnelHandshakeError("malformed_accept")`. */
453
+ declare function decodeTunnelAccept(bytes: Uint8Array): TunnelAccept;
454
+
455
+ export { type ClientHandshakeParams, type DaemonHandshakeParams, type DaemonHandshakeResult, HOSTED_RENDEZVOUS_URL, OFFER_URL_HOST, OFFER_URL_SCHEME, OFFER_VERSION, PAIR_VERSION, PairingError, type PairingErrorCode, type PairingHello, type PairingOffer, type PairingReply, type PairingSession, type ParseOfferOptions, type StartedClientHandshake, type StartedTunnelHandshake, TUNNEL_E2E_VERSION, type TunnelAccept, type TunnelE2ESession, TunnelHandshakeError, type TunnelHandshakeErrorCode, type TunnelHandshakeResult, type TunnelOffer, currentEpoch, decodePairingHello, decodePairingReply, decodeTunnelAccept, decodeTunnelOffer, deriveEpochRoutingToken, derivePairRoot, encodeOfferUrl, encodePairingMessage, encodeTunnelMessage, epochRoutingTokens, parseOfferUrl, respondToHandshake, respondToTunnelHandshake, startClientHandshake, startTunnelHandshake };