@yozz.app/tls 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fishball Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @yozz.app/tls
2
+
3
+ A TLS 1.3 client (RFC 9846) in TypeScript, over any byte duplex. It is what the YOZZ mail
4
+ client uses to reach IMAP and SMTP servers from inside a browser, where there is no socket API
5
+ and no platform TLS to call.
6
+
7
+ - Record layer, handshake state machine, key schedule, `KeyUpdate`, `close_notify`.
8
+ - 1-RTT PSK resumption; never 0-RTT.
9
+ - Certificate validation through [`@yozz.app/x509`](https://www.npmjs.com/package/@yozz.app/x509),
10
+ plus trust-on-first-use public-key pinning as a `Validator` wrapper.
11
+ - WebCrypto only: runs in Node and in Chromium, Firefox and WebKit with no native code.
12
+ - Gates: RFC 8448 byte-exact on all five traces; BoringSSL's BoGo runner, 296/296 in scope.
13
+
14
+ ```bash
15
+ pnpm add @yozz.app/tls
16
+ ```
17
+
18
+ ```ts
19
+ import { startTls } from '@yozz.app/tls';
20
+ import { compileAnchors, ROOT_BUNDLE, YOZZ_VALIDATOR } from '@yozz.app/x509';
21
+
22
+ // `transport` is a ByteDuplex: { read(): Promise<Uint8Array | null>; write(bytes): Promise<void> }
23
+ // over whatever carries bytes to the server (a TCP socket in Node, a WebSocket relay in a browser).
24
+ const result = await startTls({
25
+ transport,
26
+ serverName: 'imap.example.com',
27
+ trustAnchors: compileAnchors(ROOT_BUNDLE).source,
28
+ validationTime: new Date(),
29
+ validator: YOZZ_VALIDATOR,
30
+ });
31
+ if (result.ok) {
32
+ const { connection } = result; // read() / write() / close() over the encrypted channel
33
+ }
34
+ ```
35
+
36
+ `HandshakeResult` is a discriminated union, never a throw for a refused certificate; the refusal
37
+ says which check failed. A session handed to `onSession` can be offered once on a later
38
+ connection to resume.
39
+
40
+ `src/index.ts` is the whole public API and says why each export exists. The source is in
41
+ [fishballapp/yozz](https://github.com/fishballapp/yozz), which takes issues but no pull requests.
42
+
43
+ MIT.
@@ -0,0 +1,511 @@
1
+ import { PeerName, TrustAnchorSource, ValidationFailure, Validator } from "@yozz.app/x509";
2
+
3
+ //#region src/wire.d.ts
4
+ type NamedGroup = 'x25519' | 'secp256r1' | 'secp384r1';
5
+ declare const NAMED_GROUPS: Readonly<Record<NamedGroup, number>>;
6
+ /**
7
+ * Every group this client implements, in the order it offers them — so the
8
+ * first one carries the key share, and a server that accepts our first choice
9
+ * negotiates without a HelloRetryRequest.
10
+ *
11
+ * P-384 is not optional here: `posteo.de` refuses X25519 and P-256 outright.
12
+ *
13
+ * It doubles as the default for `supportedGroups`, and `wire.test.ts` holds it
14
+ * to covering every key of `NAMED_GROUPS` — `namedGroupFromCode` searches this
15
+ * list, so a group implemented but missing from it would decode as unknown.
16
+ */
17
+ declare const SUPPORTED_GROUPS: readonly NamedGroup[];
18
+ /** The wire code back to the name, `undefined` for a group we do not implement. */
19
+ declare const namedGroupFromCode: (code: number) => NamedGroup | undefined;
20
+ type SignatureScheme = 'ecdsa_secp256r1_sha256' | 'ecdsa_secp384r1_sha384' | 'rsa_pss_rsae_sha256' | 'rsa_pss_rsae_sha384' | 'rsa_pss_rsae_sha512' | 'ed25519';
21
+ declare const SIGNATURE_SCHEMES: Readonly<Record<SignatureScheme, number>>;
22
+ /**
23
+ * Every scheme this client can verify a CertificateVerify with, in the order it
24
+ * offers them, and the default for `signatureSchemes`.
25
+ *
26
+ * The list is the security boundary, not just a preference: RFC 9846 §4.5.2
27
+ * says a server's "signature algorithm MUST be one offered in the client's
28
+ * `signature_algorithms` extension", and the handshake refuses one that is not.
29
+ * So a scheme missing from here is a scheme the server may not sign with, and a
30
+ * scheme present but unimplemented in `verify.ts` is a hole — `wire.test.ts`
31
+ * holds this to covering every key of `SIGNATURE_SCHEMES`, the same way
32
+ * `SUPPORTED_GROUPS` is held to `NAMED_GROUPS`.
33
+ *
34
+ * Ed25519 is offered where BoringSSL disables it by default. It is a smaller,
35
+ * misuse-resistant signature over a curve we already carry for key exchange,
36
+ * and refusing it would only push a server onto RSA.
37
+ */
38
+ declare const SUPPORTED_SIGNATURE_SCHEMES: readonly SignatureScheme[];
39
+ /** The wire code back to the name, `undefined` for a scheme we do not implement. */
40
+ declare const signatureSchemeFromCode: (code: number) => SignatureScheme | undefined;
41
+ type AlertDescription = 'close_notify' | 'unexpected_message' | 'bad_record_mac' | 'record_overflow' | 'handshake_failure' | 'bad_certificate' | 'unsupported_certificate' | 'certificate_expired' | 'certificate_unknown' | 'certificate_revoked' | 'unknown_ca' | 'illegal_parameter' | 'access_denied' | 'decode_error' | 'decrypt_error' | 'protocol_version' | 'insufficient_security' | 'internal_error' | 'inappropriate_fallback' | 'user_canceled' | 'missing_extension' | 'unsupported_extension' | 'unrecognized_name' | 'bad_certificate_status_response' | 'unknown_psk_identity' | 'certificate_required' | 'general_error' | 'no_application_protocol';
42
+ //#endregion
43
+ //#region src/alert.d.ts
44
+ type AlertLevel = 'warning' | 'fatal';
45
+ type Alert = {
46
+ readonly level: AlertLevel;
47
+ readonly description: AlertDescription;
48
+ };
49
+ type TlsFailure = {
50
+ readonly kind: 'alert-sent';
51
+ readonly alert: Alert;
52
+ } | {
53
+ readonly kind: 'alert-received';
54
+ readonly alert: Alert;
55
+ }
56
+ /**
57
+ * A well-formed alert whose description TLS 1.3 does not define. RFC 9846 §6:
58
+ * "Unknown Alert types MUST be treated as error alerts" — so the connection
59
+ * ends, and what the peer said is reported rather than answered. Old servers
60
+ * still send old alerts, and "the server sent alert 30" is a diagnosis where
61
+ * "we sent illegal_parameter" is not.
62
+ */
63
+ | {
64
+ readonly kind: 'alert-received-unknown';
65
+ readonly code: number;
66
+ } | {
67
+ readonly kind: 'truncated';
68
+ } | {
69
+ readonly kind: 'certificate';
70
+ readonly reason: ValidationFailure;
71
+ readonly alert: Alert;
72
+ /**
73
+ * WHICH chain was refused, and it is the field a caller acts on.
74
+ *
75
+ * `'peer-sent'` is the chain this handshake received. `'session-stored'`
76
+ * is the copy a resumed session carried, re-checked because
77
+ * `reverifyOnResume` asked for it — and the two want opposite responses.
78
+ * A stored chain that no longer validates usually means the mail host has
79
+ * ROTATED and the session is stale, so the answer is to evict it and
80
+ * reconnect without one, which will very likely succeed. The same
81
+ * `ValidationFailure` on a peer-sent chain means the host is actually
82
+ * presenting something we refuse, and retrying achieves nothing but a
83
+ * second refusal.
84
+ *
85
+ * Two cross-model reviews landed on this independently: without it,
86
+ * `kind: 'certificate'` is all a caller has, `certificate-expired` reads
87
+ * as "this mail host's certificate expired" in both cases, and the
88
+ * retry-and-recover path this library tells callers to own cannot be
89
+ * written.
90
+ */
91
+ readonly chain: 'peer-sent' | 'session-stored';
92
+ };
93
+ //#endregion
94
+ //#region src/key-schedule.d.ts
95
+ /**
96
+ * TLS 1.3's key schedule — RFC 9846 §7.1 — over WebCrypto.
97
+ *
98
+ * **WebCrypto's own `HKDF` algorithm is unusable here.** It fuses extract and
99
+ * expand, and TLS needs the halves apart: a bare Extract for the Early,
100
+ * Handshake and Master secrets, and a bare Expand-Label everywhere else. Both
101
+ * are a few lines over `subtle.sign('HMAC')`, and every one of them is checked
102
+ * byte-for-byte against RFC 8448's five traces in the test beside this file.
103
+ *
104
+ * Nothing here knows what a record or a handshake message is. The transcript
105
+ * arrives already hashed, which is what lets the schedule be proven against the
106
+ * RFC before a single byte is framed.
107
+ */
108
+ type CipherSuite = 'TLS_AES_128_GCM_SHA256' | 'TLS_AES_256_GCM_SHA384';
109
+ type CipherSuiteParameters = {
110
+ /** The wire code point, RFC 9846 App. B.4. */readonly code: number;
111
+ readonly hash: 'SHA-256' | 'SHA-384';
112
+ readonly hashLength: number;
113
+ readonly keyLength: number; /** RFC 9846 §5.3: the AEAD nonce, which is 12 octets for both AES-GCM suites. */
114
+ readonly ivLength: number;
115
+ };
116
+ /**
117
+ * The two suites the spike measured as necessary and sufficient across real mail
118
+ * providers — `posteo.de` takes only the second. `TLS_CHACHA20_POLY1305_SHA256`
119
+ * is deliberately absent: WebCrypto has no ChaCha20, and RFC 9846 keeps it
120
+ * optional.
121
+ */
122
+ declare const CIPHER_SUITES: Readonly<Record<CipherSuite, CipherSuiteParameters>>;
123
+ /**
124
+ * RFC 5869 §2.1. An empty salt means HashLen zero octets — which is also how RFC
125
+ * 8446 §7.1 draws the Early Secret's `0` — and the substitution is not optional:
126
+ * WebCrypto refuses a zero-length HMAC key outright.
127
+ */
128
+ declare const hkdfExtract: (suite: CipherSuite, salt: Uint8Array, ikm: Uint8Array) => Promise<Uint8Array>;
129
+ /**
130
+ * `async` so that a rejected label fails the same way a rejected length does.
131
+ * Without it `hkdfLabel` throws synchronously, as an argument evaluated before
132
+ * the call, and a caller using `.catch()` rather than `await` would miss it.
133
+ */
134
+ declare const hkdfExpandLabel: (suite: CipherSuite, secret: Uint8Array, label: string, context: Uint8Array, length: number) => Promise<Uint8Array>;
135
+ /**
136
+ * RFC 9846 §7.1's `Derive-Secret(Secret, Label, Messages)` — and it takes
137
+ * MESSAGES, hashing them itself, because that is what the RFC's signature says.
138
+ *
139
+ * An earlier shape took the transcript pre-hashed while keeping this name. That
140
+ * invites a handshake author reading the RFC to pass `ClientHello ‖ ServerHello`
141
+ * straight in, which derives a secret that is merely different: every such bug
142
+ * surfaces at `Finished` as `decrypt_error`, nowhere near the cause. Pass
143
+ * nothing for the empty transcript. For the one derivation that takes an EMPTY
144
+ * context rather than `Hash("")` — the `finished` key — use `hkdfExpandLabel`.
145
+ */
146
+ declare const deriveSecret: (suite: CipherSuite, secret: Uint8Array, label: string, ...messages: readonly Uint8Array[]) => Promise<Uint8Array>;
147
+ /** `Transcript-Hash` over the handshake messages as they went on the wire. */
148
+ declare const transcriptHash: (suite: CipherSuite, ...messages: readonly Uint8Array[]) => Promise<Uint8Array>;
149
+ /**
150
+ * The three Extracts of the schedule, each folding in the one input it exists
151
+ * for. `Derive-Secret(., "derived", "")` between them is the step that is easy
152
+ * to forget, and it hashes the EMPTY transcript rather than the handshake so
153
+ * far.
154
+ */
155
+ declare const earlySecret: (suite: CipherSuite, psk?: Uint8Array) => Promise<Uint8Array>;
156
+ declare const handshakeSecret: (suite: CipherSuite, early: Uint8Array, sharedSecret: Uint8Array) => Promise<Uint8Array>;
157
+ declare const masterSecret: (suite: CipherSuite, handshake: Uint8Array) => Promise<Uint8Array>;
158
+ type TrafficKeys = {
159
+ readonly key: Uint8Array;
160
+ readonly iv: Uint8Array;
161
+ };
162
+ /** RFC 9846 §7.3. Both halves come off the same traffic secret. */
163
+ declare const trafficKeys: (suite: CipherSuite, secret: Uint8Array) => Promise<TrafficKeys>;
164
+ /** RFC 9846 §4.5.3. The base key is the sender's handshake traffic secret. */
165
+ declare const finishedKey: (suite: CipherSuite, baseKey: Uint8Array) => Promise<Uint8Array>;
166
+ /** Our own `Finished.verify_data`, to send. */
167
+ declare const verifyData: (suite: CipherSuite, key: Uint8Array, transcript: Uint8Array) => Promise<Uint8Array>;
168
+ /**
169
+ * Their `Finished.verify_data`, to check — through `subtle.verify`, which is
170
+ * constant-time, rather than a byte comparison that is not. Correctness here is
171
+ * free, so there is no reason to spend a timing side channel on it.
172
+ */
173
+ declare const isVerifyDataValid: (suite: CipherSuite, key: Uint8Array, transcript: Uint8Array, received: Uint8Array) => Promise<boolean>;
174
+ //#endregion
175
+ //#region src/session.d.ts
176
+ /**
177
+ * The certificates the peer actually sent, split the way
178
+ * `PathValidationRequest` wants them — so re-validating a stored chain is the
179
+ * same call over the same shape, not a reassembly that could put the leaf in
180
+ * the wrong slot.
181
+ *
182
+ * A leaf is not optional and the two halves are separate fields, which is what
183
+ * makes "the chain is empty" unrepresentable rather than a case to check.
184
+ */
185
+ type PeerCertificateChain = {
186
+ readonly leafDer: Uint8Array;
187
+ readonly intermediateDer: readonly Uint8Array[];
188
+ };
189
+ type TlsSession = {
190
+ /**
191
+ * The host this ticket came from. A ticket is an identifier the server hands
192
+ * out in the clear and sees again in the clear, so offering one to a
193
+ * different host tells that host where else we have been.
194
+ */
195
+ readonly serverName: string;
196
+ /**
197
+ * The identity the certificate was actually checked against on the connection
198
+ * that issued this ticket — which is NOT always `serverName`, because
199
+ * `expectedPeerName` may override it and `null` disables name matching
200
+ * altogether.
201
+ *
202
+ * It travels with the session because a resumed handshake sends no
203
+ * Certificate and no CertificateVerify: there is nothing left to check the
204
+ * name against, so the only moment the identity can be established is the
205
+ * connection that issued the ticket. Without this a session minted under
206
+ * `null` could be offered on a connection that asked for a strict name, and
207
+ * the strict check would never run.
208
+ */
209
+ readonly expectedPeerName: PeerName | null;
210
+ /**
211
+ * The suite that issued it, and with it the hash the binder runs on. A
212
+ * server may resume at a different suite, but only one with the same hash
213
+ * (§4.3.11), which is why this travels with the session rather than being
214
+ * re-derived from whatever gets negotiated next.
215
+ */
216
+ readonly suite: CipherSuite; /** The opaque ticket, which travels as the PSK identity. */
217
+ readonly ticket: Uint8Array; /** §4.7.1's `PSK`, already expanded through the ticket's own nonce. */
218
+ readonly preSharedKey: Uint8Array; /** §4.3.11.1's `ticket_age_add`, the server's random offset. */
219
+ readonly ticketAgeAdd: number;
220
+ readonly receivedAt: Date;
221
+ readonly lifetimeSeconds: number;
222
+ /**
223
+ * When the peer's certificate was last actually verified — carried FORWARD
224
+ * unchanged through every renewal, so it dates the chain of tickets rather
225
+ * than the newest one.
226
+ *
227
+ * RFC 9846 §4.7.1: "it is possible to continue issuing new tickets which
228
+ * indefinitely extend the lifetime of the keying material originally derived
229
+ * from an initial non-PSK handshake (which was most likely tied to the peer's
230
+ * certificate). It is RECOMMENDED that implementations place limits on the
231
+ * total lifetime of such keying material". Without this field there is nothing
232
+ * to place a limit against: a resumed connection mints its own tickets, each
233
+ * looking brand new, and the one signature that ever proved the peer's
234
+ * identity recedes indefinitely into the past.
235
+ */
236
+ readonly authenticatedAt: Date;
237
+ /**
238
+ * The scheme the peer's CertificateVerify was signed with on the connection
239
+ * that authenticated it — carried forward through every renewal, alongside
240
+ * `authenticatedAt`, because they describe the same signature. The renewal
241
+ * step used to be asserted by no test anywhere; `session.test.ts` now drives
242
+ * RFC 8448 §4 through the state machine and seals a ticket under §4's own
243
+ * published server application key to watch all three arrive.
244
+ *
245
+ * A resumed handshake sends no CertificateVerify, so without this a caller
246
+ * asking "how was this peer authenticated" gets an answer on the first
247
+ * connection and nothing on every one after it.
248
+ *
249
+ * A NAME rather than a code point, because this is the field that goes into
250
+ * the caller's store: `'rsa_pss_rsae_sha512'` survives a rehydration and a
251
+ * table edit where `2054` reads as nothing at all.
252
+ */
253
+ readonly peerSignatureScheme: SignatureScheme;
254
+ /**
255
+ * The chain the peer sent on the connection that authenticated it — carried
256
+ * forward through every renewal beside `authenticatedAt` and
257
+ * `peerSignatureScheme`, because all three describe that one authentication.
258
+ *
259
+ * It is here so a resumed handshake can validate the peer's chain AGAIN, as
260
+ * of today's clock and today's trust anchors (`reverifyOnResume` in
261
+ * `handshake.ts`). Nothing on a resumed wire carries a certificate, so the
262
+ * stored copy is the only chain there is to check — and checking it is what
263
+ * turns an expired leaf or a root we stopped shipping into a refusal on the
264
+ * next reconnect rather than one up to `MAX_AUTHENTICATION_AGE_SECONDS`
265
+ * later.
266
+ *
267
+ * It costs the caller's store a few KB per session, which is the whole price
268
+ * of the check.
269
+ */
270
+ readonly peerCertificateChain: PeerCertificateChain;
271
+ };
272
+ //#endregion
273
+ //#region src/transport.d.ts
274
+ /**
275
+ * Duplex byte transport interface and in-memory duplex pair implementation.
276
+ */
277
+ type ByteDuplex = {
278
+ readonly read: () => Promise<Uint8Array | null>;
279
+ readonly write: (bytes: Uint8Array) => Promise<void>;
280
+ };
281
+ //#endregion
282
+ //#region src/handshake.d.ts
283
+ type StartTlsOptions = {
284
+ readonly transport: ByteDuplex;
285
+ readonly serverName: string;
286
+ readonly trustAnchors: TrustAnchorSource;
287
+ readonly validationTime: Date;
288
+ readonly validator: Validator;
289
+ readonly expectedPeerName?: PeerName | null;
290
+ /**
291
+ * What `supported_groups` offers, in preference order — the first carries the
292
+ * key share. Defaults to every group we implement.
293
+ */
294
+ readonly supportedGroups?: readonly NamedGroup[];
295
+ /**
296
+ * What `signature_algorithms` offers, in preference order. Defaults to every
297
+ * scheme we implement.
298
+ *
299
+ * It is a security boundary as much as a preference: RFC 9846 §4.5.2 says a
300
+ * server's CertificateVerify "signature algorithm MUST be one offered in the
301
+ * client's `signature_algorithms` extension", and a scheme left out of this
302
+ * list is one the handshake below refuses with `illegal_parameter`.
303
+ */
304
+ readonly signatureSchemes?: readonly SignatureScheme[];
305
+ /**
306
+ * A session from an earlier connection to this same host, to offer for
307
+ * resumption. One is offered, not a list: a client that cannot tell which of
308
+ * its tickets a server will take is guessing, and every guess it sends is
309
+ * another identifier on the wire in the clear.
310
+ *
311
+ * **Offer each session once.** RFC 9846 App. C.4: "Clients SHOULD NOT reuse a
312
+ * ticket for multiple connections. Reuse of a ticket allows passive observers
313
+ * to correlate different connections." The ticket travels in the clear, so a
314
+ * store that keeps handing back the same one turns it into a tracking cookie.
315
+ * Nothing here can enforce that — the store is the caller's — so the caller
316
+ * evicts what it passed in and keeps whatever `onSession` hands back.
317
+ *
318
+ * A session is refused unless `serverName` and `expectedPeerName` match the
319
+ * connection that issued it, because a resumed handshake re-proves neither.
320
+ * `trustAnchors` and `validator` are NOT part of that binding: today they are
321
+ * the same on every connection — ARCHITECTURE.md pins one root bundle — and
322
+ * the day they vary per account they have to join it.
323
+ */
324
+ readonly session?: TlsSession;
325
+ /**
326
+ * Whether a resumed handshake validates the session's stored chain again,
327
+ * against today's clock and today's trust anchors. **Defaults to `true`.**
328
+ *
329
+ * A resumed handshake proves the peer holds the pre-shared key and nothing
330
+ * else: no Certificate, no CertificateVerify. So without this the certificate
331
+ * decision is the one made on the connection that issued the ticket, and the
332
+ * only thing bounding how stale it may be is
333
+ * `MAX_AUTHENTICATION_AGE_SECONDS` — a week. Re-validating narrows that to
334
+ * the reconnect: a leaf that expired an hour ago, or a root an app update
335
+ * stopped shipping, is refused now rather than within seven days.
336
+ *
337
+ * **It is not a substitute for that ceiling and does not retire it.** The
338
+ * ceiling bounds the age of the SIGNATURE, which nothing on a resumed wire
339
+ * re-proves; this bounds the validity of the CHAIN, which the stored copy
340
+ * still answers for. Two different claims, both needed.
341
+ *
342
+ * **Failure is a refused connection, not a quiet full handshake.** The
343
+ * session has already been accepted by the server by the time this runs, and
344
+ * a client that silently reconnected without it would turn "your stored chain
345
+ * no longer validates" into a fact nothing observes. What the caller gets
346
+ * instead is `{ kind: 'certificate', chain: 'session-stored' }` — and that
347
+ * `chain` field is the whole reason the refusal is usable: it separates a
348
+ * stale STORED chain, where evicting the session and reconnecting is very
349
+ * likely to work because the host has rotated, from a peer-sent chain we
350
+ * refuse, where retrying earns a second refusal. The store is the caller's,
351
+ * so the eviction is too.
352
+ *
353
+ * Default `true` because a check that must be asked for is a check that gets
354
+ * forgotten. BoringSSL's default is the other way and `-reverify-on-resume`
355
+ * turns it on, so the BoGo shim passes `false` explicitly.
356
+ */
357
+ readonly reverifyOnResume?: boolean;
358
+ /**
359
+ * Called once per `NewSessionTicket`, which in TLS 1.3 arrive AFTER the
360
+ * handshake — so this fires from `read()`, not from `startTls`. Without it no
361
+ * derivation runs and the tickets are dropped where they are read.
362
+ */
363
+ readonly onSession?: (session: TlsSession) => void | Promise<void>;
364
+ /**
365
+ * The client's clock, read whenever the AGE of a resumption ticket matters:
366
+ * once when a ticket arrives, and once per ClientHello that offers one.
367
+ *
368
+ * Deliberately not `validationTime`. That one is a POLICY input — the instant
369
+ * to validate a chain as of — and a caller may legitimately freeze or backdate
370
+ * it; a frozen clock here would report every ticket as brand new and go on
371
+ * offering one long past its lifetime. This is the running clock, and it is
372
+ * injectable because BoGo compares the reported age to `-resumption-delay` for
373
+ * exact equality. Defaults to the real one.
374
+ */
375
+ readonly now?: () => Date;
376
+ };
377
+ type HandshakeResult = {
378
+ readonly ok: true;
379
+ readonly connection: TlsConnection; /** The group the key exchange actually ran on, which a HelloRetryRequest can change. */
380
+ readonly negotiatedGroup: NamedGroup; /** Whether the server took the offered session, skipping its certificate. */
381
+ readonly isResumed: boolean; /** Whether the server asked for a second ClientHello before answering. */
382
+ readonly isHelloRetryRequested: boolean;
383
+ /**
384
+ * The scheme the server signed its CertificateVerify with — a NAME, like
385
+ * `negotiatedGroup`, because by the time it is set the handshake has
386
+ * already refused every code outside the offered list.
387
+ *
388
+ * A resumed handshake sends no CertificateVerify, so this is restored from
389
+ * the session — it names the signature that authenticated the peer, which
390
+ * on a resumption is the one from the connection that issued the ticket.
391
+ */
392
+ readonly peerSignatureScheme: SignatureScheme;
393
+ /**
394
+ * The pin for the key that authenticated this peer — RFC 7469's base64
395
+ * SHA-256 over the leaf's SubjectPublicKeyInfo — taken from the path the
396
+ * validator vouched for rather than re-derived from the wire.
397
+ *
398
+ * It is HERE, on a completed handshake, and nowhere else on purpose. A
399
+ * pin learned any earlier is a pin learned from bytes that chain to a
400
+ * trusted root and nothing more, which anyone on the path can send; only
401
+ * CertificateVerify and Finished prove the peer HOLDS the key, and both
402
+ * land after the last validation this connection performs. Trust on first
403
+ * use therefore reads this field, and a caller cannot reach it from a
404
+ * handshake that failed.
405
+ *
406
+ * `null` on a resumed handshake with `reverifyOnResume` off — the one
407
+ * configuration in which this connection validated no chain at all, so
408
+ * there is nothing it can honestly report. Every other path sets it,
409
+ * including a resumption that re-checked its stored chain, where the key
410
+ * that authenticated the peer is the stored leaf's.
411
+ */
412
+ readonly peerPublicKeyPin: string | null;
413
+ } | {
414
+ readonly ok: false;
415
+ readonly reason: TlsFailure;
416
+ };
417
+ type TlsConnection = {
418
+ readonly read: () => Promise<TlsReadResult>;
419
+ readonly write: (plaintext: Uint8Array) => Promise<TlsWriteResult>;
420
+ readonly close: () => Promise<TlsCloseResult>;
421
+ /**
422
+ * RFC 9846 §7.5's exporter — key material derived from this connection for
423
+ * something outside it, bound to a label and a context the caller chooses.
424
+ *
425
+ * Channel binding is what YOZZ will want it for: SCRAM-SHA-256-PLUS proves
426
+ * to the mail server that the TLS connection carrying the SASL exchange is
427
+ * the same one the client believes it is on, and `tls-exporter` (RFC 9266) is
428
+ * the TLS 1.3 binding.
429
+ *
430
+ * **`label` may not be empty.** It becomes `HkdfLabel.label` as `"tls13 " +
431
+ * label`, and RFC 9846 §7.1 declares that field `opaque label<7..255>`, so
432
+ * six octets is not encodable. It rejects rather than deriving a key from a
433
+ * struct that could not go on a wire.
434
+ */
435
+ readonly exportKeyingMaterial: (label: string, context: Uint8Array, length: number) => Promise<Uint8Array>;
436
+ };
437
+ type TlsReadResult = {
438
+ readonly ok: true;
439
+ readonly kind: 'data';
440
+ readonly bytes: Uint8Array;
441
+ } | {
442
+ readonly ok: true;
443
+ readonly kind: 'closed';
444
+ } | {
445
+ readonly ok: false;
446
+ readonly reason: TlsFailure;
447
+ };
448
+ type TlsWriteResult = {
449
+ readonly ok: true;
450
+ } | {
451
+ readonly ok: false;
452
+ readonly reason: TlsFailure;
453
+ };
454
+ type TlsCloseResult = {
455
+ readonly ok: true;
456
+ } | {
457
+ readonly ok: false;
458
+ readonly reason: TlsFailure;
459
+ };
460
+ declare const startTls: (options: StartTlsOptions) => Promise<HandshakeResult>;
461
+ //#endregion
462
+ //#region src/pinning.d.ts
463
+ /**
464
+ * RFC 7469 §2.1.1's pin: base64 of SHA-256 over the SubjectPublicKeyInfo DER.
465
+ *
466
+ * A STRING, and that is the load-bearing part. The pin goes into a caller's
467
+ * store, which for YOZZ means it is serialised and read back, and a
468
+ * `Uint8Array` through `JSON.stringify` becomes `{"0":48,"1":89,...}` — an
469
+ * object that revives as something no byte comparison will ever match, so every
470
+ * reconnection reads as a rotation. It is also the format `openssl x509
471
+ * -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary |
472
+ * base64` prints, which is how a user checks a pin out of band.
473
+ */
474
+ declare const publicKeyPin: (subjectPublicKeyInfoDer: Uint8Array) => Promise<string>;
475
+ /**
476
+ * A `Validator` that runs another one and then refuses any chain whose leaf key
477
+ * is not the pinned one.
478
+ *
479
+ * The inner validator runs FIRST and its failure is returned untouched. A pin
480
+ * that matched could otherwise carry an expired or unanchored chain through,
481
+ * which would make pinning a way to weaken validation rather than a check on
482
+ * top of it.
483
+ *
484
+ * A mismatch is `rejected-by-policy`, which `@yozz.app/tls` maps to a
485
+ * `certificate_unknown` alert. It deliberately is not one of the codes that
486
+ * name a property of the chain: the chain is fine. Reporting a rotated key as
487
+ * `no-path-to-trust-anchor` would send a user to their CA over a problem that
488
+ * has nothing to do with one.
489
+ *
490
+ * `pin` is required. There is no null-means-allow-anything mode, because that
491
+ * is a pinned validator that silently never fires — a caller with no pin yet
492
+ * passes the unwrapped validator and learns one from the result.
493
+ *
494
+ * **One per connection**, since a pin belongs to a host and the caller is the
495
+ * one holding the store that maps between them. Reusing an instance across
496
+ * hosts refuses the second one, which is the safe direction and still a bug.
497
+ *
498
+ * ONE pin, not a set. HPKP needed a backup pin to stop a browser bricking a
499
+ * site it could not un-pin; a mismatch here asks the user, who can accept. The
500
+ * case that would need a set is a hostname behind independently keyed
501
+ * frontends, and every IP of all nine stage-3 mail hosts serves one key.
502
+ */
503
+ declare const pinnedValidator: ({
504
+ validator,
505
+ pin
506
+ }: {
507
+ readonly validator: Validator;
508
+ readonly pin: string;
509
+ }) => Validator;
510
+ //#endregion
511
+ export { type Alert, type AlertDescription, type ByteDuplex, CIPHER_SUITES, type CipherSuite, type HandshakeResult, NAMED_GROUPS, type NamedGroup, SIGNATURE_SCHEMES, SUPPORTED_GROUPS, SUPPORTED_SIGNATURE_SCHEMES, type SignatureScheme, type StartTlsOptions, type TlsCloseResult, type TlsConnection, type TlsFailure, type TlsReadResult, type TlsSession, type TlsWriteResult, type TrafficKeys, deriveSecret, earlySecret, finishedKey, handshakeSecret, hkdfExpandLabel, hkdfExtract, isVerifyDataValid, masterSecret, namedGroupFromCode, pinnedValidator, publicKeyPin, signatureSchemeFromCode, startTls, trafficKeys, transcriptHash, verifyData };