@yozz.app/tls 0.1.3 → 0.1.5

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 (3) hide show
  1. package/dist/index.d.mts +85 -371
  2. package/dist/index.mjs +285 -1147
  3. package/package.json +7 -7
package/dist/index.d.mts CHANGED
@@ -1,43 +1,20 @@
1
1
  import { PeerName, TrustAnchorSource, ValidationFailure, Validator } from "@yozz.app/x509";
2
-
3
2
  //#region src/wire.d.ts
4
3
  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[];
4
+ export declare const NAMED_GROUPS: Readonly<Record<NamedGroup, number>>;
5
+ /** In offer order; the first carries the key share. P-384 is required: `posteo.de` refuses X25519 and P-256. */
6
+ export declare const SUPPORTED_GROUPS: readonly NamedGroup[];
18
7
  /** The wire code back to the name, `undefined` for a group we do not implement. */
19
- declare const namedGroupFromCode: (code: number) => NamedGroup | undefined;
8
+ export declare const namedGroupFromCode: (code: number) => NamedGroup | undefined;
20
9
  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>>;
10
+ export declare const SIGNATURE_SCHEMES: Readonly<Record<SignatureScheme, number>>;
22
11
  /**
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.
12
+ * The security boundary, not a preference: RFC 9846 §4.5.2 lets a server sign a CertificateVerify
13
+ * only with a scheme offered here. Ed25519 is offered though BoringSSL disables it by default.
37
14
  */
38
- declare const SUPPORTED_SIGNATURE_SCHEMES: readonly SignatureScheme[];
15
+ export declare const SUPPORTED_SIGNATURE_SCHEMES: readonly SignatureScheme[];
39
16
  /** The wire code back to the name, `undefined` for a scheme we do not implement. */
40
- declare const signatureSchemeFromCode: (code: number) => SignatureScheme | undefined;
17
+ export declare const signatureSchemeFromCode: (code: number) => SignatureScheme | undefined;
41
18
  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
19
  //#endregion
43
20
  //#region src/alert.d.ts
@@ -52,15 +29,9 @@ type TlsFailure = {
52
29
  } | {
53
30
  readonly kind: 'alert-received';
54
31
  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
- | {
32
+ } |
33
+ /** RFC 9846 §6: an unknown alert type is an error alert; the code is reported rather than answered. */
34
+ {
64
35
  readonly kind: 'alert-received-unknown';
65
36
  readonly code: number;
66
37
  } | {
@@ -70,210 +41,87 @@ type TlsFailure = {
70
41
  readonly reason: ValidationFailure;
71
42
  readonly alert: Alert;
72
43
  /**
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.
44
+ * `'session-stored'` (re-checked under `reverifyOnResume`) usually means a stale session: evict
45
+ * it and reconnect. `'peer-sent'` means the host presents something we refuse, and a retry
46
+ * achieves nothing.
90
47
  */
91
48
  readonly chain: 'peer-sent' | 'session-stored';
92
49
  };
93
50
  //#endregion
94
51
  //#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
- */
52
+ /** RFC 9846 §7.1. WebCrypto's `HKDF` fuses extract and expand, and TLS needs them apart, so both are HMAC here. */
108
53
  type CipherSuite = 'TLS_AES_128_GCM_SHA256' | 'TLS_AES_256_GCM_SHA384';
109
54
  type CipherSuiteParameters = {
110
- /** The wire code point, RFC 9846 App. B.4. */readonly code: number;
55
+ /** RFC 9846 App. B.4. */
56
+ readonly code: number;
111
57
  readonly hash: 'SHA-256' | 'SHA-384';
112
58
  readonly hashLength: number;
113
- readonly keyLength: number; /** RFC 9846 §5.3: the AEAD nonce, which is 12 octets for both AES-GCM suites. */
59
+ readonly keyLength: number;
60
+ /** RFC 9846 §5.3. */
114
61
  readonly ivLength: number;
115
62
  };
63
+ /** `posteo.de` takes only the second. ChaCha20 is absent: WebCrypto has none. */
64
+ export declare const CIPHER_SUITES: Readonly<Record<CipherSuite, CipherSuiteParameters>>;
65
+ /** RFC 5869 §2.1. WebCrypto refuses a zero-length HMAC key, so an empty salt becomes HashLen zeros. */
66
+ export declare const hkdfExtract: (suite: CipherSuite, salt: Uint8Array, ikm: Uint8Array) => Promise<Uint8Array>;
67
+ /** `async` so a rejected label rejects the promise rather than throwing synchronously. */
68
+ export declare const hkdfExpandLabel: (suite: CipherSuite, secret: Uint8Array, label: string, context: Uint8Array, length: number) => Promise<Uint8Array>;
116
69
  /**
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.
70
+ * RFC 9846 §7.1 `Derive-Secret`, taking MESSAGES and hashing them as the RFC's signature does.
71
+ * Pass nothing for the empty transcript; for an empty context (the `finished` key) use `hkdfExpandLabel`.
127
72
  */
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>;
73
+ export declare const deriveSecret: (suite: CipherSuite, secret: Uint8Array, label: string, ...messages: readonly Uint8Array[]) => Promise<Uint8Array>;
74
+ export declare const transcriptHash: (suite: CipherSuite, ...messages: readonly Uint8Array[]) => Promise<Uint8Array>;
75
+ /** The three Extracts. `Derive-Secret(., "derived", "")` between them hashes the EMPTY transcript. */
76
+ export declare const earlySecret: (suite: CipherSuite, psk?: Uint8Array) => Promise<Uint8Array>;
77
+ export declare const handshakeSecret: (suite: CipherSuite, early: Uint8Array, sharedSecret: Uint8Array) => Promise<Uint8Array>;
78
+ export declare const masterSecret: (suite: CipherSuite, handshake: Uint8Array) => Promise<Uint8Array>;
158
79
  type TrafficKeys = {
159
80
  readonly key: Uint8Array;
160
81
  readonly iv: Uint8Array;
161
82
  };
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>;
83
+ /** RFC 9846 §7.3. */
84
+ export declare const trafficKeys: (suite: CipherSuite, secret: Uint8Array) => Promise<TrafficKeys>;
85
+ /** RFC 9846 §4.5.3. */
86
+ export declare const finishedKey: (suite: CipherSuite, baseKey: Uint8Array) => Promise<Uint8Array>;
87
+ export declare const verifyData: (suite: CipherSuite, key: Uint8Array, transcript: Uint8Array) => Promise<Uint8Array>;
88
+ /** Through `subtle.verify`, which is constant-time. */
89
+ export declare const isVerifyDataValid: (suite: CipherSuite, key: Uint8Array, transcript: Uint8Array, received: Uint8Array) => Promise<boolean>;
174
90
  //#endregion
175
91
  //#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
- */
92
+ /** Split the way `PathValidationRequest` wants it. */
185
93
  type PeerCertificateChain = {
186
94
  readonly leafDer: Uint8Array;
187
95
  readonly intermediateDer: readonly Uint8Array[];
188
96
  };
189
97
  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
- */
98
+ /** Offering a ticket to a different host would tell it where else we have been. */
195
99
  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
- */
100
+ /** The identity checked on the issuing connection; a resumed handshake has no certificate to check a name against. */
209
101
  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. */
102
+ /** The binder runs on this suite's hash; §4.3.11 allows resumption only within the same hash. */
103
+ readonly suite: CipherSuite;
104
+ /** Travels as the PSK identity. */
105
+ readonly ticket: Uint8Array;
106
+ /** §4.7.1's `PSK`, already expanded through the ticket's own nonce. */
107
+ readonly preSharedKey: Uint8Array;
108
+ /** §4.3.11.1's `ticket_age_add`, the server's random offset. */
219
109
  readonly ticketAgeAdd: number;
110
+ /** A real `Date`; a persisted session must revive it. */
220
111
  readonly receivedAt: Date;
221
112
  readonly lifetimeSeconds: number;
222
113
  /**
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.
114
+ * When the peer's certificate was last verified, carried forward unchanged through every renewal:
115
+ * RFC 9846 §4.7.1 asks for a limit on the total lifetime of renewed keying material. A real `Date`.
235
116
  */
236
117
  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
- */
118
+ /** Carried forward through renewals. A name rather than a code point, so it survives the caller's store. */
253
119
  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
- */
120
+ /** Carried forward, so a resumed handshake can re-validate the chain against today's clock and anchors (`reverifyOnResume`). */
270
121
  readonly peerCertificateChain: PeerCertificateChain;
271
122
  };
272
123
  //#endregion
273
124
  //#region src/transport.d.ts
274
- /**
275
- * Duplex byte transport interface and in-memory duplex pair implementation.
276
- */
277
125
  type ByteDuplex = {
278
126
  readonly read: () => Promise<Uint8Array | null>;
279
127
  readonly write: (bytes: Uint8Array) => Promise<void>;
@@ -287,127 +135,42 @@ type StartTlsOptions = {
287
135
  readonly validationTime: Date;
288
136
  readonly validator: Validator;
289
137
  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
- */
138
+ /** In preference order; the first carries the key share. */
294
139
  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
- */
140
+ /** In preference order. RFC 9846 §4.5.2: a CertificateVerify signed with a scheme not offered here is refused. */
304
141
  readonly signatureSchemes?: readonly SignatureScheme[];
305
142
  /**
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.
143
+ * Offer each session once (RFC 9846 App. C.4: reuse lets observers correlate connections): the
144
+ * caller evicts what it passed and keeps what `onSession` hands back. Refused unless `serverName`
145
+ * and `expectedPeerName` match the issuing connection.
323
146
  */
324
147
  readonly session?: TlsSession;
325
148
  /**
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.
149
+ * Whether a resumed handshake re-validates the stored chain against today's clock and anchors.
150
+ * Default `true`. Failure is `{ kind: 'certificate', chain: 'session-stored' }`: evict the
151
+ * session and reconnect.
356
152
  */
357
153
  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
- */
154
+ /** Tickets arrive after the handshake, so this fires from `read()`. Without it, tickets are dropped. */
363
155
  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
- */
156
+ /** The running clock for ticket ages, distinct from `validationTime` (a policy input a caller may freeze). Injectable for BoGo. */
375
157
  readonly now?: () => Date;
376
158
  };
377
159
  type HandshakeResult = {
378
160
  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. */
161
+ readonly connection: TlsConnection;
162
+ /** The group the key exchange actually ran on, which a HelloRetryRequest can change. */
163
+ readonly negotiatedGroup: NamedGroup;
164
+ /** Whether the server took the offered session, skipping its certificate. */
165
+ readonly isResumed: boolean;
166
+ /** Whether the server asked for a second ClientHello before answering. */
382
167
  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
- */
168
+ /** On a resumption, restored from the session. */
392
169
  readonly peerSignatureScheme: SignatureScheme;
393
170
  /**
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.
171
+ * RFC 7469 pin of the key that authenticated this peer, from the validated path. Only a
172
+ * completed handshake has one: earlier, the peer has only sent a public chain. `null` on a
173
+ * resumption with `reverifyOnResume` off.
411
174
  */
412
175
  readonly peerPublicKeyPin: string | null;
413
176
  } | {
@@ -418,20 +181,7 @@ type TlsConnection = {
418
181
  readonly read: () => Promise<TlsReadResult>;
419
182
  readonly write: (plaintext: Uint8Array) => Promise<TlsWriteResult>;
420
183
  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
- */
184
+ /** RFC 9846 §7.5, for channel binding (RFC 9266 `tls-exporter`). `label` may not be empty: `HkdfLabel.label` is `opaque<7..255>`. */
435
185
  readonly exportKeyingMaterial: (label: string, context: Uint8Array, length: number) => Promise<Uint8Array>;
436
186
  };
437
187
  type TlsReadResult = {
@@ -457,55 +207,19 @@ type TlsCloseResult = {
457
207
  readonly ok: false;
458
208
  readonly reason: TlsFailure;
459
209
  };
460
- declare const startTls: (options: StartTlsOptions) => Promise<HandshakeResult>;
210
+ export declare const startTls: (options: StartTlsOptions) => Promise<HandshakeResult>;
461
211
  //#endregion
462
212
  //#region src/pinning.d.ts
213
+ /** RFC 7469 §2.1.1: base64 of SHA-256 over the SPKI DER. A string, so it survives the caller's JSON store. */
214
+ export declare const publicKeyPin: (subjectPublicKeyInfoDer: Uint8Array) => Promise<string>;
463
215
  /**
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.
216
+ * The inner validator runs first and its failure is returned untouched. A mismatch is
217
+ * `rejected-by-policy`: the chain is fine. `pin` is required; a caller without one passes the
218
+ * unwrapped validator. One instance per connection.
502
219
  */
503
- declare const pinnedValidator: ({
504
- validator,
505
- pin
506
- }: {
220
+ export declare const pinnedValidator: ({ validator, pin }: {
507
221
  readonly validator: Validator;
508
222
  readonly pin: string;
509
223
  }) => Validator;
510
224
  //#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 };
225
+ export type { Alert, AlertDescription, ByteDuplex, CipherSuite, HandshakeResult, NamedGroup, SignatureScheme, StartTlsOptions, TlsCloseResult, TlsConnection, TlsFailure, TlsReadResult, TlsSession, TlsWriteResult, TrafficKeys };