@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.
- package/dist/index.d.mts +85 -371
- package/dist/index.mjs +285 -1147
- 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
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
/**
|
|
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;
|
|
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
|
-
*
|
|
118
|
-
*
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
163
|
-
declare const trafficKeys: (suite: CipherSuite, secret: Uint8Array) => Promise<TrafficKeys>;
|
|
164
|
-
/** RFC 9846 §4.5.3.
|
|
165
|
-
declare const finishedKey: (suite: CipherSuite, baseKey: Uint8Array) => Promise<Uint8Array>;
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
224
|
-
*
|
|
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
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
|
327
|
-
*
|
|
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;
|
|
380
|
-
|
|
381
|
-
readonly
|
|
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
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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 {
|
|
225
|
+
export type { Alert, AlertDescription, ByteDuplex, CipherSuite, HandshakeResult, NamedGroup, SignatureScheme, StartTlsOptions, TlsCloseResult, TlsConnection, TlsFailure, TlsReadResult, TlsSession, TlsWriteResult, TrafficKeys };
|