@blamejs/pki 0.8.7 → 0.8.10

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.
@@ -0,0 +1,561 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.identity
6
+ * @since 0.8.8
7
+ * @spec RFC 9525
8
+ * @nav Path validation
9
+ * @title Service identity (RFC 9525)
10
+ *
11
+ * @intro
12
+ * Service identity: whether a certificate presents a name the client was trying to reach.
13
+ * RFC 9525 calls the names the client builds reference identities and the names the
14
+ * certificate carries presented identifiers, and the check is whether any of the first
15
+ * matches any of the second.
16
+ *
17
+ * This is a separate answer from path validation, and RFC 9525 sec. 1.2 says so: it does not
18
+ * supersede certificate validation, and an application needs both. `pki.path.validate` takes
19
+ * `opts.identity` to run the two together and report each.
20
+ *
21
+ * @card
22
+ * | Reference form | Reads | Compared against |
23
+ * |---|---|---|
24
+ * | `"www.example.com"` | DNS-ID | `dNSName` entries, label by label |
25
+ * | `"192.0.2.1"`, `"2001:db8::1"`, a 4- or 16-octet BufferSource | IP-ID | `iPAddress` entries, octet for octet |
26
+ * | `{ type: "srv", service: "_imap", value: "example.com" }` | SRV-ID | an `otherName` carrying an RFC 4985 SRVName |
27
+ * | `{ type: "uri", scheme: "https", value: "www.example.com" }` | URI-ID | the scheme and host of a `uniformResourceIdentifier` |
28
+ */
29
+
30
+ var asn1 = require("./asn1-der");
31
+ var oid = require("./oid");
32
+ var constants = require("./constants");
33
+ var guard = require("./guard-all");
34
+ var intrinsic = require("./guard-intrinsic");
35
+ var frameworkError = require("./framework-error");
36
+ var pkix = require("./schema-pkix");
37
+ var ip = require("./ip-utils");
38
+ var x509 = require("./schema-x509");
39
+
40
+ var IdentityError = frameworkError.IdentityError;
41
+ function E(code, message, cause) { return new IdentityError(code, message, cause); }
42
+
43
+ var NS = pkix.makeNS("identity", IdentityError, oid);
44
+ var OID_SAN = oid.byName("subjectAltName");
45
+ var OID_DNS_SRV = oid.byName("dnsSRV");
46
+ var SAN_DECODER = pkix.certExtensionDecoders(NS).byOid[OID_SAN];
47
+
48
+ var _append = guard.list.append;
49
+ var _lowerAscii = guard.name.lowerAscii;
50
+ var _stripTrailingDot = guard.name.stripTrailingDot;
51
+ var _hostLabels = guard.name.hostLabels;
52
+ var _isFqdnHost = guard.name.isFqdnHost;
53
+ var _uriParts = guard.name.uriParts;
54
+ var _charCodeAt = intrinsic.uncurry(String.prototype.charCodeAt);
55
+ var _strSlice = intrinsic.uncurry(String.prototype.slice);
56
+ var _indexOf = intrinsic.uncurry(String.prototype.indexOf);
57
+ var _isArray = intrinsic.isArray;
58
+
59
+ var MATCH_OPTS = intrinsic.assign(intrinsic.create(null), { wildcards: 1 });
60
+ var REFERENCE_KEYS = intrinsic.assign(intrinsic.create(null), { type: 1, value: 1, service: 1, scheme: 1 });
61
+
62
+ var MAX_LABEL = 63;
63
+ var MAX_HOST = 253;
64
+
65
+ /** @internal Every character of a reference identity is printable ASCII. A byte above 0x7E cannot
66
+ * be compared, because RFC 9525 sec. 6.3 requires a U-label to be converted to an A-label first and
67
+ * this toolkit carries no IDNA implementation; a byte below 0x20 is the CVE-2009-2408 shape the
68
+ * presented side already refuses at decode. The two are separate codes because one is a gap in what
69
+ * the toolkit can do and the other is malformed input. */
70
+ function _assertAsciiReference(s, what) {
71
+ guard.name.assertNoControlBytes(s, E, "identity/bad-reference", what);
72
+ for (var i = 0; i < s.length; i++) {
73
+ var c = _charCodeAt(s, i);
74
+ if (c === 0x7f) throw E("identity/bad-reference", what + " carries a DELETE byte at offset " + i);
75
+ if (c > 0x7e) {
76
+ throw E("identity/unsupported-reference", what + " carries a character outside ASCII at offset " + i +
77
+ ". RFC 9525 sec. 6.3 requires a U-label to be converted to an A-label before it is compared, and this " +
78
+ "toolkit carries no IDNA implementation: pass the name already converted to A-labels");
79
+ }
80
+ }
81
+ }
82
+
83
+ /** @internal A reference identity carries no wildcard. RFC 9525 sec. 6.3 covers a wildcard in a
84
+ * presented identifier only, so one on this side is a caller mistake and not a name. */
85
+ function _assertNoWildcard(s, what) {
86
+ if (_indexOf(s, "*") !== -1) {
87
+ throw E("identity/bad-reference", what + " carries a wildcard character. RFC 9525 sec. 6.3 covers " +
88
+ "wildcards in a presented identifier only, never in a reference identity");
89
+ }
90
+ }
91
+
92
+ /** @internal The domain of a reference identity, normalized the way both sides are normalized: one
93
+ * trailing dot stripped, then held to the label rules so an empty label or a label past 63
94
+ * characters is a caller mistake, not a name that can never match. */
95
+ function _referenceDomain(v, what) {
96
+ if (typeof v !== "string" || v === "") throw E("identity/bad-reference", what + " must be a non-empty string");
97
+ _assertAsciiReference(v, what);
98
+ _assertNoWildcard(v, what);
99
+ var d = _stripTrailingDot(v);
100
+ if (d === "" || d.length > MAX_HOST) {
101
+ throw E("identity/bad-reference", what + " must be a domain name of 1 to " + MAX_HOST + " characters");
102
+ }
103
+ var labels = _hostLabels(d);
104
+ for (var i = 0; i < labels.length; i++) {
105
+ if (labels[i].length === 0 || labels[i].length > MAX_LABEL) {
106
+ throw E("identity/bad-reference", what + " has a label at position " + i + " that is empty or longer than " + MAX_LABEL + " characters");
107
+ }
108
+ }
109
+ return d;
110
+ }
111
+
112
+ /** @internal One reference identity, classified once. RFC 9525 sec. 3 has an IPv4 address read as
113
+ * an IP-ID and not as a name, and sec. 7.4 has the classification carried forward instead of
114
+ * re-derived, so a value that is an address is never also tried as a domain. A record form states
115
+ * its own type, and carries its service type and its domain as ONE value, which is what makes
116
+ * sec. 6.5's prohibition on combining a service from one reference with a domain from another
117
+ * structural instead of a check that could be forgotten. */
118
+ function _reference(r, index) {
119
+ var what = "reference identity [" + index + "]";
120
+ if (guard.bytes.isByteSource(r)) {
121
+ var octets = guard.bytes.snapshotSource(r, IdentityError, "identity/bad-reference", what);
122
+ if (octets.length !== 4 && octets.length !== 16) {
123
+ throw E("identity/bad-reference", what + " is a " + octets.length + "-octet address. RFC 9525 sec. 2 " +
124
+ "has an IP-ID carry exactly 4 octets for IPv4 or 16 for IPv6; an address plus a mask is a name " +
125
+ "constraint rather than a reference identity");
126
+ }
127
+ return { kind: "ip", octets: octets, service: null, text: ip.textFromOctets(octets),
128
+ input: _reported({ type: "ip", value: ip.textFromOctets(octets), octets: octets }) };
129
+ }
130
+ if (typeof r === "string") {
131
+ if (r === "") throw E("identity/bad-reference", what + " must be a non-empty string");
132
+ _assertAsciiReference(r, what);
133
+ if (ip.isIPv4(r) || ip.isIpLiteral(r)) {
134
+ var packed = ip.packIpLiteral(r);
135
+ if (packed === null) throw E("identity/bad-reference", what + " is not a textual IP address this toolkit reads");
136
+ return { kind: "ip", octets: packed, service: null, text: r, input: r };
137
+ }
138
+ return { kind: "dns", domain: _referenceDomain(r, what), service: null, input: r };
139
+ }
140
+ if (!r || typeof r !== "object") {
141
+ throw E("identity/bad-reference", what + " must be a domain name, a textual IP address, a 4- or 16-octet " +
142
+ "BufferSource, or a { type, value } record");
143
+ }
144
+ guard.identifier.assertKnownKeys(r, REFERENCE_KEYS, E, "identity/bad-reference",
145
+ what + " has an unknown key (a reference record is { type, value } plus `service` for srv or `scheme` for uri): ");
146
+ if (r.type === "dns") {
147
+ var dnsDomain = _referenceDomain(r.value, what + " value");
148
+ return { kind: "dns", domain: dnsDomain, service: null, input: _reported({ type: "dns", value: dnsDomain }) };
149
+ }
150
+ if (r.type === "ip") {
151
+ /** @internal The record STATES the type, so the type decides what the value may be.
152
+ * Classifying the value again would let a name inside an `ip` record be compared as a
153
+ * name, which is RFC 9525 sec. 3's consistent classification read backwards: what the
154
+ * caller asked for is an address, so anything else is a caller mistake. */
155
+ var addr = _reference(r.value, index);
156
+ if (addr.kind !== "ip") {
157
+ throw E("identity/bad-reference", what + " of type ip carries " + guard.text.showValue(r.value) +
158
+ ", which is no address. RFC 9525 sec. 3 has an identifier classified once, so a name here is " +
159
+ "a mistake and is never compared as a DNS-ID");
160
+ }
161
+ return { kind: "ip", octets: addr.octets, service: null, text: addr.text,
162
+ input: _reported({ type: "ip", value: addr.text, octets: addr.octets }) };
163
+ }
164
+ if (r.type === "srv") {
165
+ if (typeof r.service !== "string" || r.service === "") throw E("identity/bad-reference", what + " of type srv needs a `service`");
166
+ _assertAsciiReference(r.service, what + " service");
167
+ if (_charCodeAt(r.service, 0) !== 0x5f) {
168
+ throw E("identity/bad-reference", what + " service must open with an underscore. RFC 4985 sec. 2 makes the " +
169
+ "underscore part of the service name rather than a separator, so `_imap` is the value and `imap` is not");
170
+ }
171
+ if (r.service.length < 2) throw E("identity/bad-reference", what + " service must name a service after its underscore");
172
+ var srvDomain = _referenceDomain(r.value, what + " value");
173
+ var srvService = _lowerAscii(r.service);
174
+ return { kind: "srv", domain: srvDomain, service: srvService,
175
+ input: _reported({ type: "srv", service: srvService, value: srvDomain }) };
176
+ }
177
+ if (r.type === "uri") {
178
+ if (typeof r.scheme !== "string" || r.scheme === "") throw E("identity/bad-reference", what + " of type uri needs a `scheme`");
179
+ _assertAsciiReference(r.scheme, what + " scheme");
180
+ if (_indexOf(r.scheme, ":") !== -1) {
181
+ throw E("identity/bad-reference", what + " scheme carries a colon. RFC 3986 sec. 3.1 makes the colon a " +
182
+ "separator rather than part of the scheme");
183
+ }
184
+ var uriDomain = _referenceDomain(r.value, what + " value");
185
+ var uriScheme = _lowerAscii(r.scheme);
186
+ return { kind: "uri", domain: uriDomain, service: uriScheme,
187
+ input: _reported({ type: "uri", scheme: uriScheme, value: uriDomain }) };
188
+ }
189
+ throw E("identity/unsupported-reference", what + " names the type " + guard.text.showValue(r.type) +
190
+ ". The types this verb reads are dns, ip, srv and uri (RFC 9525 sec. 1.3)");
191
+ }
192
+
193
+ /** @internal The caller's reference list, read once and bounded at the door. An empty list is
194
+ * refused, not treated as "match anything", because a check that constrains nothing reads to
195
+ * a caller as a policy in force. */
196
+ /** @internal What the verdict hands back as the matched reference. RFC 9525 sec. 6.6 has the
197
+ * caller use it as the validated identity of the service, so it is built from the values that were
198
+ * checked and frozen, rather than being the caller's own object: a record the caller rewrites
199
+ * afterwards would otherwise name an identity nothing compared. A string is already a value; bytes
200
+ * are the copy taken at the door. */
201
+ function _reported(record) {
202
+ var out = intrinsic.create(null), k;
203
+ for (k in record) {
204
+ if (intrinsic.hasOwn(record, k) && record[k] !== undefined) out[k] = record[k];
205
+ }
206
+ return intrinsic.freeze(out);
207
+ }
208
+
209
+ var _SNAPSHOTS = new WeakSet();
210
+
211
+ /** @internal Whether this list is one `snapshotReferences` produced. The record is kept off the
212
+ * value, so a caller cannot make an arbitrary array pass by writing a field on it. */
213
+ function _isSnapshot(v) {
214
+ return _isArray(v) && _SNAPSHOTS.has(v);
215
+ }
216
+
217
+ /** @internal The reference identities, read and copied in one pass, so a caller that mutates its
218
+ * array or its records afterwards changes nothing the verdict answers for. A verb with awaits
219
+ * between the call and the comparison calls this in its synchronous prologue and passes the
220
+ * result to `match`, which is what makes the policy the verdict answers for the policy the call
221
+ * was made with. */
222
+ function snapshotReferences(references) {
223
+ if (_isSnapshot(references)) return references;
224
+ var parsed = _references(references);
225
+ /** @internal Every part of a settled reference is frozen, the list and each record alike, so
226
+ * what the comparison reads is what the door checked. The octets are held as a frozen list of
227
+ * numbers rather than a Buffer, because a Buffer's elements stay writable however the object
228
+ * around them is sealed, and an address rewritten after the door would be compared against
229
+ * without having been checked. */
230
+ for (var i = 0; i < parsed.length; i++) {
231
+ if (parsed[i].octets) parsed[i].octets = _frozenOctets(parsed[i].octets);
232
+ intrinsic.freeze(parsed[i]);
233
+ }
234
+ intrinsic.freeze(parsed);
235
+ _SNAPSHOTS.add(parsed);
236
+ return parsed;
237
+ }
238
+
239
+ function _frozenOctets(buf) {
240
+ var out = [];
241
+ for (var i = 0; i < buf.length; i++) _append(out, buf[i]);
242
+ return intrinsic.freeze(out);
243
+ }
244
+
245
+ function _references(references) {
246
+ var list = _isArray(references) ? references : [references];
247
+ if (list.length === 0) {
248
+ throw E("identity/bad-input", "the reference identity list is empty. A list that constrains nothing would " +
249
+ "read as an identity check that passed; pass at least one name, address or record");
250
+ }
251
+ if (list.length > constants.LIMITS.IDENTITY_MAX_REFERENCES) {
252
+ throw E("identity/too-many-references", "the reference identity list holds " + list.length +
253
+ " entries and at most " + constants.LIMITS.IDENTITY_MAX_REFERENCES + " are read");
254
+ }
255
+ var out = [];
256
+ for (var i = 0; i < list.length; i++) _append(out, _reference(list[i], i));
257
+ return out;
258
+ }
259
+
260
+ /** @internal Whether a presented `dNSName` is shaped like a name at all. This is deliberately not
261
+ * `guard.name.isFqdnHost` and not the certificate-quality test `lint` applies: RFC 9525 says nothing
262
+ * about an underscore label, and a name this check refuses is a name that can never match rather
263
+ * than a certificate fault. Only the structure the label comparison needs is required. */
264
+ function _presentedNameShape(s) {
265
+ if (s.length === 0 || s.length > MAX_HOST) return false;
266
+ var labels = _hostLabels(s);
267
+ for (var i = 0; i < labels.length; i++) {
268
+ if (labels[i].length === 0 || labels[i].length > MAX_LABEL) return false;
269
+ }
270
+ return true;
271
+ }
272
+
273
+ /** @internal Whether a presented name carries a wildcard RFC 9525 sec. 6.3 admits: exactly one
274
+ * wildcard character, and that character the complete content of the left-most label. A name
275
+ * carrying a wildcard that fails either test is invalid and is IGNORED rather than refused, which
276
+ * sec. 6.3 states and which is why this answers three ways. */
277
+ function _wildcardShape(labels) {
278
+ var total = 0, i, j;
279
+ for (i = 0; i < labels.length; i++) {
280
+ for (j = 0; j < labels[i].length; j++) if (_charCodeAt(labels[i], j) === 0x2a) total += 1;
281
+ }
282
+ if (total === 0) return "none";
283
+ if (total > 1) return "invalid";
284
+ if (labels.length < 2) return "invalid";
285
+ return labels[0] === "*" ? "valid" : "invalid";
286
+ }
287
+
288
+ /** @internal Two names compared label by label (RFC 9525 sec. 6.3): equal label counts, every label
289
+ * equal under an ASCII-only case fold. A wildcard in the left-most presented label matches exactly
290
+ * one reference label, which is where this differs from the suffix relation a name constraint uses. */
291
+ function _dnsMatch(presented, referenceDomain, wildcardsAllowed) {
292
+ var p = _stripTrailingDot(presented);
293
+ if (!_presentedNameShape(p)) return { disposition: "ignored", reason: "the presented name is not shaped like a domain name" };
294
+ var pl = _hostLabels(p), rl = _hostLabels(referenceDomain);
295
+ var shape = _wildcardShape(pl);
296
+ if (shape === "invalid") {
297
+ return { disposition: "ignored", reason: "the presented name carries a wildcard that is not the whole of its left-most label, or carries more than one (RFC 9525 sec. 6.3)" };
298
+ }
299
+ if (shape === "valid") {
300
+ if (!wildcardsAllowed) return { disposition: "ignored", reason: "wildcard matching is off for this call" };
301
+ if (pl.length !== rl.length) return { disposition: "no-match" };
302
+ for (var w = 1; w < pl.length; w++) {
303
+ if (_lowerAscii(pl[w]) !== _lowerAscii(rl[w])) return { disposition: "no-match" };
304
+ }
305
+ return rl[0].length === 0 ? { disposition: "no-match" } : { disposition: "match" };
306
+ }
307
+ if (pl.length !== rl.length) return { disposition: "no-match" };
308
+ for (var i = 0; i < pl.length; i++) {
309
+ if (_lowerAscii(pl[i]) !== _lowerAscii(rl[i])) return { disposition: "no-match" };
310
+ }
311
+ return { disposition: "match" };
312
+ }
313
+
314
+ /** @internal RFC 9525 sec. 6.4 compares an IP-ID octet for octet. A length difference is a
315
+ * non-match, not a truncated comparison, because length is the only thing that tells the two
316
+ * address versions apart (RFC 9110 sec. 4.3.5). */
317
+ function _ipMatch(refOctets, entryOctets) {
318
+ if (refOctets.length !== entryOctets.length) return false;
319
+ var same = 0;
320
+ for (var i = 0; i < refOctets.length; i++) same |= refOctets[i] ^ entryOctets[i];
321
+ return same === 0;
322
+ }
323
+
324
+ /** @internal The `_Service.Name` an RFC 4985 SRVName carries. The value arrives as the inner
325
+ * element's own DER, so it is decoded here with its own universal-tag, primitive and printable
326
+ * checks, not trusted because the OID said what it would be. */
327
+ function _srvName(entryValue) {
328
+ var node;
329
+ try { node = asn1.decode(entryValue.valueBytes); }
330
+ catch (e) { return { reason: "the SRVName value is not valid DER", cause: e }; }
331
+ if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.IA5_STRING) {
332
+ return { reason: "the SRVName value is not an IA5String (RFC 4985 sec. 2)" };
333
+ }
334
+ if (node.constructed) return { reason: "the SRVName value is a constructed IA5String" };
335
+ var content = node.content;
336
+ if (!content || content.length === 0) return { reason: "the SRVName value is empty, and RFC 4985 sec. 2 writes SIZE (1..MAX)" };
337
+ try { guard.name.assertPrintableIa5(content, E, "identity/bad-reference", "the SRVName value"); }
338
+ catch (e) { return { reason: "the SRVName value carries a byte outside printable ASCII", cause: e }; }
339
+ var text = intrinsic.bufToString(content, "latin1");
340
+ if (_charCodeAt(text, 0) !== 0x5f) {
341
+ return { reason: "the SRVName does not open with an underscore, so it names no service (RFC 4985 sec. 2)" };
342
+ }
343
+ var dot = _indexOf(text, ".");
344
+ if (dot < 2 || dot === text.length - 1) {
345
+ return { reason: "the SRVName is not _Service.Name with both parts present (RFC 4985 sec. 2)" };
346
+ }
347
+ return { service: _strSlice(text, 0, dot), domain: _strSlice(text, dot + 1) };
348
+ }
349
+
350
+ /** @internal The scheme and host a URI-ID presents. RFC 9525 sec. 7.2 uses an entry only when it
351
+ * carries both, and requires an entry missing either to be IGNORED with the search continuing.
352
+ * Sec. 6.2 narrows the host to the `reg-name` rule, which excludes an IP literal, so a bracketed
353
+ * address is not a URI-ID. A percent-escape is not decoded: decoding one here would reintroduce the
354
+ * byte the IA5 assertion refused at the certificate's door. */
355
+ function _uriId(text) {
356
+ var parts = _uriParts(text);
357
+ if (parts === null) return { reason: "the presented URI has no scheme (RFC 3986 sec. 3.1)" };
358
+ if (!parts.hasAuthority || parts.host === null) {
359
+ return { reason: "the presented URI carries no host, and RFC 9525 sec. 7.2 requires an entry without both a scheme and a host to be ignored" };
360
+ }
361
+ if (parts.bracketed) {
362
+ return { reason: "the presented URI names an IP literal, and RFC 9525 sec. 6.2 narrows a URI-ID host to the reg-name rule" };
363
+ }
364
+ if (_indexOf(parts.host, "%") !== -1) {
365
+ return { reason: "the presented URI host carries a percent-escape, which is not decoded before comparison" };
366
+ }
367
+ /** @internal A wildcard belongs to a presented identifier (sec. 6.3) and not to one entry
368
+ * form, so a URI-ID host carrying one reaches the same comparison a `dNSName` does. The
369
+ * host is still narrowed to a reg-name that is not an address, which is what sec. 6.2 asks
370
+ * of it, so the wildcard label is set aside for that test and the rest is held to it. */
371
+ var bare = _charCodeAt(parts.host, 0) === 0x2a && _charCodeAt(parts.host, 1) === 0x2e
372
+ ? _strSlice(parts.host, 2) : parts.host;
373
+ if (!_isFqdnHost(bare)) {
374
+ return { reason: "the presented URI host is not a domain name (RFC 9525 sec. 6.2 narrows it to the reg-name rule, which excludes an address)" };
375
+ }
376
+ return { scheme: _lowerAscii(parts.scheme), domain: parts.host };
377
+ }
378
+
379
+ /** @internal The identifiers a certificate presents, read from subjectAltName and from nowhere
380
+ * else. RFC 9525 sec. 2 forbids the subject commonName and every other RDN as a source of identity,
381
+ * so this reads the extension and never the subject. */
382
+ function _presented(cert) {
383
+ var ext = null;
384
+ for (var i = 0; i < cert.extensions.length; i++) {
385
+ if (cert.extensions[i].oid === OID_SAN) { ext = cert.extensions[i]; break; }
386
+ }
387
+ if (ext === null) return [];
388
+ var decoded = SAN_DECODER(ext.value);
389
+ var names = decoded.names || [];
390
+ if (names.length > constants.LIMITS.SAN_MAX_ENTRIES) {
391
+ throw E("identity/too-many-names", "the certificate presents " + names.length +
392
+ " subjectAltName entries and at most " + constants.LIMITS.SAN_MAX_ENTRIES + " are read");
393
+ }
394
+ return names;
395
+ }
396
+
397
+ function _entryLabel(entry) {
398
+ if (entry.tagNumber === 2) return "dNSName " + guard.text.showValue(entry.value);
399
+ if (entry.tagNumber === 6) return "uniformResourceIdentifier " + guard.text.showValue(entry.value);
400
+ if (entry.tagNumber === 7) return "iPAddress " + guard.text.showValue(ip.textFromOctets(entry.value));
401
+ if (entry.tagNumber === 0) return "otherName " + guard.text.showValue(entry.value && entry.value.typeId);
402
+ return "GeneralName [" + entry.tagNumber + "]";
403
+ }
404
+
405
+ /** @internal One presented entry against one reference identity. The dispatch is on the GeneralName
406
+ * tag the decoder already assigned, which is total over the CHOICE, and each arm answers `match`,
407
+ * `no-match`, or `ignored` with the reason it could not be read. A reference that names a service
408
+ * type carries its domain with it, so no arm can pair a service from one reference with a domain
409
+ * from another. */
410
+ function _compare(ref, entry, wildcardsAllowed) {
411
+ var t = entry.tagNumber;
412
+ if (ref.kind === "ip") {
413
+ if (t !== 7) return { disposition: "no-match" };
414
+ return { disposition: _ipMatch(ref.octets, entry.value) ? "match" : "no-match" };
415
+ }
416
+ if (ref.kind === "dns") {
417
+ if (t !== 2) return { disposition: "no-match" };
418
+ return _dnsMatch(entry.value, ref.domain, wildcardsAllowed);
419
+ }
420
+ if (ref.kind === "srv") {
421
+ if (t !== 0) return { disposition: "no-match" };
422
+ if (!entry.value || entry.value.typeId !== OID_DNS_SRV) return { disposition: "no-match" };
423
+ var srv = _srvName(entry.value);
424
+ if (srv.reason) return { disposition: "ignored", reason: srv.reason };
425
+ if (_lowerAscii(srv.service) !== ref.service) return { disposition: "no-match" };
426
+ return _dnsMatch(srv.domain, ref.domain, wildcardsAllowed);
427
+ }
428
+ if (t !== 6) return { disposition: "no-match" };
429
+ var uriId = _uriId(entry.value);
430
+ if (uriId.reason) return { disposition: "ignored", reason: uriId.reason };
431
+ if (uriId.scheme !== ref.service) return { disposition: "no-match" };
432
+ return _dnsMatch(uriId.domain, ref.domain, wildcardsAllowed);
433
+ }
434
+
435
+ /** @internal The search RFC 9525 sec. 6.2 describes: every reference against every presented
436
+ * identifier, stopping at the first match, and failing only once the cross product is exhausted.
437
+ * One counter bounds the pairs, not the two lists independently, because the work is the
438
+ * product. */
439
+ function _search(refs, entries, wildcardsAllowed) {
440
+ var pairs = guard.limits.counter(constants.LIMITS.SAN_MAX_ENTRIES * constants.LIMITS.IDENTITY_MAX_REFERENCES,
441
+ E, "identity/too-many-names", "identity comparison");
442
+ var ignored = [], seenIgnored = intrinsic.create(null);
443
+ for (var r = 0; r < refs.length; r++) {
444
+ for (var e = 0; e < entries.length; e++) {
445
+ pairs.tick();
446
+ var got = _compare(refs[r], entries[e], wildcardsAllowed);
447
+ if (got.disposition === "match") {
448
+ return { matched: true, matchedReference: refs[r].input, matchedEntry: _entryLabel(entries[e]),
449
+ ignored: ignored, reason: null };
450
+ }
451
+ if (got.disposition === "ignored") {
452
+ var key = e + "|" + got.reason;
453
+ if (!intrinsic.hasOwn(seenIgnored, key)) {
454
+ seenIgnored[key] = 1;
455
+ _append(ignored, { entry: _entryLabel(entries[e]), reason: got.reason });
456
+ }
457
+ }
458
+ }
459
+ }
460
+ return { matched: false, matchedReference: null, matchedEntry: null, ignored: ignored,
461
+ reason: entries.length === 0
462
+ ? "the certificate presents no subjectAltName entry, and RFC 9525 sec. 2 forbids reading an identity from the subject"
463
+ : "no presented identifier matched a reference identity" };
464
+ }
465
+
466
+ /**
467
+ * @primitive pki.identity.match
468
+ * @signature pki.identity.match(cert, references, opts?) -> verdict
469
+ * @since 0.8.8
470
+ * @status stable
471
+ * @spec RFC 9525, RFC 4985
472
+ * @related pki.path.validate, pki.schema.x509.parse
473
+ *
474
+ * Check a certificate against the names a client expected to reach, which RFC 9525 calls
475
+ * reference identities. This answers a different question from path validation and stands in
476
+ * for none of it: a certificate that matches here may still chain to nothing, and
477
+ * `pki.path.validate` is what says whether it chains.
478
+ *
479
+ * `references` is one reference identity or an array of them, and the caller builds that list
480
+ * from what it was trying to reach rather than from the certificate (sec. 6.1.1). Four forms
481
+ * are read. A domain name, `"www.example.com"`, is a DNS-ID. A textual IP address,
482
+ * `"192.0.2.1"` or `"2001:db8::1"`, or a 4- or 16-octet `BufferSource`, is an IP-ID. A record
483
+ * `{ type: "srv", service: "_imap", value: "example.com" }` is an SRV-ID, where the leading
484
+ * underscore is part of the service name (RFC 4985 sec. 2). A record
485
+ * `{ type: "uri", scheme: "https", value: "www.example.com" }` is a URI-ID. A record carries
486
+ * its service type and its domain together, so no comparison can pair the service of one
487
+ * reference with the domain of another (sec. 6.5).
488
+ *
489
+ * The subject distinguished name is never a source of identity. The `commonName` is not read
490
+ * under any option or fallback, including when the certificate carries no subjectAltName at
491
+ * all, and neither is any other RDN (sec. 2, RFC 9110 sec. 4.3.4). A certificate presenting
492
+ * nothing this check reads fails with `reason` naming that, never with an absent field.
493
+ *
494
+ * An IP-ID is compared octet for octet against an `iPAddress` entry and never against a
495
+ * `dNSName` carrying the same address as text, and no prefix or mask applies (sec. 6.2,
496
+ * sec. 6.4). `::ffff:192.0.2.1` and `192.0.2.1` pack to sixteen and four octets, so under that
497
+ * comparison they do not match.
498
+ *
499
+ * A name is compared label by label under an ASCII-only case fold, with one trailing dot
500
+ * normalized on both sides. An A-label is compared as the ASCII it is and is not decoded
501
+ * (sec. 7.3), so a reference identity carrying a character outside ASCII is refused with
502
+ * `identity/unsupported-reference`: RFC 9525 sec. 6.3 requires a U-label to be converted first
503
+ * and this toolkit carries no IDNA implementation. Pass a name already in A-labels.
504
+ *
505
+ * Wildcards are ON, and `opts.wildcards: false` turns them off, which sec. 3 requires a
506
+ * specification to state either way. A wildcard matches when it is the complete content of the
507
+ * left-most label and there is exactly one of them, and it reaches exactly one label:
508
+ * `*.example.com` matches `foo.example.com`, and matches neither `example.com` nor
509
+ * `a.b.example.com`. A presented name carrying a wildcard that fails those rules is ignored
510
+ * and the search continues, which is what sec. 6.3 directs; it does not refuse the
511
+ * certificate. A wildcard in a reference identity is a caller mistake and is refused.
512
+ *
513
+ * On a match the verdict's `matchedReference` is what sec. 6.6 has the caller use as the
514
+ * validated identity of the service. A reference given as a string comes back as that string; a
515
+ * record or a `BufferSource` comes back as a frozen `{ type, value, ... }` built from the values
516
+ * that were compared, with an address in its textual form, so what the verdict names is the
517
+ * identity that was checked rather than an object the caller can still write to.
518
+ *
519
+ * Protection against a wildcard that spans an administrative boundary, such as `*.co.uk`, is
520
+ * out of scope here as it is in sec. 7.1: this toolkit ships no public-suffix list.
521
+ *
522
+ * A name constraint and an identity check read the same subjectAltName entries to answer
523
+ * different questions, and neither is evidence for the other. A `dNSName` name constraint on
524
+ * an issuer says nothing about whether an SRV-ID or a URI-ID in a leaf is constrained
525
+ * (sec. 7.6); a client wanting that correspondence layers it on top of both.
526
+ *
527
+ * @opts wildcards Whether a presented identifier may carry a wildcard (RFC 9525 sec. 6.3),
528
+ * default `true`; `false` puts every wildcard entry in `ignored`.
529
+ * @example
530
+ * var pair = await pki.key.generate("Ed25519");
531
+ * var der = await pki.x509.sign({ subject: "example.com", subjectPublicKey: await pki.key.export(pair.publicKey),
532
+ * notBefore: new Date("2026-01-01T00:00:00Z"), notAfter: new Date("2036-01-01T00:00:00Z"),
533
+ * extensions: { subjectAltName: [{ dNSName: "www.example.com" }] } },
534
+ * { key: await pki.key.export(pair.privateKey) });
535
+ * var v = pki.identity.match(der, "www.example.com");
536
+ * console.log(v.matched, v.matchedReference);
537
+ */
538
+ function match(cert, references, opts) {
539
+ opts = guard.identifier.optionsObject(opts, E, "identity/bad-input", "match: opts");
540
+ guard.identifier.assertKnownKeys(opts, MATCH_OPTS, E, "identity/bad-input",
541
+ "pki.identity.match has an unknown option (the only option is `wildcards`). The unknown option was: ");
542
+ var snap = guard.identifier.snapshotOptions(opts, MATCH_OPTS);
543
+ if (snap.wildcards !== undefined && typeof snap.wildcards !== "boolean") {
544
+ throw E("identity/bad-input", "match: opts.wildcards must be a boolean");
545
+ }
546
+ var wildcardsAllowed = snap.wildcards === undefined ? true : snap.wildcards;
547
+ var parsed = guard.parsed.acceptDerived(cert, "certificate", x509.parse, E, "identity/bad-input", "a certificate");
548
+ var refs = _isSnapshot(references) ? references : snapshotReferences(references);
549
+ var entries = _presented(parsed);
550
+ var found = _search(refs, entries, wildcardsAllowed);
551
+ return guard.verdict.of({
552
+ matched: found.matched,
553
+ matchedReference: found.matchedReference,
554
+ matchedEntry: found.matchedEntry,
555
+ presented: entries.length,
556
+ ignored: found.ignored,
557
+ reason: found.reason,
558
+ });
559
+ }
560
+
561
+ module.exports = intrinsic.freeze({ match: match, snapshotReferences: snapshotReferences });
package/lib/ip-utils.js CHANGED
@@ -102,4 +102,55 @@ function packIpLiteral(s) {
102
102
  return hex === null ? null : _bufferFrom(hex, "hex");
103
103
  }
104
104
 
105
- module.exports = { isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral };
105
+ /** @internal The RFC 5952 sec. 4 textual form of an IPv6 address given as eight group values: each
106
+ * group written in lower-case hexadecimal with no leading zeros, and the longest run of two or more
107
+ * zero groups replaced by `::`. A run of one is written out, which sec. 4.2.2 requires. */
108
+ function _ipv6Text(nums) {
109
+ var hex = [], i;
110
+ for (i = 0; i < 8; i += 1) _push(hex, _numToString(nums[i], 16));
111
+ var bestStart = -1, bestLen = 0, curStart = -1, curLen = 0;
112
+ for (i = 0; i < 8; i += 1) {
113
+ if (nums[i] === 0) {
114
+ if (curStart === -1) curStart = i;
115
+ curLen += 1;
116
+ if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
117
+ } else { curStart = -1; curLen = 0; }
118
+ }
119
+ if (bestLen < 2) return _joinOn(hex, 0, 8, ":");
120
+ return _joinOn(hex, 0, bestStart, ":") + "::" + _joinOn(hex, bestStart + bestLen, 8, ":");
121
+ }
122
+
123
+ function _joinOn(list, from, to, sep) {
124
+ var out = "";
125
+ for (var i = from; i < to; i += 1) out += (i > from ? sep : "") + list[i];
126
+ return out;
127
+ }
128
+
129
+ /** @internal The textual address an `iPAddress` entry's octets carry: dotted-quad for four octets
130
+ * and the RFC 5952 form for sixteen. `null` for any other length, because no IP version is defined
131
+ * for one (RFC 9110 sec. 4.3.5). The inverse of `packIpLiteral` for every value it produces. */
132
+ function textFromOctets(buf) {
133
+ if (!buf || typeof buf.length !== "number") return null;
134
+ if (buf.length === 4) return buf[0] + "." + buf[1] + "." + buf[2] + "." + buf[3];
135
+ if (buf.length !== 16) return null;
136
+ var nums = [];
137
+ for (var i = 0; i < 16; i += 2) _push(nums, (buf[i] << 8) | buf[i + 1]);
138
+ return _ipv6Text(nums);
139
+ }
140
+
141
+ /** @internal The RFC 5952 canonical form of an IPv6 address written as text, or `null` when the
142
+ * text is not eight groups. A caller comparing its input against the result learns whether the
143
+ * input was already canonical. */
144
+ function canonicalizeIpv6(value) {
145
+ if (typeof value !== "string") return null;
146
+ var hex = expandIpv6Hex(value);
147
+ if (hex === null) return null;
148
+ var nums = [];
149
+ for (var i = 0; i < 8; i += 1) _push(nums, _Number("0x" + _slice(hex, i * 4, i * 4 + 4)));
150
+ return _ipv6Text(nums);
151
+ }
152
+
153
+ module.exports = {
154
+ isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral,
155
+ textFromOctets: textFromOctets, canonicalizeIpv6: canonicalizeIpv6,
156
+ };