@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 +21 -0
- package/README.md +43 -0
- package/dist/index.d.mts +511 -0
- package/dist/index.mjs +3872 -0
- package/package.json +51 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|