@ondewo/sip-client-nodejs 5.4.0 → 5.4.2
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/README.md +94 -0
- package/auth/grpcChannel.d.ts +146 -0
- package/auth/grpcChannel.js +346 -0
- package/auth/grpcChannel.spec.ts +747 -0
- package/auth/grpcChannel.ts +408 -0
- package/auth/offlineTokenProvider.d.ts +9 -5
- package/auth/offlineTokenProvider.js +86 -100
- package/package.json +1 -1
- package/public-api.d.ts +4 -2
- package/public-api.js +16 -6
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// Copyright 2021-2026 ONDEWO GmbH
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Tests of auth/grpcChannel.ts: the TLS / mutual-TLS contract shared by every ONDEWO SDK, with real
|
|
16
|
+
// handshakes against an in-process @grpc/grpc-js server. The test PKI (two unrelated CAs, a server
|
|
17
|
+
// certificate for localhost / 127.0.0.1 / ::1, client certificates) is generated with the openssl
|
|
18
|
+
// CLI into a temporary directory at test time and deleted afterwards; no key is ever committed.
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'assert';
|
|
21
|
+
import { execFileSync } from 'child_process';
|
|
22
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
23
|
+
import * as net from 'net';
|
|
24
|
+
import { tmpdir } from 'os';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { inspect } from 'util';
|
|
27
|
+
import { after, before, describe, it, mock, Mock } from 'node:test';
|
|
28
|
+
|
|
29
|
+
import * as grpc from '@grpc/grpc-js';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createChannelCredentials,
|
|
33
|
+
createGrpcClient,
|
|
34
|
+
DEFAULT_GRPC_CHANNEL_OPTIONS,
|
|
35
|
+
GrpcChannelLogger,
|
|
36
|
+
GrpcClientConfig,
|
|
37
|
+
GrpcClientConfigFields,
|
|
38
|
+
hostAndPort,
|
|
39
|
+
MAX_MESSAGE_LENGTH,
|
|
40
|
+
REDACTED
|
|
41
|
+
} from './grpcChannel';
|
|
42
|
+
|
|
43
|
+
/** PEMs of the test PKI, read back from the openssl output. */
|
|
44
|
+
interface TestPki {
|
|
45
|
+
caCert: string;
|
|
46
|
+
serverCert: string;
|
|
47
|
+
serverKey: string;
|
|
48
|
+
clientCert: string;
|
|
49
|
+
clientKey: string;
|
|
50
|
+
otherCaCert: string;
|
|
51
|
+
otherClientCert: string;
|
|
52
|
+
otherClientKey: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Outcome of one unary call: `null` on success, the gRPC error otherwise. */
|
|
56
|
+
type CallOutcome = grpc.ServiceError | null;
|
|
57
|
+
|
|
58
|
+
/** Recording logger. */
|
|
59
|
+
interface RecordingLogger extends GrpcChannelLogger {
|
|
60
|
+
messages: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PEM_MARKER: string = '-----BEGIN';
|
|
64
|
+
const NOT_REALLY_PEM: string = '-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n';
|
|
65
|
+
const PING_PATH: string = '/ondewo.test.TlsProbe/Ping';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Generate the test PKI with the openssl CLI.
|
|
69
|
+
*
|
|
70
|
+
* @param dir - Scratch directory for the key and certificate files.
|
|
71
|
+
* @returns The PEM contents.
|
|
72
|
+
*/
|
|
73
|
+
function generatePki(dir: string): TestPki {
|
|
74
|
+
const openssl: (...args: string[]) => void = (...args: string[]): void => {
|
|
75
|
+
execFileSync('openssl', args, { cwd: dir, stdio: 'pipe' });
|
|
76
|
+
};
|
|
77
|
+
const newKeyArgs: string[] = ['-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes'];
|
|
78
|
+
const makeCa: (name: string) => void = (name: string): void => {
|
|
79
|
+
openssl(
|
|
80
|
+
'req',
|
|
81
|
+
'-x509',
|
|
82
|
+
...newKeyArgs,
|
|
83
|
+
'-days',
|
|
84
|
+
'2',
|
|
85
|
+
'-subj',
|
|
86
|
+
`/CN=${name}`,
|
|
87
|
+
'-keyout',
|
|
88
|
+
`${name}.key`,
|
|
89
|
+
'-out',
|
|
90
|
+
`${name}.pem`
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
const makeLeaf: (name: string, ca: string, extensions: string) => void = (
|
|
94
|
+
name: string,
|
|
95
|
+
ca: string,
|
|
96
|
+
extensions: string
|
|
97
|
+
): void => {
|
|
98
|
+
writeFileSync(join(dir, `${name}.ext`), extensions);
|
|
99
|
+
openssl('req', ...newKeyArgs, '-subj', `/CN=${name}`, '-keyout', `${name}.key`, '-out', `${name}.csr`);
|
|
100
|
+
openssl(
|
|
101
|
+
'x509',
|
|
102
|
+
'-req',
|
|
103
|
+
'-in',
|
|
104
|
+
`${name}.csr`,
|
|
105
|
+
'-CA',
|
|
106
|
+
`${ca}.pem`,
|
|
107
|
+
'-CAkey',
|
|
108
|
+
`${ca}.key`,
|
|
109
|
+
'-CAcreateserial',
|
|
110
|
+
'-days',
|
|
111
|
+
'2',
|
|
112
|
+
'-extfile',
|
|
113
|
+
`${name}.ext`,
|
|
114
|
+
'-out',
|
|
115
|
+
`${name}.pem`
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
makeCa('ca');
|
|
119
|
+
makeCa('otherca');
|
|
120
|
+
makeLeaf('server', 'ca', 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\nextendedKeyUsage=serverAuth\n');
|
|
121
|
+
makeLeaf('client', 'ca', 'extendedKeyUsage=clientAuth\n');
|
|
122
|
+
makeLeaf('otherclient', 'otherca', 'extendedKeyUsage=clientAuth\n');
|
|
123
|
+
const read: (file: string) => string = (file: string): string => readFileSync(join(dir, file), 'utf8');
|
|
124
|
+
return {
|
|
125
|
+
caCert: read('ca.pem'),
|
|
126
|
+
serverCert: read('server.pem'),
|
|
127
|
+
serverKey: read('server.key'),
|
|
128
|
+
clientCert: read('client.pem'),
|
|
129
|
+
clientKey: read('client.key'),
|
|
130
|
+
otherCaCert: read('otherca.pem'),
|
|
131
|
+
otherClientCert: read('otherclient.pem'),
|
|
132
|
+
otherClientKey: read('otherclient.key')
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Start an in-process gRPC server answering `Ping`.
|
|
138
|
+
*
|
|
139
|
+
* @param host - Address to bind (`127.0.0.1`, `[::1]`).
|
|
140
|
+
* @param credentials - Server credentials (TLS, mutual TLS or insecure).
|
|
141
|
+
* @returns The server and its port.
|
|
142
|
+
*/
|
|
143
|
+
async function startServer(
|
|
144
|
+
host: string,
|
|
145
|
+
credentials: grpc.ServerCredentials
|
|
146
|
+
): Promise<{ server: grpc.Server; port: number }> {
|
|
147
|
+
const server: grpc.Server = new grpc.Server();
|
|
148
|
+
const identity: (value: Buffer) => Buffer = (value: Buffer): Buffer => value;
|
|
149
|
+
server.addService(
|
|
150
|
+
{
|
|
151
|
+
Ping: {
|
|
152
|
+
path: PING_PATH,
|
|
153
|
+
requestStream: false,
|
|
154
|
+
responseStream: false,
|
|
155
|
+
requestSerialize: identity,
|
|
156
|
+
requestDeserialize: identity,
|
|
157
|
+
responseSerialize: identity,
|
|
158
|
+
responseDeserialize: identity
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
Ping: (_call: grpc.ServerUnaryCall<Buffer, Buffer>, done: grpc.sendUnaryData<Buffer>): void => {
|
|
163
|
+
done(null, Buffer.from('pong'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
const port: number = await new Promise<number>(
|
|
168
|
+
(resolve: (value: number) => void, reject: (reason: Error) => void): void => {
|
|
169
|
+
server.bindAsync(`${host}:0`, credentials, (error: Error | null, boundPort: number): void => {
|
|
170
|
+
if (error !== null) {
|
|
171
|
+
reject(error);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
resolve(boundPort);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
return { server, port };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Issue one `Ping` (an RPC reaching the server proves the handshake).
|
|
183
|
+
*
|
|
184
|
+
* @param client - The client to call on.
|
|
185
|
+
* @returns `null` on success, the gRPC error otherwise.
|
|
186
|
+
*/
|
|
187
|
+
function ping(client: grpc.Client): Promise<CallOutcome> {
|
|
188
|
+
const identity: (value: Buffer) => Buffer = (value: Buffer): Buffer => value;
|
|
189
|
+
return new Promise<CallOutcome>((resolve: (outcome: CallOutcome) => void): void => {
|
|
190
|
+
client.makeUnaryRequest(
|
|
191
|
+
PING_PATH,
|
|
192
|
+
identity,
|
|
193
|
+
identity,
|
|
194
|
+
Buffer.alloc(0),
|
|
195
|
+
{ deadline: Date.now() + 10000 },
|
|
196
|
+
(error: grpc.ServiceError | null): void => resolve(error)
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a client with the helper, ping once, close it.
|
|
203
|
+
*
|
|
204
|
+
* @param fields - The configuration.
|
|
205
|
+
* @param useSecureChannel - Secure (default) or plaintext.
|
|
206
|
+
* @param channelOptions - Extra channel options.
|
|
207
|
+
* @returns The outcome of the ping.
|
|
208
|
+
*/
|
|
209
|
+
async function pingWith(
|
|
210
|
+
fields: GrpcClientConfigFields,
|
|
211
|
+
useSecureChannel: boolean = true,
|
|
212
|
+
channelOptions: grpc.ClientOptions = {}
|
|
213
|
+
): Promise<CallOutcome> {
|
|
214
|
+
const client: grpc.Client = createGrpcClient(grpc.Client, fields, {
|
|
215
|
+
useSecureChannel,
|
|
216
|
+
channelOptions,
|
|
217
|
+
logger: recordingLogger()
|
|
218
|
+
});
|
|
219
|
+
try {
|
|
220
|
+
return await ping(client);
|
|
221
|
+
} finally {
|
|
222
|
+
client.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @returns A logger that records every warning.
|
|
228
|
+
*/
|
|
229
|
+
function recordingLogger(): RecordingLogger {
|
|
230
|
+
const messages: string[] = [];
|
|
231
|
+
return { messages, warn: (message: string): number => messages.push(message) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param text - PEM text with LF line endings.
|
|
236
|
+
* @returns The same PEM with CRLF line endings.
|
|
237
|
+
*/
|
|
238
|
+
function toCrlf(text: string): string {
|
|
239
|
+
return text.replace(/\r?\n/g, '\r\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Assert that a text renders no PEM, no key and no other secret of the test PKI.
|
|
244
|
+
*
|
|
245
|
+
* @param text - Error message or rendering.
|
|
246
|
+
* @param pki - The test PKI.
|
|
247
|
+
*/
|
|
248
|
+
function assertNoSecrets(text: string, pki: TestPki): void {
|
|
249
|
+
assert.ok(!text.includes(PEM_MARKER), `must not contain a PEM: ${text}`);
|
|
250
|
+
for (const pem of [pki.clientKey, pki.clientCert, pki.caCert]) {
|
|
251
|
+
const body: string = pem.split('\n')[1];
|
|
252
|
+
assert.ok(!text.includes(body), 'must not contain PEM content');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Capture the error a function throws.
|
|
258
|
+
*
|
|
259
|
+
* @param action - The function expected to throw.
|
|
260
|
+
* @returns The thrown error.
|
|
261
|
+
*/
|
|
262
|
+
function thrownBy(action: () => unknown): Error {
|
|
263
|
+
try {
|
|
264
|
+
action();
|
|
265
|
+
} catch (error: unknown) {
|
|
266
|
+
assert.ok(error instanceof Error);
|
|
267
|
+
return error;
|
|
268
|
+
}
|
|
269
|
+
assert.fail('expected an error');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe('grpcChannel', () => {
|
|
273
|
+
let pkiDir: string;
|
|
274
|
+
let pki: TestPki;
|
|
275
|
+
|
|
276
|
+
before(() => {
|
|
277
|
+
pkiDir = mkdtempSync(join(tmpdir(), 'ondewo-tls-test-'));
|
|
278
|
+
pki = generatePki(pkiDir);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
after(() => {
|
|
282
|
+
rmSync(pkiDir, { recursive: true, force: true });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('hostAndPort', () => {
|
|
286
|
+
it('joins host names and IPv4 addresses as they are', () => {
|
|
287
|
+
assert.equal(hostAndPort('localhost', 50051), 'localhost:50051');
|
|
288
|
+
assert.equal(hostAndPort('10.0.0.5', '50051'), '10.0.0.5:50051');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('brackets bare IPv6 literals', () => {
|
|
292
|
+
assert.equal(hostAndPort('::1', 50051), '[::1]:50051');
|
|
293
|
+
assert.equal(hostAndPort('2001:db8::7', '443'), '[2001:db8::7]:443');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('leaves bracketed hosts and scheme targets alone', () => {
|
|
297
|
+
assert.equal(hostAndPort('[::1]', 50051), '[::1]:50051');
|
|
298
|
+
assert.equal(hostAndPort('ipv6:[::1]', 50051), 'ipv6:[::1]:50051');
|
|
299
|
+
assert.equal(hostAndPort('dns:nlu.example.com', 443), 'dns:nlu.example.com:443');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('GrpcClientConfig', () => {
|
|
304
|
+
it('accepts no certificates, a CA only, and a full identity', () => {
|
|
305
|
+
assert.equal(new GrpcClientConfig({ host: 'localhost', port: 1 }).hostAndPort, 'localhost:1');
|
|
306
|
+
assert.equal(new GrpcClientConfig({ host: '::1', port: 1, grpcCert: pki.caCert }).hostAndPort, '[::1]:1');
|
|
307
|
+
const config: GrpcClientConfig = new GrpcClientConfig({
|
|
308
|
+
host: 'localhost',
|
|
309
|
+
port: '50051',
|
|
310
|
+
grpcCert: Buffer.from(pki.caCert),
|
|
311
|
+
grpcClientCert: pki.clientCert,
|
|
312
|
+
grpcClientKey: Buffer.from(pki.clientKey)
|
|
313
|
+
});
|
|
314
|
+
assert.ok(Object.isFrozen(config));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
for (const field of ['grpcCert', 'grpcClientCert', 'grpcClientKey'] as const) {
|
|
318
|
+
it(`refuses a file path in ${field} instead of PEM content`, () => {
|
|
319
|
+
const fields: GrpcClientConfigFields = {
|
|
320
|
+
host: 'localhost',
|
|
321
|
+
port: 50051,
|
|
322
|
+
grpcCert: pki.caCert,
|
|
323
|
+
grpcClientCert: pki.clientCert,
|
|
324
|
+
grpcClientKey: pki.clientKey,
|
|
325
|
+
[field]: 'certs/some-file.pem'
|
|
326
|
+
};
|
|
327
|
+
for (const build of [
|
|
328
|
+
(): unknown => new GrpcClientConfig(fields),
|
|
329
|
+
(): unknown => createChannelCredentials(fields)
|
|
330
|
+
]) {
|
|
331
|
+
const error: Error = thrownBy(build);
|
|
332
|
+
assert.match(error.message, new RegExp(`localhost:50051: ${field} is not PEM content`));
|
|
333
|
+
assert.ok(!error.message.includes('certs/some-file.pem'));
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
it('treats empty strings on both identity fields as no identity', () => {
|
|
339
|
+
assert.doesNotThrow(() => new GrpcClientConfig({ host: 'h', port: 1, grpcClientCert: '', grpcClientKey: '' }));
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
for (const [name, half] of [
|
|
343
|
+
['certificate only', { grpcClientCert: 'cert' }],
|
|
344
|
+
['key only', { grpcClientKey: 'key' }],
|
|
345
|
+
['certificate with an empty key', { grpcClientKey: Buffer.alloc(0) }]
|
|
346
|
+
] as const) {
|
|
347
|
+
it(`refuses half a client identity (${name}) without rendering it`, () => {
|
|
348
|
+
const fields: GrpcClientConfigFields = {
|
|
349
|
+
host: 'nlu.example.com',
|
|
350
|
+
port: 443,
|
|
351
|
+
...(name === 'certificate with an empty key' ? { grpcClientCert: pki.clientCert } : {}),
|
|
352
|
+
...half
|
|
353
|
+
};
|
|
354
|
+
const error: Error = thrownBy(() => new GrpcClientConfig(fields));
|
|
355
|
+
assert.match(
|
|
356
|
+
error.message,
|
|
357
|
+
/GrpcClientConfig for nlu\.example\.com:443 has only one of grpcClientCert and grpcClientKey/
|
|
358
|
+
);
|
|
359
|
+
assertNoSecrets(error.message, pki);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
it('refuses a missing host, an empty port and a PEM that is not text or bytes', () => {
|
|
364
|
+
assert.throws(() => new GrpcClientConfig({ host: '', port: 1 }), /host must be a non-empty string/);
|
|
365
|
+
assert.throws(
|
|
366
|
+
() => new GrpcClientConfig({ host: 1 as unknown as string, port: 1 }),
|
|
367
|
+
/host must be a non-empty string/
|
|
368
|
+
);
|
|
369
|
+
assert.throws(() => new GrpcClientConfig({ host: 'h', port: '' }), /port must be a non-empty string or a number/);
|
|
370
|
+
assert.throws(
|
|
371
|
+
() => new GrpcClientConfig({ host: 'h', port: {} as unknown as number }),
|
|
372
|
+
/port must be a non-empty string or a number/
|
|
373
|
+
);
|
|
374
|
+
assert.throws(
|
|
375
|
+
() => new GrpcClientConfig({ host: 'h', port: 1, grpcClientKey: 42 as unknown as string }),
|
|
376
|
+
/grpcClientKey must be PEM content \(string or Buffer\), not a number/
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('redacts the client key in toString, util.inspect, console formatting and JSON.stringify', () => {
|
|
381
|
+
const config: GrpcClientConfig = new GrpcClientConfig({
|
|
382
|
+
host: 'localhost',
|
|
383
|
+
port: 50051,
|
|
384
|
+
grpcCert: pki.caCert,
|
|
385
|
+
grpcClientCert: Buffer.from(pki.clientCert),
|
|
386
|
+
grpcClientKey: pki.clientKey
|
|
387
|
+
});
|
|
388
|
+
const text: string = config.toString();
|
|
389
|
+
assert.equal(
|
|
390
|
+
text,
|
|
391
|
+
`GrpcClientConfig(host=localhost, port=50051, grpcCert=<set>, grpcClientCert=<set>, grpcClientKey=${REDACTED})`
|
|
392
|
+
);
|
|
393
|
+
assert.equal(inspect(config), text);
|
|
394
|
+
assert.equal(`${String(config)}`, text);
|
|
395
|
+
assertNoSecrets(text, pki);
|
|
396
|
+
|
|
397
|
+
const json: Record<string, unknown> = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
|
398
|
+
assert.equal(json.grpcClientKey, REDACTED);
|
|
399
|
+
assert.equal(json.grpcCert, pki.caCert);
|
|
400
|
+
assert.equal(json.grpcClientCert, pki.clientCert);
|
|
401
|
+
assert.ok(!JSON.stringify(config).includes(pki.clientKey.split('\n')[1]));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('renders an empty key as empty and an unset key as absent', () => {
|
|
405
|
+
const empty: GrpcClientConfig = new GrpcClientConfig({
|
|
406
|
+
host: 'h',
|
|
407
|
+
port: 1,
|
|
408
|
+
grpcClientCert: '',
|
|
409
|
+
grpcClientKey: ''
|
|
410
|
+
});
|
|
411
|
+
assert.equal(
|
|
412
|
+
empty.toString(),
|
|
413
|
+
'GrpcClientConfig(host=h, port=1, grpcCert=<unset>, grpcClientCert=<unset>, grpcClientKey=)'
|
|
414
|
+
);
|
|
415
|
+
assert.equal(empty.toJSON().grpcClientKey, '');
|
|
416
|
+
const unset: GrpcClientConfig = new GrpcClientConfig({ host: 'h', port: 1 });
|
|
417
|
+
assert.equal(unset.toJSON().grpcClientKey, undefined);
|
|
418
|
+
assert.equal(
|
|
419
|
+
unset.toString(),
|
|
420
|
+
'GrpcClientConfig(host=h, port=1, grpcCert=<unset>, grpcClientCert=<unset>, grpcClientKey=)'
|
|
421
|
+
);
|
|
422
|
+
assert.equal(JSON.stringify(unset), '{"host":"h","port":1}');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('createChannelCredentials', () => {
|
|
427
|
+
it('refuses half an identity BEFORE gRPC sees it, also for plain objects', (context: { mock: typeof mock }) => {
|
|
428
|
+
const createSsl: Mock<typeof grpc.credentials.createSsl> = context.mock.method(grpc.credentials, 'createSsl');
|
|
429
|
+
for (const half of [{ grpcClientCert: pki.clientCert }, { grpcClientKey: pki.clientKey }]) {
|
|
430
|
+
const error: Error = thrownBy(() => createChannelCredentials({ host: '::1', port: 7, ...half }));
|
|
431
|
+
assert.match(
|
|
432
|
+
error.message,
|
|
433
|
+
/createChannelCredentials for \[::1\]:7 has only one of grpcClientCert and grpcClientKey/
|
|
434
|
+
);
|
|
435
|
+
assertNoSecrets(error.message, pki);
|
|
436
|
+
}
|
|
437
|
+
assert.equal(createSsl.mock.callCount(), 0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('passes no identity (not empty buffers) for empty strings, and the system roots for an empty CA', (context: {
|
|
441
|
+
mock: typeof mock;
|
|
442
|
+
}) => {
|
|
443
|
+
const createSsl: Mock<typeof grpc.credentials.createSsl> = context.mock.method(grpc.credentials, 'createSsl');
|
|
444
|
+
createChannelCredentials({ host: 'h', port: 1, grpcCert: '', grpcClientCert: '', grpcClientKey: '' });
|
|
445
|
+
createChannelCredentials({ host: 'h', port: 1 });
|
|
446
|
+
assert.deepEqual(createSsl.mock.calls[0].arguments, [null, null, null]);
|
|
447
|
+
assert.deepEqual(createSsl.mock.calls[1].arguments, [null, null, null]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('hands CA, key and certificate to gRPC as buffers', (context: { mock: typeof mock }) => {
|
|
451
|
+
const createSsl: Mock<typeof grpc.credentials.createSsl> = context.mock.method(grpc.credentials, 'createSsl');
|
|
452
|
+
createChannelCredentials(
|
|
453
|
+
{
|
|
454
|
+
host: 'h',
|
|
455
|
+
port: 1,
|
|
456
|
+
grpcCert: pki.caCert,
|
|
457
|
+
grpcClientCert: pki.clientCert,
|
|
458
|
+
grpcClientKey: Buffer.from(pki.clientKey)
|
|
459
|
+
},
|
|
460
|
+
{ useSecureChannel: true }
|
|
461
|
+
);
|
|
462
|
+
const [root, key, chain] = createSsl.mock.calls[0].arguments as Buffer[];
|
|
463
|
+
assert.equal(root.toString(), pki.caCert);
|
|
464
|
+
assert.equal(key.toString(), pki.clientKey);
|
|
465
|
+
assert.equal(chain.toString(), pki.clientCert);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('refuses an insecure channel with a client identity instead of dropping it', () => {
|
|
469
|
+
const logger: RecordingLogger = recordingLogger();
|
|
470
|
+
const error: Error = thrownBy(() =>
|
|
471
|
+
createChannelCredentials(
|
|
472
|
+
{ host: 'nlu.example.com', port: 50051, grpcClientCert: pki.clientCert, grpcClientKey: pki.clientKey },
|
|
473
|
+
{ useSecureChannel: false, logger }
|
|
474
|
+
)
|
|
475
|
+
);
|
|
476
|
+
assert.match(error.message, /nlu\.example\.com:50051: useSecureChannel is false but a client identity/);
|
|
477
|
+
assertNoSecrets(error.message, pki);
|
|
478
|
+
assert.deepEqual(logger.messages, []);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('warns about an insecure channel, naming host:port, through the given logger', () => {
|
|
482
|
+
const logger: RecordingLogger = recordingLogger();
|
|
483
|
+
createChannelCredentials({ host: '::1', port: 50051, grpcCert: pki.caCert }, { useSecureChannel: false, logger });
|
|
484
|
+
assert.deepEqual(logger.messages, [
|
|
485
|
+
'Insecure gRPC channel to [::1]:50051: traffic is NOT encrypted (useSecureChannel=false).'
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('warns through console.warn when no logger is given', (context: { mock: typeof mock }) => {
|
|
490
|
+
const warn: Mock<typeof console.warn> = context.mock.method(console, 'warn', (): void => undefined);
|
|
491
|
+
createChannelCredentials({ host: 'localhost', port: 1 }, { useSecureChannel: false });
|
|
492
|
+
assert.equal(warn.mock.callCount(), 1);
|
|
493
|
+
assert.match(String(warn.mock.calls[0].arguments[0]), /Insecure gRPC channel to localhost:1/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('reports a PEM gRPC rejects with the target and the TLS reason, never the PEM', () => {
|
|
497
|
+
const mismatched: Error = thrownBy(() =>
|
|
498
|
+
createChannelCredentials({
|
|
499
|
+
host: 'localhost',
|
|
500
|
+
port: 1,
|
|
501
|
+
grpcCert: pki.caCert,
|
|
502
|
+
grpcClientCert: pki.clientCert,
|
|
503
|
+
grpcClientKey: pki.otherClientKey
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
assert.match(mismatched.message, /^createChannelCredentials for localhost:1: gRPC rejected the TLS material: /);
|
|
507
|
+
assertNoSecrets(mismatched.message, pki);
|
|
508
|
+
const garbage: Error = thrownBy(() =>
|
|
509
|
+
createChannelCredentials({
|
|
510
|
+
host: 'localhost',
|
|
511
|
+
port: 1,
|
|
512
|
+
grpcClientCert: NOT_REALLY_PEM,
|
|
513
|
+
grpcClientKey: NOT_REALLY_PEM
|
|
514
|
+
})
|
|
515
|
+
);
|
|
516
|
+
assert.match(garbage.message, /gRPC rejected the TLS material/);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('reports a non-Error thrown by gRPC as text', (context: { mock: typeof mock }) => {
|
|
520
|
+
context.mock.method(grpc.credentials, 'createSsl', (): never => {
|
|
521
|
+
throw 'boom'; // eslint-disable-line @typescript-eslint/only-throw-error
|
|
522
|
+
});
|
|
523
|
+
assert.throws(() => createChannelCredentials({ host: 'h', port: 1 }), /gRPC rejected the TLS material: boom$/);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('validates the shape of a plain object', () => {
|
|
527
|
+
assert.throws(() => createChannelCredentials({ host: '', port: 1 }), /createChannelCredentials: host must be/);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe('createGrpcClient', () => {
|
|
532
|
+
it('applies the default channel options under the caller options', () => {
|
|
533
|
+
const seen: unknown[][] = [];
|
|
534
|
+
class FakeClient {
|
|
535
|
+
public constructor(...args: unknown[]) {
|
|
536
|
+
seen.push(args);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const credentials: grpc.ChannelCredentials = grpc.credentials.createInsecure();
|
|
540
|
+
createGrpcClient(
|
|
541
|
+
FakeClient,
|
|
542
|
+
{ host: '::1', port: 9 },
|
|
543
|
+
{ credentials, channelOptions: { 'grpc.max_reconnect_backoff_ms': 1 } }
|
|
544
|
+
);
|
|
545
|
+
createGrpcClient(FakeClient, { host: 'h', port: 9 }, { useSecureChannel: false, logger: recordingLogger() });
|
|
546
|
+
assert.equal(seen[0][0], '[::1]:9');
|
|
547
|
+
assert.equal(seen[0][1], credentials);
|
|
548
|
+
assert.deepEqual(seen[0][2], { ...DEFAULT_GRPC_CHANNEL_OPTIONS, 'grpc.max_reconnect_backoff_ms': 1 });
|
|
549
|
+
assert.deepEqual(seen[1][2], DEFAULT_GRPC_CHANNEL_OPTIONS);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('pins the default channel options', () => {
|
|
553
|
+
assert.deepEqual(DEFAULT_GRPC_CHANNEL_OPTIONS, {
|
|
554
|
+
'grpc.max_send_message_length': 2147483647,
|
|
555
|
+
'grpc.max_receive_message_length': 2147483647,
|
|
556
|
+
'grpc.max_reconnect_backoff_ms': 5000,
|
|
557
|
+
'grpc.keepalive_timeout_ms': 20000,
|
|
558
|
+
'grpc.keepalive_permit_without_calls': 0
|
|
559
|
+
});
|
|
560
|
+
assert.equal(MAX_MESSAGE_LENGTH, 2147483647);
|
|
561
|
+
// Keepalive pings stay off by default: see DEFAULT_GRPC_CHANNEL_OPTIONS for why.
|
|
562
|
+
assert.ok(!('grpc.keepalive_time_ms' in DEFAULT_GRPC_CHANNEL_OPTIONS));
|
|
563
|
+
assert.ok(Object.isFrozen(DEFAULT_GRPC_CHANNEL_OPTIONS));
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('validates the configuration before building credentials', () => {
|
|
567
|
+
assert.throws(() => createGrpcClient(grpc.Client, { host: 'h', port: '' }), /createGrpcClient for host h: port/);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('builds secure credentials by default', (context: { mock: typeof mock }) => {
|
|
571
|
+
const createSsl: Mock<typeof grpc.credentials.createSsl> = context.mock.method(grpc.credentials, 'createSsl');
|
|
572
|
+
createGrpcClient(grpc.Client, new GrpcClientConfig({ host: 'localhost', port: 1 })).close();
|
|
573
|
+
assert.equal(createSsl.mock.callCount(), 1);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('real handshakes', () => {
|
|
578
|
+
let tlsServer: { server: grpc.Server; port: number };
|
|
579
|
+
let mtlsServer: { server: grpc.Server; port: number };
|
|
580
|
+
let insecureServer: { server: grpc.Server; port: number };
|
|
581
|
+
|
|
582
|
+
before(async () => {
|
|
583
|
+
const keyCertPairs: grpc.KeyCertPair[] = [
|
|
584
|
+
{ private_key: Buffer.from(pki.serverKey), cert_chain: Buffer.from(pki.serverCert) }
|
|
585
|
+
];
|
|
586
|
+
tlsServer = await startServer('127.0.0.1', grpc.ServerCredentials.createSsl(null, keyCertPairs, false));
|
|
587
|
+
mtlsServer = await startServer(
|
|
588
|
+
'127.0.0.1',
|
|
589
|
+
grpc.ServerCredentials.createSsl(Buffer.from(pki.caCert), keyCertPairs, true)
|
|
590
|
+
);
|
|
591
|
+
insecureServer = await startServer('127.0.0.1', grpc.ServerCredentials.createInsecure());
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
after(() => {
|
|
595
|
+
for (const running of [tlsServer, mtlsServer, insecureServer]) {
|
|
596
|
+
running.server.forceShutdown();
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('plain TLS with a custom CA', async () => {
|
|
601
|
+
assert.equal(await pingWith({ host: '127.0.0.1', port: tlsServer.port, grpcCert: pki.caCert }), null);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('plain TLS with empty strings for the client identity', async () => {
|
|
605
|
+
const outcome: CallOutcome = await pingWith({
|
|
606
|
+
host: 'localhost',
|
|
607
|
+
port: tlsServer.port,
|
|
608
|
+
grpcCert: pki.caCert,
|
|
609
|
+
grpcClientCert: '',
|
|
610
|
+
grpcClientKey: ''
|
|
611
|
+
});
|
|
612
|
+
assert.equal(outcome, null);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('mutual TLS', async () => {
|
|
616
|
+
const config: GrpcClientConfig = new GrpcClientConfig({
|
|
617
|
+
host: '127.0.0.1',
|
|
618
|
+
port: mtlsServer.port,
|
|
619
|
+
grpcCert: pki.caCert,
|
|
620
|
+
grpcClientCert: pki.clientCert,
|
|
621
|
+
grpcClientKey: pki.clientKey
|
|
622
|
+
});
|
|
623
|
+
assert.equal(await pingWith(config), null);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('mutual TLS with CRLF PEMs', async () => {
|
|
627
|
+
const outcome: CallOutcome = await pingWith({
|
|
628
|
+
host: '127.0.0.1',
|
|
629
|
+
port: mtlsServer.port,
|
|
630
|
+
grpcCert: toCrlf(pki.caCert),
|
|
631
|
+
grpcClientCert: toCrlf(pki.clientCert),
|
|
632
|
+
grpcClientKey: Buffer.from(toCrlf(pki.clientKey))
|
|
633
|
+
});
|
|
634
|
+
assert.equal(outcome, null);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('a server that requires a client certificate rejects a client without one', async () => {
|
|
638
|
+
const outcome: CallOutcome = await pingWith({ host: '127.0.0.1', port: mtlsServer.port, grpcCert: pki.caCert });
|
|
639
|
+
assert.equal(outcome?.code, grpc.status.UNAVAILABLE);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('a client identity signed by an unrelated CA is rejected', async () => {
|
|
643
|
+
const outcome: CallOutcome = await pingWith({
|
|
644
|
+
host: '127.0.0.1',
|
|
645
|
+
port: mtlsServer.port,
|
|
646
|
+
grpcCert: pki.caCert,
|
|
647
|
+
grpcClientCert: pki.otherClientCert,
|
|
648
|
+
grpcClientKey: pki.otherClientKey
|
|
649
|
+
});
|
|
650
|
+
assert.equal(outcome?.code, grpc.status.UNAVAILABLE);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('the wrong CA fails the handshake with UNAVAILABLE instead of crashing', async () => {
|
|
654
|
+
const outcome: CallOutcome = await pingWith({
|
|
655
|
+
host: '127.0.0.1',
|
|
656
|
+
port: tlsServer.port,
|
|
657
|
+
grpcCert: pki.otherCaCert
|
|
658
|
+
});
|
|
659
|
+
assert.equal(outcome?.code, grpc.status.UNAVAILABLE);
|
|
660
|
+
assertNoSecrets(outcome?.details ?? '', pki);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('without grpcCert the system trust store is used (and does not trust the test CA)', async () => {
|
|
664
|
+
const outcome: CallOutcome = await pingWith({ host: '127.0.0.1', port: tlsServer.port });
|
|
665
|
+
assert.equal(outcome?.code, grpc.status.UNAVAILABLE);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('an insecure channel reaches an insecure server', async () => {
|
|
669
|
+
assert.equal(await pingWith({ host: '127.0.0.1', port: insecureServer.port }, false), null);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('mutual TLS over IPv6 [::1] (skipped when the host has no IPv6 loopback)', async (context: {
|
|
673
|
+
skip: (message: string) => void;
|
|
674
|
+
}) => {
|
|
675
|
+
let ipv6Server: { server: grpc.Server; port: number };
|
|
676
|
+
try {
|
|
677
|
+
ipv6Server = await startServer(
|
|
678
|
+
'[::1]',
|
|
679
|
+
grpc.ServerCredentials.createSsl(
|
|
680
|
+
Buffer.from(pki.caCert),
|
|
681
|
+
[{ private_key: Buffer.from(pki.serverKey), cert_chain: Buffer.from(pki.serverCert) }],
|
|
682
|
+
true
|
|
683
|
+
)
|
|
684
|
+
);
|
|
685
|
+
} catch {
|
|
686
|
+
context.skip('no IPv6 loopback');
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
// The target is bracketed ([::1]:port). The certificate is checked against its DNS name:
|
|
691
|
+
// some Node releases (22.23, 24.18) reject an IPv6 literal against an IP SAN in
|
|
692
|
+
// tls.checkServerIdentity, which is a Node issue, not part of what this test pins.
|
|
693
|
+
const outcome: CallOutcome = await pingWith(
|
|
694
|
+
{
|
|
695
|
+
host: '::1',
|
|
696
|
+
port: ipv6Server.port,
|
|
697
|
+
grpcCert: pki.caCert,
|
|
698
|
+
grpcClientCert: pki.clientCert,
|
|
699
|
+
grpcClientKey: pki.clientKey
|
|
700
|
+
},
|
|
701
|
+
true,
|
|
702
|
+
{ 'grpc.ssl_target_name_override': 'localhost' }
|
|
703
|
+
);
|
|
704
|
+
assert.equal(outcome, null);
|
|
705
|
+
} finally {
|
|
706
|
+
ipv6Server.server.forceShutdown();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('clients built with the same credentials share one connection', async () => {
|
|
711
|
+
let connections: number = 0;
|
|
712
|
+
const proxy: net.Server = net.createServer((socket: net.Socket): void => {
|
|
713
|
+
connections += 1;
|
|
714
|
+
const upstream: net.Socket = net.connect(mtlsServer.port, '127.0.0.1');
|
|
715
|
+
socket.pipe(upstream).pipe(socket);
|
|
716
|
+
socket.on('error', (): void => {
|
|
717
|
+
upstream.destroy();
|
|
718
|
+
});
|
|
719
|
+
upstream.on('error', (): void => {
|
|
720
|
+
socket.destroy();
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
await new Promise<void>((resolve: () => void): void => {
|
|
724
|
+
proxy.listen(0, '127.0.0.1', resolve);
|
|
725
|
+
});
|
|
726
|
+
const config: GrpcClientConfig = new GrpcClientConfig({
|
|
727
|
+
host: '127.0.0.1',
|
|
728
|
+
port: (proxy.address() as net.AddressInfo).port,
|
|
729
|
+
grpcCert: pki.caCert,
|
|
730
|
+
grpcClientCert: pki.clientCert,
|
|
731
|
+
grpcClientKey: pki.clientKey
|
|
732
|
+
});
|
|
733
|
+
const credentials: grpc.ChannelCredentials = createChannelCredentials(config);
|
|
734
|
+
const first: grpc.Client = createGrpcClient(grpc.Client, config, { credentials });
|
|
735
|
+
const second: grpc.Client = createGrpcClient(grpc.Client, config, { credentials });
|
|
736
|
+
try {
|
|
737
|
+
assert.equal(await ping(first), null);
|
|
738
|
+
assert.equal(await ping(second), null);
|
|
739
|
+
assert.equal(connections, 1);
|
|
740
|
+
} finally {
|
|
741
|
+
first.close();
|
|
742
|
+
second.close();
|
|
743
|
+
proxy.close();
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
});
|