@agentproto/secrets 0.2.6 → 1.1.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.
Files changed (40) hide show
  1. package/dist/{chunk-Q6K3AT3V.mjs → chunk-2FHFW2AU.mjs} +3 -3
  2. package/dist/{chunk-Q6K3AT3V.mjs.map → chunk-2FHFW2AU.mjs.map} +1 -1
  3. package/dist/chunk-3YRYO6LK.mjs +222 -0
  4. package/dist/chunk-3YRYO6LK.mjs.map +1 -0
  5. package/dist/chunk-432SC3XS.mjs +100 -0
  6. package/dist/chunk-432SC3XS.mjs.map +1 -0
  7. package/dist/chunk-5HUHYBPR.mjs +69 -0
  8. package/dist/chunk-5HUHYBPR.mjs.map +1 -0
  9. package/dist/{chunk-NLZ5HXGO.mjs → chunk-7V6U6DII.mjs} +4 -4
  10. package/dist/{chunk-NLZ5HXGO.mjs.map → chunk-7V6U6DII.mjs.map} +1 -1
  11. package/dist/chunk-L4LKX6JY.mjs +626 -0
  12. package/dist/chunk-L4LKX6JY.mjs.map +1 -0
  13. package/dist/chunk-NQX3KBPW.mjs +50 -0
  14. package/dist/chunk-NQX3KBPW.mjs.map +1 -0
  15. package/dist/chunk-PCFPJ47T.mjs +28 -0
  16. package/dist/chunk-PCFPJ47T.mjs.map +1 -0
  17. package/dist/cli.mjs +10 -7
  18. package/dist/cli.mjs.map +1 -1
  19. package/dist/core-CIn0-z-Y.d.ts +42 -0
  20. package/dist/core-D8zVNPDb.d.ts +58 -0
  21. package/dist/identity/index.d.ts +25 -46
  22. package/dist/identity/index.mjs +76 -1
  23. package/dist/identity/index.mjs.map +1 -1
  24. package/dist/pairing/browser.d.ts +18 -0
  25. package/dist/pairing/browser.mjs +6 -0
  26. package/dist/pairing/browser.mjs.map +1 -0
  27. package/dist/pairing/index.d.ts +45 -440
  28. package/dist/pairing/index.mjs +33 -465
  29. package/dist/pairing/index.mjs.map +1 -1
  30. package/dist/provision/index.mjs +5 -2
  31. package/dist/provision/recipe/index.mjs +6 -3
  32. package/dist/seal/index.d.ts +21 -31
  33. package/dist/seal/index.mjs +4 -1
  34. package/dist/types-B2ZfEhsU.d.ts +57 -0
  35. package/dist/webcrypto-DEOV2iYE.d.ts +678 -0
  36. package/package.json +8 -2
  37. package/dist/chunk-2DL6W33G.mjs +0 -126
  38. package/dist/chunk-2DL6W33G.mjs.map +0 -1
  39. package/dist/chunk-MVHOJPML.mjs +0 -132
  40. package/dist/chunk-MVHOJPML.mjs.map +0 -1
@@ -0,0 +1,678 @@
1
+ import { C as CryptoProvider } from './types-B2ZfEhsU.js';
2
+ import { D as DaemonIdentity } from './core-D8zVNPDb.js';
3
+
4
+ /**
5
+ * @agentproto/secrets/pairing — the `pair/v2` session handshake
6
+ * (design: DESIGN §4).
7
+ *
8
+ * A Noise-flavoured, two-message handshake that lets a client and a daemon —
9
+ * connected only through an untrusted rendezvous that byte-splices their
10
+ * sockets — derive per-direction AEAD keys such that the rendezvous can neither
11
+ * read nor forge the session:
12
+ *
13
+ * ```
14
+ * client → daemon: e_pub // ephemeral X25519
15
+ * ct₀ = Seal(to = daemon_x25519,
16
+ * {clientPub: e_pub, clientName, auth})
17
+ * daemon → client: d_e_pub, sig = Ed25519(daemon_ed25519,
18
+ * transcript = sha256(e_pub ‖ ct₀ ‖ d_e_pub))
19
+ * both: K = HKDF-SHA256(ECDH(e, d_e) ‖ ECDH(e, daemon_x25519),
20
+ * salt = transcript, info = "agentproto/pair/v2")
21
+ * → K_c2d ‖ K_d2c (two AES-256-GCM keys)
22
+ * ```
23
+ *
24
+ * Why this shape:
25
+ * - The client learned the daemon's static public keys out-of-band (the offer
26
+ * URL / QR). Verifying `sig` against the offer's Ed25519 key proves the peer
27
+ * is the daemon the human scanned, not a rendezvous impersonating it — MITM
28
+ * protection without a CA.
29
+ * - The daemon proves the client is authorised by opening `ct₀` (only the
30
+ * daemon's X25519 private key can) and checking the sealed `auth` token in
31
+ * constant time. `auth` is derived from the pairing secret under a label
32
+ * distinct from the broker ROUTE token (./derive.ts), so the one value the
33
+ * broker sees in clear — the route on the upgrade URL — never authenticates.
34
+ * pair/v1 sealed the route itself as the proof, which let a broker that
35
+ * knew the route (all of them) seal its own hello and pair; v1 is retired
36
+ * (see `respondToLegacyHandshake`).
37
+ * - `sig` covers the whole transcript and the transcript salts the key
38
+ * schedule, so any tampering with `e_pub`, `ct₀`, or `d_e_pub` in flight
39
+ * makes either the signature or the derived keys disagree — the session
40
+ * fails closed, never continuing with attacker-chosen material.
41
+ *
42
+ * The primitives come from a `CryptoProvider` (node:crypto or WebCrypto), so this
43
+ * one implementation runs in Node and in a browser; every entry point that does
44
+ * crypto is async and takes an optional trailing `crypto` argument.
45
+ *
46
+ * This module is deliberately **transport-agnostic**: it produces and consumes
47
+ * plain messages (`encode*`/`decode*` give byte arrays). The code that pumps
48
+ * those bytes over a `FrameSink` lives in `@agentproto/acp/tunnel`, which stays
49
+ * free of any dependency on this package — it receives only the derived keys.
50
+ * All crypto stays here so the acp layer never touches key material beyond the
51
+ * two symmetric session keys.
52
+ */
53
+
54
+ /** Wire version of the handshake. Bumped if the message shape or key schedule
55
+ * changes; both sides refuse a version they don't recognise. v2: the sealed
56
+ * hello carries a dedicated auth token, never the broker route (./derive.ts). */
57
+ declare const PAIR_VERSION: 2;
58
+ /** The retired pair/v1 wire version. Recognised only to answer it with a
59
+ * re-pair notice (`respondToLegacyHandshake`); never served. */
60
+ declare const LEGACY_PAIR_VERSION: 1;
61
+ /** What a peer is told when it speaks the retired protocol: pairings made
62
+ * under pair/v1 can't be upgraded in place, so the fix is a fresh pairing. */
63
+ declare const PAIRING_PROTOCOL_OUTDATED_MESSAGE: string;
64
+ /** Stable, machine-readable failure codes. Every rejection maps to one of
65
+ * these so callers (and tests) branch on a code, not a message string. */
66
+ type PairingErrorCode = "malformed_hello" | "malformed_reply" | "invalid_key" | "unseal_failed" | "ephemeral_mismatch" | "offer_rejected" | "bad_signature" | "malformed_offer" | "offer_expired" | "pairing_protocol_outdated";
67
+ /** Raised for every handshake failure. Never carries key material or
68
+ * plaintext; the `code` is the contract, the message is for humans. */
69
+ declare class PairingError extends Error {
70
+ readonly code: PairingErrorCode;
71
+ constructor(code: PairingErrorCode, message: string);
72
+ }
73
+ /** Client → daemon. `ePub` is the client ephemeral X25519 public key (base64
74
+ * SPKI DER); `ct0` is the sealed hello payload (a `seal()` envelope string). */
75
+ interface PairingHello {
76
+ v: typeof PAIR_VERSION;
77
+ ePub: string;
78
+ ct0: string;
79
+ }
80
+ /** Daemon → client. `dePub` is the daemon ephemeral X25519 public key (base64
81
+ * SPKI DER); `sig` is the Ed25519 transcript signature (base64). */
82
+ interface PairingReply {
83
+ v: typeof PAIR_VERSION;
84
+ dePub: string;
85
+ sig: string;
86
+ }
87
+ /**
88
+ * The result of a completed handshake, on either side. `sendKey`/`recvKey` are
89
+ * already role-adjusted (a client's `sendKey` is a daemon's `recvKey`), so the
90
+ * consumer — `wrapE2E` — never has to know which side it is.
91
+ */
92
+ interface PairingSession {
93
+ /** AES-256-GCM key for frames THIS side sends. 32 bytes. */
94
+ sendKey: Uint8Array;
95
+ /** AES-256-GCM key for frames THIS side receives. 32 bytes. */
96
+ recvKey: Uint8Array;
97
+ /** Fingerprint of the peer's static identity (the daemon's X25519 key on the
98
+ * client side; in P1, the client's ephemeral key on the daemon side, since
99
+ * clients have no persisted identity until P2's pairings store). */
100
+ peerFingerprint: string;
101
+ /** `sha256(e_pub ‖ ct₀ ‖ d_e_pub)` — the exact transcript both sides bound
102
+ * to. A later phase channel-binds reconnect tokens to this. */
103
+ transcriptHash: Uint8Array;
104
+ /**
105
+ * The human-facing client label carried in the sealed hello. On the daemon
106
+ * side this is the name the peer chose (surfaced in `pairings.json` and on
107
+ * `pair accept`); on the client side it echoes the name this side supplied.
108
+ * Optional so pre-P2 callers constructing a session literal are unaffected —
109
+ * P2 (pairing-registry) reads it to name a persisted pairing without opening
110
+ * the seal a second time. Populated by both handshake entry points below.
111
+ */
112
+ clientName?: string;
113
+ }
114
+ /** Everything the client learned from the offer URL, plus its chosen name. */
115
+ interface ClientHandshakeParams {
116
+ /** Daemon static X25519 public key (base64 SPKI DER) — the seal recipient
117
+ * and one ECDH input. From the offer's `pk`. */
118
+ daemonX25519Pub: string;
119
+ /** Daemon static Ed25519 public key (base64 SPKI DER) — verifies `sig`.
120
+ * From the offer's `sk`. */
121
+ daemonEd25519Pub: string;
122
+ /** Auth token sealed into the hello: `deriveOfferTokens(offer.secret).auth`
123
+ * on first contact, `deriveEpochAuthToken(pairRoot, epoch)` on reconnect.
124
+ * NEVER the route token the broker sees. */
125
+ authToken: string;
126
+ /** Human-facing client label the daemon displays on accept. */
127
+ clientName: string;
128
+ }
129
+ /** A started client handshake: send `hello`, then feed the daemon's reply to
130
+ * `complete` to derive the session. */
131
+ interface StartedClientHandshake {
132
+ hello: PairingHello;
133
+ /** Verify the daemon reply and derive the session. Rejects with
134
+ * `PairingError` on a bad signature, malformed reply, or invalid key —
135
+ * never resolves partial state. */
136
+ complete(reply: PairingReply): Promise<PairingSession>;
137
+ }
138
+ /**
139
+ * Begin a client handshake. Generates the client ephemeral keypair, seals the
140
+ * hello payload to the daemon's static key, and resolves to the `hello` to send
141
+ * plus a `complete` to run once the daemon replies.
142
+ *
143
+ * `crypto` selects the primitive implementation (default: WebCrypto here,
144
+ * `node:crypto` through the `@agentproto/secrets/pairing` Node entry). The
145
+ * provider is captured for `complete` as well.
146
+ */
147
+ declare function startClientHandshake(params: ClientHandshakeParams, crypto?: CryptoProvider): Promise<StartedClientHandshake>;
148
+ /** What the daemon brings to the handshake: its identity, and a predicate that
149
+ * validates (and, for an offer, spends) the sealed auth token. */
150
+ interface DaemonHandshakeParams {
151
+ identity: DaemonIdentity;
152
+ /**
153
+ * Validate the presented auth token (constant time). Returning false
154
+ * rejects the handshake with `offer_rejected`. The daemon owns single-use +
155
+ * expiry policy here so this module never needs to know about the offer
156
+ * store — a stale, spent, or wrong token simply returns false. May be async.
157
+ */
158
+ verifyAuthToken: (token: string) => boolean | Promise<boolean>;
159
+ }
160
+ /** A completed daemon handshake: send `reply`, keep `session`. */
161
+ interface DaemonHandshakeResult {
162
+ reply: PairingReply;
163
+ session: PairingSession;
164
+ }
165
+ /**
166
+ * Respond to a client hello. Opens the sealed payload with the daemon's X25519
167
+ * private key, checks the ephemeral-key binding and the auth token, signs the
168
+ * transcript, and derives the session. Rejects with `PairingError` on any
169
+ * failure — a tampered `ct₀`, a swapped `ePub`, a rejected token — before
170
+ * producing any reply, so a rejected client learns nothing and gets no session.
171
+ */
172
+ declare function respondToHandshake(hello: PairingHello, params: DaemonHandshakeParams, crypto?: CryptoProvider): Promise<DaemonHandshakeResult>;
173
+ /**
174
+ * Answer a retired pair/v1 hello so its client can be TOLD to re-pair.
175
+ *
176
+ * An unmodified v1 client only surfaces what arrives inside a completed v1
177
+ * channel — anything short of that (a closed socket, a WS close reason, which
178
+ * its transport drops) reads as a timeout. So the daemon completes the v1 key
179
+ * schedule (`agentproto/pair/v1`) and hands back the reply + keys; the caller
180
+ * sends ONLY the outdated notice over them and closes.
181
+ *
182
+ * Deliberately performs NO authorisation: the v1 token proves nothing (it's
183
+ * the broker-visible route), so it isn't even read. The result must never be
184
+ * served — it is a one-way notice channel, and a party that gets one (the
185
+ * broker included) gains nothing but that notice. Rejects with `PairingError`
186
+ * for anything that isn't a well-formed v1 hello sealed to this daemon.
187
+ */
188
+ declare function respondToLegacyHandshake(helloBytes: Uint8Array, identity: DaemonIdentity, crypto?: CryptoProvider): Promise<{
189
+ reply: Uint8Array;
190
+ keys: {
191
+ sendKey: Uint8Array;
192
+ recvKey: Uint8Array;
193
+ };
194
+ }>;
195
+ /** Serialize a handshake message to bytes for transport. */
196
+ declare function encodePairingMessage(message: PairingHello | PairingReply): Uint8Array;
197
+ /** Parse + validate a client hello from raw bytes. Truncated or malformed
198
+ * input throws `PairingError("malformed_hello")` — never a partial object. A
199
+ * retired pair/v1 hello throws `PairingError("pairing_protocol_outdated")` so
200
+ * the daemon can answer it with `respondToLegacyHandshake`. */
201
+ declare function decodePairingHello(bytes: Uint8Array): PairingHello;
202
+ /** Parse + validate a daemon reply from raw bytes. Truncated or malformed
203
+ * input throws `PairingError("malformed_reply")`. */
204
+ declare function decodePairingReply(bytes: Uint8Array): PairingReply;
205
+
206
+ /**
207
+ * The pairing offer URL codec (design: DESIGN §2).
208
+ *
209
+ * `agentproto pair offer` prints a single URL (also renderable as a QR):
210
+ *
211
+ * ```
212
+ * agentproto://pair?v=2
213
+ * &rv=<rendezvous ws/wss url> // where both sides meet
214
+ * &id=<fingerprint> // daemon identity fingerprint (32 hex)
215
+ * &pk=<b64url x25519 SPKI DER> // daemon static encryption key
216
+ * &sk=<b64url ed25519 SPKI DER> // daemon signing key
217
+ * &s=<one-time offer secret> // derives the route + auth tokens
218
+ * &exp=<unix seconds> // offer expiry
219
+ * &scope=host // optional — see "Offer scope" below
220
+ * ```
221
+ *
222
+ * The URL **is** the bootstrap secret. It carries the daemon's public keys, so
223
+ * a client that scans it can pin the daemon and detect a man-in-the-middle
224
+ * rendezvous (verifying the handshake signature against `sk`); and it carries a
225
+ * one-time, short-TTL secret so a stranger who never saw the URL can't pair.
226
+ * The secret itself never goes on the wire: both sides derive from it a ROUTE
227
+ * token for the broker and an AUTH token for the sealed hello
228
+ * (`deriveOfferTokens`, ./derive.ts), so the broker — which sees the route —
229
+ * can't pair.
230
+ *
231
+ * v=1 offers (pair/v1) used their `t` token as both route and proof; they are
232
+ * refused with `pairing_protocol_outdated`.
233
+ *
234
+ * This module is a **pure codec** — it validates structure and echoes bytes; it
235
+ * performs no I/O and no network calls, so it is safe to run on either side
236
+ * (the daemon builds it, the client parses it). It lives in `@agentproto/secrets`
237
+ * beside the handshake so both sides share one authority on the format.
238
+ *
239
+ * ## The web form (phone QR)
240
+ *
241
+ * A phone camera opens `https://` links, not `agentproto://`. For a browser
242
+ * client the same parameters ride in the **fragment** of a web URL:
243
+ *
244
+ * ```
245
+ * https://<fingerprint>.agentproto.cloud/pair#v=2&rv=…&id=…&pk=…&sk=…&s=…&exp=…
246
+ * ```
247
+ *
248
+ * i.e. the query string of the `agentproto://` URL, verbatim, after the `#`.
249
+ * A fragment is never sent to a server (not in the request line, not in
250
+ * `Referer`), so the page host never sees the token. `encodeOfferWebUrl` builds
251
+ * it; `parseOfferUrl` accepts both forms and validates them identically.
252
+ *
253
+ * Key material travels **base64url** in the URL (no `+`/`/`/`=` to percent-
254
+ * escape). The handshake, however, speaks standard base64 SPKI DER, so
255
+ * `parseOfferUrl` returns `daemonX25519Pub`/`daemonEd25519Pub` already converted
256
+ * back to standard base64 — feed them straight into `startClientHandshake`.
257
+ *
258
+ * ## Offer scope
259
+ *
260
+ * `scope` is an optional, purely additive param: absent (the default) is
261
+ * today's plain "remote-control" offer, byte-identical to before this field
262
+ * existed — `encodeOfferUrl` only emits `&scope=host` when the caller sets
263
+ * `offer.scope === "host"`. A host-scoped offer is what lets the accepting
264
+ * side register the offering daemon as a driveable HOST (`agentproto devices
265
+ * add`), rather than just remote-controlling it as a client.
266
+ *
267
+ * `scope` rides in the plaintext query string — it is metadata, not part of
268
+ * the sealed handshake (`handshake.ts` never sees it). A relay could tamper
269
+ * it in transit; that changes nothing about actual technical capability (a
270
+ * pair/v2 client and host offer are cryptographically identical in what they
271
+ * grant), it can only mislead the ACCEPTING side's own bookkeeping about what
272
+ * it thinks it registered. The daemon that MINTED the offer records its own
273
+ * intended scope server-side (`PairingRegistry`'s `OfferEntry`/
274
+ * `PairingRecord`) independently of anything the URL says by the time a
275
+ * client presents it — that server-side record, never the URL text, is what
276
+ * is authoritative for what the offering daemon believes it granted.
277
+ */
278
+
279
+ /** URL scheme + host for offer URLs. */
280
+ declare const OFFER_URL_SCHEME: "agentproto:";
281
+ declare const OFFER_URL_HOST: "pair";
282
+ /** Offer-format version. Bumped if the param set changes. v2: `s` (a secret
283
+ * that never goes on the wire) replaces v1's `t` (route-and-proof). */
284
+ declare const OFFER_VERSION: 2;
285
+ /** A single shared-origin page for the web form of an offer: every daemon's
286
+ * pairing on one origin (AIP-59 §5.8 fallback, which exposes each pairing to
287
+ * every other paired daemon's UI). Not the default: select it explicitly with
288
+ * `pairing.pairPage` / `--pair-page`. */
289
+ declare const PAIR_WEB_URL: "https://cli.agentproto.sh/pair";
290
+ /** Placeholder for the daemon fingerprint in a pair-page template. Allowed in
291
+ * the hostname only. */
292
+ declare const PAIR_PAGE_FP_PLACEHOLDER: "{fp}";
293
+ /**
294
+ * The default pair page: one origin per daemon
295
+ * (`<fingerprint>.agentproto.cloud`, AIP-59 §5.8), so each daemon's page,
296
+ * service worker and stored credential are isolated from every other
297
+ * pairing's by the browser's same-origin policy. Override it with
298
+ * `pairing.pairPage` / `--pair-page` (a self-hosted page, or a plain URL).
299
+ */
300
+ declare const PAIR_WEB_URL_TEMPLATE_CLOUD: "https://{fp}.agentproto.cloud/pair";
301
+ /** The pair page used when none is configured. */
302
+ declare const DEFAULT_PAIR_PAGE: "https://{fp}.agentproto.cloud/pair";
303
+ /**
304
+ * A parsed, structurally-valid pairing offer. `daemonX25519Pub` /
305
+ * `daemonEd25519Pub` are standard-base64 SPKI DER (handshake-ready). `secret` is
306
+ * the opaque one-time offer secret verbatim — derive the route + auth tokens
307
+ * from it with `deriveOfferTokens`; never send it anywhere. `exp` is unix
308
+ * **seconds**.
309
+ */
310
+ interface PairingOffer {
311
+ v: typeof OFFER_VERSION;
312
+ /** Rendezvous endpoint both sides dial (ws:// or wss://). */
313
+ rendezvousUrl: string;
314
+ /** Daemon identity fingerprint (32 lowercase hex) — must equal fingerprint(pk). */
315
+ fingerprint: string;
316
+ /** Daemon static X25519 public key, standard base64 SPKI DER. */
317
+ daemonX25519Pub: string;
318
+ /** Daemon static Ed25519 public key, standard base64 SPKI DER. */
319
+ daemonEd25519Pub: string;
320
+ /** One-time offer secret — derives the broker route and the sealed auth
321
+ * token (`deriveOfferTokens`). Never sent on its own. */
322
+ secret: string;
323
+ /** Offer expiry, unix seconds. */
324
+ exp: number;
325
+ /** Optional advisory scope (see "Offer scope" above). `"host"` marks an
326
+ * offer minted with `agentproto pair offer --host`; absent/undefined is
327
+ * the default plain remote-control offer. Plaintext, unauthenticated —
328
+ * never treat it as authoritative on its own. */
329
+ scope?: "host";
330
+ }
331
+ /**
332
+ * Build the offer URL from an offer. The public keys come in as standard
333
+ * base64 (the shape the identity file + handshake use) and are emitted as
334
+ * base64url. `secret` is emitted verbatim (callers mint it as base64url).
335
+ */
336
+ declare function encodeOfferUrl(offer: PairingOffer): string;
337
+ /**
338
+ * Resolve a pair-page setting for one daemon. `templateOrUrl` is either a plain
339
+ * http(s) URL (returned unchanged) or a template with `{fp}` in its HOSTNAME,
340
+ * e.g. `https://{fp}.agentproto.cloud/pair`, where `{fp}` becomes the daemon
341
+ * identity `fingerprint` (lowercase hex, which must be a valid DNS label).
342
+ * Throws `PairingError("malformed_offer")` for a template with `{fp}` outside
343
+ * the hostname (userinfo, port, path, query, fragment), for any other brace
344
+ * left in the URL, for a non-http(s) URL, or for a URL with a fragment (the
345
+ * offer goes there).
346
+ */
347
+ declare function resolvePairPageUrl(templateOrUrl: string, fingerprint: string): string;
348
+ /**
349
+ * The `host` (hostname[:port]) a pair page for `fingerprint` must be served
350
+ * from under `templateOrUrl`. The page compares it with its own
351
+ * `location.host` to refuse an offer meant for another daemon's origin.
352
+ */
353
+ declare function expectedPairHost(templateOrUrl: string, fingerprint: string): string;
354
+ /**
355
+ * Re-wrap an `agentproto://pair?…` offer URL as its web form
356
+ * `<pageUrl>#<query>` (see "The web form" above). The parameters are carried
357
+ * byte-for-byte. `pageUrl` is a plain http(s) URL without a fragment, or a
358
+ * `{fp}` template (see `resolvePairPageUrl`) filled in with the offer's daemon
359
+ * fingerprint (`id`).
360
+ */
361
+ declare function encodeOfferWebUrl(offerUrl: string, pageUrl?: string): string;
362
+ interface ParseOfferOptions {
363
+ /**
364
+ * When set, the parser rejects an offer whose `exp` is at or before this
365
+ * instant (unix **milliseconds**) with `PairingError("offer_expired")`. Omit
366
+ * to parse structure only and let the caller decide when to check expiry
367
+ * (the daemon's offer store is the authoritative single-use + expiry gate).
368
+ */
369
+ now?: number;
370
+ }
371
+ /**
372
+ * Parse + strictly validate an offer URL. Rejects with `PairingError` — never
373
+ * resolves a partial object — on any structural problem:
374
+ *
375
+ * - `pairing_protocol_outdated`: a v=1 (pair/v1) offer from an older daemon.
376
+ * - `malformed_offer`: wrong scheme/host, unknown version, missing/blank
377
+ * params, non-base64url keys/token, non-integer `exp`, or a `fingerprint`
378
+ * that does not match `fingerprint(pk)` (tamper detection: a rendezvous or
379
+ * link-mangler that swaps the daemon key can't keep `id` consistent).
380
+ * - `offer_expired`: only when `opts.now` is supplied and `exp` has passed.
381
+ *
382
+ * Accepts the `agentproto://pair?…` form and the web form
383
+ * (`https://…/pair#<query>`, see `encodeOfferWebUrl`).
384
+ *
385
+ * Async because the `id` ↔ `fingerprint(pk)` check hashes the key, and
386
+ * WebCrypto's SHA-256 is async; `crypto` selects the provider.
387
+ */
388
+ declare function parseOfferUrl(url: string, opts?: ParseOfferOptions, crypto?: CryptoProvider): Promise<PairingOffer>;
389
+
390
+ /**
391
+ * The hosted rendezvous broker — the meeting point `pair offer` defaults to
392
+ * when neither `--rendezvous` nor `pairing.rendezvous` is configured.
393
+ *
394
+ * ## Why it lives here
395
+ *
396
+ * It sits in `@agentproto/secrets/pairing`, beside `offer-url.ts` — the codec
397
+ * that bakes this endpoint into every offer's `rv=` param. The daemon runtime
398
+ * already depends on `@agentproto/secrets`, so it reaches this constant without
399
+ * `@agentproto/runtime` gaining a dependency on `@agentproto/rendezvous`. That
400
+ * matters: the broker package is a deliberately lean, standalone container (its
401
+ * only dep is `ws`), and nothing on the daemon/CLI side should have to pull it
402
+ * in just to learn the default endpoint's URL.
403
+ *
404
+ * ## What the broker sees
405
+ *
406
+ * The hosted broker only ever relays **ciphertext** — it learns the route
407
+ * token (opaque; it authenticates nothing — see ./derive.ts), the peers' IPs,
408
+ * ciphertext sizes, and timing; never plaintext or an auth token, and it
409
+ * cannot pair, inject, or alter frames (the pairing handshake is transcript-bound and
410
+ * every frame is AEAD-sealed). See `docs/cli/concepts/pairing.md` for the full
411
+ * threat model.
412
+ *
413
+ * ## Pointing elsewhere / self-hosting
414
+ *
415
+ * The broker is self-hostable (`agentproto rendezvous serve`). To route through
416
+ * your own instead of the hosted default, set `pairing.rendezvous` in
417
+ * `config.json` (or pass `--rendezvous` for a single offer). To disable the
418
+ * default entirely — so a daemon never reaches the hosted broker unless an
419
+ * endpoint is named explicitly — set `pairing.rendezvous: ""` (an explicit
420
+ * opt-out; `pair offer` then requires `--rendezvous`).
421
+ */
422
+ declare const HOSTED_RENDEZVOUS_URL: "wss://rdv.agentproto.sh/v1";
423
+
424
+ /**
425
+ * Pairing-derived key material (design: DESIGN §3/§4).
426
+ *
427
+ * All derivations are HKDF-SHA256 over secret material the untrusted
428
+ * rendezvous never sees, so it can't reproduce any of them:
429
+ *
430
+ * - **pair root** `K_pair = HKDF(session, "pair-root")`: the long-term shared
431
+ * secret persisted by both sides (daemon `pairings.json`, client
432
+ * `pair-credentials.json`). Everything a reconnect needs derives from it.
433
+ * - **route / auth split** (pair/v2): every pairing secret — the offer secret
434
+ * from the offer URL, and the pair root per epoch — yields TWO one-way
435
+ * outputs under distinct labels:
436
+ * - a **route token**, the ONLY value that goes on the broker upgrade URL
437
+ * (`?side=…&t=<route>`). It is an opaque meeting-point name; it
438
+ * authenticates nothing.
439
+ * - an **auth token**, sent ONLY inside the sealed hello (opened by the
440
+ * daemon's X25519 key alone) and checked by the daemon in constant time.
441
+ * HKDF is one-way, so a broker that logs every route it ever sees still
442
+ * can't compute an auth token. pair/v1 used a single value for both jobs,
443
+ * which let the broker pair as a client (offer) or replay a reconnect.
444
+ *
445
+ * Labels (salt ‖ info), all HKDF-SHA256:
446
+ *
447
+ * | output | IKM | salt | info | len |
448
+ * | ----------------- | ---------------------- | --------------------------- | ------------------------------ | --- |
449
+ * | pair root | sorted session keys | transcript hash | `agentproto/pair-root` | 32 |
450
+ * | offer route | UTF-8 of offer secret | `agentproto/pair-offer` | `agentproto/rv-route` | 16 |
451
+ * | offer auth | UTF-8 of offer secret | `agentproto/pair-offer` | `agentproto/rv-auth` | 32 |
452
+ * | epoch route `e` | pair root (raw bytes) | `agentproto/rv-route-salt` | `agentproto/rv-route` ‖ u64(e) | 16 |
453
+ * | epoch auth `e` | pair root (raw bytes) | `agentproto/rv-auth-salt` | `agentproto/rv-auth` ‖ u64(e) | 32 |
454
+ *
455
+ * Tokens are base64url: a 16-byte route is 22 chars (the broker's width), a
456
+ * 32-byte auth is 43. The epoch route is byte-identical to pair/v1's epoch
457
+ * token on purpose: a legacy (v1) client still meets the daemon at the same
458
+ * place, so the daemon can tell it to re-pair instead of leaving it to time out.
459
+ * That leaks nothing new — the route was always public to the broker.
460
+ *
461
+ * ## Why the pair root is derived order-independently
462
+ *
463
+ * A `PairingSession` exposes `sendKey`/`recvKey`, which are **role-swapped**
464
+ * between the two peers (the client's `sendKey` is the daemon's `recvKey`). To
465
+ * get an identical root on both sides without threading a "which side am I"
466
+ * flag, we sort the two keys byte-wise before mixing them: the *set* {sendKey,
467
+ * recvKey} is identical on both sides, so the sorted concatenation — and thus
468
+ * the HKDF output — is identical. Both keys are secret ECDH-derived material the
469
+ * rendezvous never sees, so the root (and every epoch token) stays secret.
470
+ *
471
+ * Every derivation is async (WebCrypto HKDF is) and takes an optional trailing
472
+ * `crypto` provider.
473
+ *
474
+ * ## The pair root as a non-extractable key
475
+ *
476
+ * The epoch derivations accept the pair root either as base64 or as a
477
+ * **non-extractable** WebCrypto HKDF `CryptoKey` (`importPairRootKey`). A
478
+ * browser client can keep that key in IndexedDB, and script can derive tokens
479
+ * with it but never read the root back out. The salts, infos and lengths are
480
+ * the same either way, so the tokens are byte-identical.
481
+ */
482
+
483
+ /**
484
+ * Derive the long-term pair root from a completed handshake session. Returns
485
+ * standard base64 (persisted in `pairings.json` / `credentials.json`). Both
486
+ * peers, despite role-swapped direction keys, produce the identical root.
487
+ */
488
+ declare function derivePairRoot(session: Pick<PairingSession, "sendKey" | "recvKey" | "transcriptHash">, crypto?: CryptoProvider): Promise<string>;
489
+ /** The current pairing epoch — the UTC day number. Injectable `now` (ms) for
490
+ * tests; defaults to the wall clock. */
491
+ declare function currentEpoch(now?: number): number;
492
+ /** A route token (broker URL only) and its paired auth token (sealed hello
493
+ * only), both base64url. */
494
+ interface RouteAuthTokens {
495
+ route: string;
496
+ auth: string;
497
+ }
498
+ /**
499
+ * Import a base64 pair root as a **non-extractable** WebCrypto HKDF key (see
500
+ * "The pair root as a non-extractable key" above). IndexedDB stores it by
501
+ * structured clone, so it stays non-extractable at rest.
502
+ */
503
+ declare function importPairRootKey(pairRoot: string): Promise<CryptoKey>;
504
+ /**
505
+ * Derive the rendezvous ROUTE token for a pairing at a given epoch:
506
+ * `HKDF(pairRoot, salt "agentproto/rv-route-salt", "agentproto/rv-route" ‖ epoch)`.
507
+ * base64url, so it drops straight into a `?t=` upgrade param. This is the only
508
+ * reconnect value the broker sees; it does not authenticate. Deterministic —
509
+ * both sides derive the same token for the same `(pairRoot, epoch)`.
510
+ */
511
+ declare function deriveEpochRoutingToken(pairRoot: string | CryptoKey, epoch: number, crypto?: CryptoProvider): Promise<string>;
512
+ /**
513
+ * Derive the reconnect AUTH token for a pairing at a given epoch:
514
+ * `HKDF(pairRoot, salt "agentproto/rv-auth-salt", "agentproto/rv-auth" ‖ epoch)`.
515
+ * Carried only inside the sealed hello; the daemon compares it in constant
516
+ * time. Never put it on a URL.
517
+ */
518
+ declare function deriveEpochAuthToken(pairRoot: string | CryptoKey, epoch: number, crypto?: CryptoProvider): Promise<string>;
519
+ /** Route + auth tokens for a pairing at one epoch. */
520
+ declare function deriveEpochTokens(pairRoot: string | CryptoKey, epoch: number, crypto?: CryptoProvider): Promise<RouteAuthTokens>;
521
+ /**
522
+ * Derive the route + auth tokens for a pairing offer from its secret (the
523
+ * offer URL's `s`): the daemon parks on — and the client dials — `route`; the
524
+ * client seals `auth` into its hello. The secret itself never leaves the offer
525
+ * URL.
526
+ */
527
+ declare function deriveOfferTokens(offerSecret: string, crypto?: CryptoProvider): Promise<RouteAuthTokens>;
528
+ /**
529
+ * The route + auth tokens a peer should accept/dial to bridge clock skew
530
+ * around a day boundary: the current epoch and the previous one (design:
531
+ * PLAN "accept current and previous epoch"). The daemon parks on both routes
532
+ * so a client whose clock sits on either side of midnight still finds it; the
533
+ * client likewise tries both when reconnecting.
534
+ */
535
+ declare function epochRoutingTokens(pairRoot: string | CryptoKey, now?: number, crypto?: CryptoProvider): Promise<({
536
+ epoch: number;
537
+ } & RouteAuthTokens)[]>;
538
+
539
+ /**
540
+ * @agentproto/secrets/pairing — the `tunnel-e2e/v1` handshake.
541
+ *
542
+ * The reverse tunnel (`agentproto serve --connect <host>`) is a DIFFERENT trust
543
+ * relationship from the client↔daemon pairing in ./handshake.ts:
544
+ *
545
+ * - There is no offer URL, no QR, no PKI. The daemon and the host already
546
+ * share one pre-provisioned secret — the `apt_` **tunnel token** the daemon
547
+ * presents as its WS bearer. Both ends hold it before the socket opens.
548
+ * - So instead of authenticating with a static-key seal + Ed25519 signature,
549
+ * both ends authenticate by proving knowledge of that shared token, and get
550
+ * forward secrecy from a fresh ephemeral X25519 exchange.
551
+ *
552
+ * ```
553
+ * daemon → host: offer = { ePub_d, mac_d = HMAC(K_auth, "offer/v1" ‖ ePub_d) }
554
+ * host → daemon: accept = { ePub_h, mac_h = HMAC(K_auth, "accept/v1" ‖ ePub_d ‖ ePub_h) }
555
+ * both: K_auth = HKDF(ikm = utf8(token),
556
+ * salt = sha256(token), info = "…/auth/v1") // token-only MAC key
557
+ * K = HKDF(ikm = ECDH(e_d, e_h),
558
+ * salt = sha256(token), info = "…/v1") // → K_d2h ‖ K_h2d
559
+ * ```
560
+ *
561
+ * Why this shape:
562
+ * - **Mutual authentication at handshake time.** `mac_d` binds the daemon
563
+ * ephemeral to the token; the host verifies it and refuses (`bad_auth`) if
564
+ * the token differs — no partial session, no plaintext, before the daemon's
565
+ * first byte. `mac_h` binds BOTH ephemerals to the token; the daemon
566
+ * verifies it symmetrically. A man-in-the-middle without the token cannot
567
+ * forge either MAC for its own ephemeral, and cannot reuse a recorded one
568
+ * (it lacks the matching private key to finish the ECDH). So the confirm is
569
+ * a **transcript/confirm step that fails a token mismatch AT handshake time,
570
+ * not mid-stream** — exactly what a wrong `tunnel.token` on one side needs.
571
+ * - **Forward secrecy.** The session keys mix ONLY the ephemeral ECDH output;
572
+ * the long-term token merely salts them. A later token compromise can't
573
+ * decrypt a recorded past session.
574
+ * - **Salt-binds the token.** Because `sha256(token)` salts the session-key
575
+ * HKDF too, even if the MACs were somehow bypassed the derived AEAD keys
576
+ * still disagree under a mismatched token → the channel fails closed.
577
+ *
578
+ * Like ./handshake.ts, the primitives come from a `CryptoProvider` (node:crypto
579
+ * or WebCrypto) and every crypto entry point is async with an optional trailing
580
+ * `crypto` argument. Like ./handshake.ts, this module is also deliberately **transport-agnostic**: it
581
+ * produces and consumes plain byte messages (`encode*`/`decode*`). The code that
582
+ * pumps those bytes over a `FrameSink` and wraps the channel lives in
583
+ * `@agentproto/acp/tunnel`, which never depends on this package — it receives
584
+ * only the two derived symmetric keys. Everything crypto stays here.
585
+ */
586
+
587
+ /** Wire version. Bumped if the message shape or key schedule changes; both
588
+ * sides refuse a version they don't recognise. */
589
+ declare const TUNNEL_E2E_VERSION: 1;
590
+ /** Stable, machine-readable failure codes. Every rejection maps to one of these
591
+ * so callers (and tests) branch on a code, not a message string. */
592
+ type TunnelHandshakeErrorCode = "malformed_offer" | "malformed_accept" | "invalid_key" | "bad_auth" | "unsupported_version";
593
+ /** Raised for every tunnel-handshake failure. Never carries key material or the
594
+ * token; the `code` is the contract, the message is for humans. */
595
+ declare class TunnelHandshakeError extends Error {
596
+ readonly code: TunnelHandshakeErrorCode;
597
+ constructor(code: TunnelHandshakeErrorCode, message: string);
598
+ }
599
+ /** Daemon → host. `ePub` is the daemon ephemeral X25519 public key (base64 SPKI
600
+ * DER); `mac` authenticates it under the shared tunnel token. */
601
+ interface TunnelOffer {
602
+ v: typeof TUNNEL_E2E_VERSION;
603
+ ePub: string;
604
+ mac: string;
605
+ }
606
+ /** Host → daemon. `ePub` is the host ephemeral X25519 public key (base64 SPKI
607
+ * DER); `mac` authenticates both ephemerals under the shared tunnel token. */
608
+ interface TunnelAccept {
609
+ v: typeof TUNNEL_E2E_VERSION;
610
+ ePub: string;
611
+ mac: string;
612
+ }
613
+ /**
614
+ * A completed tunnel handshake, on either side. `sendKey`/`recvKey` are already
615
+ * role-adjusted (the daemon's `sendKey` is the host's `recvKey`), so the
616
+ * consumer — `wrapE2E` — never has to know which side it is.
617
+ */
618
+ interface TunnelE2ESession {
619
+ /** AES-256-GCM key for frames THIS side sends. 32 bytes. */
620
+ sendKey: Uint8Array;
621
+ /** AES-256-GCM key for frames THIS side receives. 32 bytes. */
622
+ recvKey: Uint8Array;
623
+ /** `sha256(SESSION_INFO ‖ ePub_d ‖ ePub_h)` — the exact transcript both sides
624
+ * bound to. Exposed for parity with `PairingSession`; not required to use. */
625
+ transcriptHash: Uint8Array;
626
+ }
627
+ /** A started daemon handshake: send `offer`, then feed the host's `accept` to
628
+ * `complete` to derive the session. */
629
+ interface StartedTunnelHandshake {
630
+ offer: TunnelOffer;
631
+ /**
632
+ * Verify the host accept and derive the session. Throws `TunnelHandshakeError`
633
+ * on a bad MAC (`bad_auth` — the host holds a different token), a malformed
634
+ * accept, or an invalid key — never returns partial state.
635
+ */
636
+ complete(accept: TunnelAccept): Promise<TunnelE2ESession>;
637
+ }
638
+ /**
639
+ * Begin the daemon (initiator) side. Generates the daemon ephemeral keypair,
640
+ * MACs it under the token, and resolves to the `offer` to send plus a
641
+ * `complete` to run once the host replies with its `accept`. `crypto` selects
642
+ * the primitive implementation (captured for `complete` too).
643
+ */
644
+ declare function startTunnelHandshake(token: string, crypto?: CryptoProvider): Promise<StartedTunnelHandshake>;
645
+ /** A completed host handshake: send `accept`, keep `session`. */
646
+ interface TunnelHandshakeResult {
647
+ accept: TunnelAccept;
648
+ session: TunnelE2ESession;
649
+ }
650
+ /**
651
+ * Respond to a daemon offer (host side). Verifies the offer MAC under the shared
652
+ * token, generates the host ephemeral, MACs both ephemerals, and derives the
653
+ * session. Rejects with `TunnelHandshakeError` on a bad MAC (`bad_auth`),
654
+ * malformed offer, or invalid key BEFORE producing any accept — so a mismatched
655
+ * token yields no session and no reply, failing closed at handshake time.
656
+ */
657
+ declare function respondToTunnelHandshake(offer: TunnelOffer, token: string, crypto?: CryptoProvider): Promise<TunnelHandshakeResult>;
658
+ /** Serialize a handshake message to bytes for transport. */
659
+ declare function encodeTunnelMessage(message: TunnelOffer | TunnelAccept): Uint8Array;
660
+ /** Parse + validate a daemon offer from raw bytes. Truncated or malformed input
661
+ * throws `TunnelHandshakeError("malformed_offer")` — never a partial object. */
662
+ declare function decodeTunnelOffer(bytes: Uint8Array): TunnelOffer;
663
+ /** Parse + validate a host accept from raw bytes. Truncated or malformed input
664
+ * throws `TunnelHandshakeError("malformed_accept")`. */
665
+ declare function decodeTunnelAccept(bytes: Uint8Array): TunnelAccept;
666
+
667
+ /**
668
+ * `CryptoProvider` over WebCrypto (`globalThis.crypto.subtle`) — the default in
669
+ * the browser-safe entry. Browser-safe: no `node:` import, no `Buffer`.
670
+ *
671
+ * Needs X25519 + Ed25519 in `SubtleCrypto` (Chrome 133+, Safari 17+, Firefox
672
+ * 130+, Node ≥ 20). The subtle instance is resolved lazily, per call, so merely
673
+ * importing this module never throws in an environment without WebCrypto.
674
+ */
675
+
676
+ declare const webCryptoProvider: CryptoProvider;
677
+
678
+ export { startClientHandshake as $, decodeTunnelOffer as A, deriveEpochAuthToken as B, type ClientHandshakeParams as C, DEFAULT_PAIR_PAGE as D, deriveEpochRoutingToken as E, deriveEpochTokens as F, deriveOfferTokens as G, HOSTED_RENDEZVOUS_URL as H, derivePairRoot as I, encodeOfferUrl as J, encodeOfferWebUrl as K, LEGACY_PAIR_VERSION as L, encodePairingMessage as M, encodeTunnelMessage as N, OFFER_URL_HOST as O, PAIRING_PROTOCOL_OUTDATED_MESSAGE as P, epochRoutingTokens as Q, type RouteAuthTokens as R, type StartedClientHandshake as S, TUNNEL_E2E_VERSION as T, expectedPairHost as U, importPairRootKey as V, parseOfferUrl as W, resolvePairPageUrl as X, respondToHandshake as Y, respondToLegacyHandshake as Z, respondToTunnelHandshake as _, type DaemonHandshakeParams as a, startTunnelHandshake as a0, webCryptoProvider as a1, type DaemonHandshakeResult as b, OFFER_URL_SCHEME as c, OFFER_VERSION as d, PAIR_PAGE_FP_PLACEHOLDER as e, PAIR_VERSION as f, PAIR_WEB_URL as g, PAIR_WEB_URL_TEMPLATE_CLOUD as h, PairingError as i, type PairingErrorCode as j, type PairingHello as k, type PairingOffer as l, type PairingReply as m, type PairingSession as n, type ParseOfferOptions as o, type StartedTunnelHandshake as p, type TunnelAccept as q, type TunnelE2ESession as r, TunnelHandshakeError as s, type TunnelHandshakeErrorCode as t, type TunnelHandshakeResult as u, type TunnelOffer as v, currentEpoch as w, decodePairingHello as x, decodePairingReply as y, decodeTunnelAccept as z };