Skip to content
File

Blob: src/node/internal/internal_tls.ts

typescript270 lines
1// Copyright (c) 2017-2022 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4//
5// Copyright Joyent, Inc. and other Node contributors.
6//
7// Permission is hereby granted, free of charge, to any person obtaining a
8// copy of this software and associated documentation files (the
9// "Software"), to deal in the Software without restriction, including
10// without limitation the rights to use, copy, modify, merge, publish,
11// distribute, sublicense, and/or sell copies of the Software, and to permit
12// persons to whom the Software is furnished to do so, subject to the
13// following conditions:
14//
15// The above copyright notice and this permission notice shall be included
16// in all copies or substantial portions of the Software.
17//
18// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
19// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
21// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
22// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
23// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
24// USE OR OTHER DEALINGS IN THE SOFTWARE.
25 
26import type { PeerCertificate } from 'node:tls';
27import { isIP } from 'node-internal:internal_net';
28import { default as urlUtil } from 'node-internal:url';
29import {
30 ERR_TLS_CERT_ALTNAME_INVALID,
31 ERR_TLS_CERT_ALTNAME_FORMAT,
32 ERR_OUT_OF_RANGE,
33} from 'node-internal:internal_errors';
34import { isUint8Array, isArrayBufferView } from 'node-internal:internal_types';
35 
36// String#toLowerCase() is locale-sensitive so we use
37// a conservative version that only lowercases A-Z.
38function toLowerCase(c: string): string {
39 return String.fromCharCode(32 + c.charCodeAt(0));
40}
41 
42function unfqdn(host: string): string {
43 return host.replace(/[.]$/, '');
44}
45 
46function splitHost(host: string): string[] {
47 return unfqdn(host).replace(/[A-Z]/g, toLowerCase).split('.');
48}
49 
50function check(
51 hostParts: string[],
52 pattern: string | undefined | null,
53 wildcards: boolean
54): boolean {
55 // Empty strings, null, undefined, etc. never match.
56 if (!pattern) return false;
57 
58 const patternParts = splitHost(pattern);
59 
60 if (hostParts.length !== patternParts.length) return false;
61 
62 // Pattern has empty components, e.g. "bad..example.com".
63 if (patternParts.includes('')) return false;
64 
65 // RFC 6125 allows IDNA U-labels (Unicode) in names but we have no
66 // good way to detect their encoding or normalize them so we simply
67 // reject them. Control characters and blanks are rejected as well
68 // because nothing good can come from accepting them.
69 const isBad = (s: string): boolean => /[^\u0021-\u007F]/u.test(s);
70 if (patternParts.some(isBad)) return false;
71 
72 // Check host parts from right to left first.
73 for (let i = hostParts.length - 1; i > 0; i -= 1) {
74 if (hostParts[i] !== patternParts[i]) return false;
75 }
76 
77 const hostSubdomain = hostParts[0] as string;
78 const patternSubdomain = patternParts[0] as string;
79 const patternSubdomainParts = patternSubdomain.split('*', 3);
80 
81 // Short-circuit when the subdomain does not contain a wildcard.
82 // RFC 6125 does not allow wildcard substitution for components
83 // containing IDNA A-labels (Punycode) so match those verbatim.
84 if (patternSubdomainParts.length === 1 || patternSubdomain.includes('xn--'))
85 return hostSubdomain === patternSubdomain;
86 
87 if (!wildcards) return false;
88 
89 // More than one wildcard is always wrong.
90 if (patternSubdomainParts.length > 2) return false;
91 
92 // *.tld wildcards are not allowed.
93 if (patternParts.length <= 2) return false;
94 
95 const { 0: prefix, 1: suffix } = patternSubdomainParts as [string, string];
96 
97 if (prefix.length + suffix.length > hostSubdomain.length) return false;
98 
99 if (!hostSubdomain.startsWith(prefix)) return false;
100 
101 if (!hostSubdomain.endsWith(suffix)) return false;
102 
103 return true;
104}
105 
106// This pattern is used to determine the length of escaped sequences within
107// the subject alt names string. It allows any valid JSON string literal.
108// This MUST match the JSON specification (ECMA-404 / RFC8259) exactly.
109const jsonStringPattern =
110 // eslint-disable-next-line no-control-regex
111 /^"(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"/;
112 
113function splitEscapedAltNames(altNames: string): string[] {
114 const result = [];
115 let currentToken = '';
116 let offset = 0;
117 while (offset !== altNames.length) {
118 const nextSep = altNames.indexOf(',', offset);
119 const nextQuote = altNames.indexOf('"', offset);
120 if (nextQuote !== -1 && (nextSep === -1 || nextQuote < nextSep)) {
121 // There is a quote character and there is no separator before the quote.
122 currentToken += altNames.substring(offset, nextQuote);
123 const match = jsonStringPattern.exec(altNames.substring(nextQuote));
124 if (!match) {
125 throw new ERR_TLS_CERT_ALTNAME_FORMAT();
126 }
127 currentToken += JSON.parse(match[0]) as string;
128 offset = nextQuote + match[0].length;
129 } else if (nextSep !== -1) {
130 // There is a separator and no quote before it.
131 currentToken += altNames.substring(offset, nextSep);
132 result.push(currentToken);
133 currentToken = '';
134 offset = nextSep + 2;
135 } else {
136 currentToken += altNames.substring(offset);
137 offset = altNames.length;
138 }
139 }
140 result.push(currentToken);
141 return result;
142}
143 
144export function checkServerIdentity(
145 hostname: string,
146 cert: Partial<PeerCertificate>
147): Error | undefined {
148 const subject = cert.subject;
149 const altNames = cert.subjectaltname;
150 const dnsNames: string[] = [];
151 const ips: string[] = [];
152 
153 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-conversion
154 hostname = '' + hostname;
155 
156 if (altNames) {
157 const splitAltNames = altNames.includes('"')
158 ? splitEscapedAltNames(altNames)
159 : altNames.split(', ');
160 splitAltNames.forEach((name) => {
161 if (name.startsWith('DNS:')) {
162 dnsNames.push(name.slice(4));
163 } else if (name.startsWith('IP Address:')) {
164 ips.push(urlUtil.canonicalizeIp(name.slice(11)));
165 }
166 });
167 }
168 
169 let valid = false;
170 let reason = 'Unknown reason';
171 
172 hostname = unfqdn(hostname); // Remove trailing dot for error messages.
173 
174 if (isIP(hostname)) {
175 valid = ips.includes(urlUtil.canonicalizeIp(hostname));
176 if (!valid)
177 reason = `IP: ${hostname} is not in the cert's list: ` + ips.join(', ');
178 } else if (dnsNames.length > 0 || subject?.CN) {
179 const hostParts = splitHost(hostname);
180 const wildcard = (pattern: string): boolean =>
181 check(hostParts, pattern, true);
182 
183 if (dnsNames.length > 0) {
184 valid = dnsNames.some(wildcard);
185 if (!valid)
186 reason = `Host: ${hostname}. is not in the cert's altnames: ${altNames}`;
187 } else {
188 // Match against Common Name only if no supported identifiers exist.
189 const cn = subject?.CN;
190 
191 if (Array.isArray(cn)) valid = cn.some(wildcard);
192 else if (cn) valid = wildcard(cn);
193 
194 if (!valid) reason = `Host: ${hostname}. is not cert's CN: ${cn}`;
195 }
196 } else {
197 reason = 'Cert does not contain a DNS name';
198 }
199 
200 if (!valid) {
201 return new ERR_TLS_CERT_ALTNAME_INVALID(reason, hostname, cert);
202 }
203 return undefined;
204}
205 
206// Convert protocols array into valid OpenSSL protocols list
207// ("\x06spdy/2\x08http/1.1\x08http/1.0")
208function convertProtocols(protocols: string[]): Buffer {
209 const lens = Array.from({ length: protocols.length }, () => 0);
210 const buff = Buffer.allocUnsafe(
211 protocols.reduce((p, c, i) => {
212 const len = Buffer.byteLength(c);
213 if (len > 255) {
214 throw new ERR_OUT_OF_RANGE(
215 'The byte length of the protocol at index ' +
216 `${i} exceeds the maximum length.`,
217 '<= 255',
218 len,
219 true
220 );
221 }
222 lens[i] = len;
223 return p + 1 + len;
224 }, 0)
225 );
226 
227 let offset = 0;
228 for (let i = 0, c = protocols.length; i < c; i++) {
229 buff[offset++] = lens[i] as number;
230 buff.write(protocols[i] as string, offset);
231 offset += lens[i] as number;
232 }
233 
234 return buff;
235}
236 
237export function convertALPNProtocols(
238 protocols: unknown,
239 out: {
240 ALPNProtocols: Buffer;
241 }
242): void {
243 // If protocols is Array - translate it into buffer
244 if (Array.isArray(protocols)) {
245 out.ALPNProtocols = convertProtocols(protocols as string[]);
246 } else if (isUint8Array(protocols)) {
247 // Copy new buffer not to be modified by user.
248 out.ALPNProtocols = Buffer.from(protocols);
249 } else if (isArrayBufferView(protocols)) {
250 out.ALPNProtocols = Buffer.from(
251 protocols.buffer.slice(
252 protocols.byteOffset,
253 protocols.byteOffset + protocols.byteLength
254 )
255 );
256 }
257}
258 
259export function createServer(): void {
260 throw new Error('Not implemented');
261}
262 
263export function Server(): void {
264 throw new Error('Not implemented');
265}
266 
267export function getCiphers(): void {
268 throw new Error('Not implemented');
269}