Skip to content

Commit 9c36556

Browse files
committed
WL#14837: consistent TLS connection options
The existing TLS interface both for connection strings and connection configuration objects is too strict and, at the same time, is not clear about the role of additional TLS options such as verifying server certificates are issued or revoked by a CA in a given chain. Also, there is currently no option to verify the server identity comparing the hostname and the common name specified by its own certificate. This patch introduces some changes to relax the validation requirements of TLS-related options and to enable implicit or explicit server identity checks. Two specific working TLS modes - "VERIFY_CA" and "VERIFY_IDENTITY" are now available to enable these certificate validation features with connection strings, and a new secure context option "checkServerIdentity" is now available and allow applications to verify the server hostname using the details of the server certificate. This can happen implicitly using a custom function or using the builtin Node.js validation function. https://nodejs.org/docs/v12.0.0/api/tls.html#tls_tls_checkserveridentity_hostname_cert
1 parent 232e452 commit 9c36556

File tree

14 files changed

+1004
-211
lines changed

14 files changed

+1004
-211
lines changed

docs/tutorial/Secure_Sessions.md

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,19 +166,22 @@ Applications are free to use older TLSv1 and TLSv1.1 compatible ciphersuites (li
166166

167167
Non-TLS ciphersuites, including the `MD5`, `SSLv3` and other older sets are not supported and will be ignored if the application wants to use them. If none of the ciphers provided by the application is actually supported by the client, an error will be thrown.
168168

169-
#### Certificate authority validation
169+
#### Certificate Authority Validation
170170

171171
When creating a connection to the database using TLS, the connector can validate if the server certificate was signed by a certificate authority (CA) and, at the same time, ensure that the certificate was not revoked by that same authority, or any other in a given chain of trust.
172172

173173
Just like with TLS-related core Node.js APIs, an application can provide one or more PEM formatted CA certificates and certificate revocation lists (CRL). The [secure context](https://nodejs.org/docs/v12.0.0/api/tls.html#tls_tls_createsecurecontext_options) uses a list of well-known CAs curated by Mozilla, which are completely replaced by the list of CAs provided by an application. This means that if the certificate was not signed by the root CA, the entire chain of trust down to the signing CA, should be included in the list. The same is true for the certificate revocation lists, because each CA has its own.
174174

175-
Verifying if the server certificate was signed by the root CA can be done by providing the path to the CA file:
175+
Verifying if the server certificate was signed by the root CA can be done by providing the path to the CA file. When using a connection string, this feature needs to be explicitly enabled by setting value of `ssl-mode` to `VERIFY_CA` or `VERIFY_IDENTITY` (which will also validate the server identity against its own certificate).
176+
177+
> **IMPORTANT**<br />
178+
> When using a connection string, if the `ssl-mode` option is not set whilst providing a path to a certificate authority file, the verification does not happen and the client will yield a warning message.
176179
177180
```javascript
178181
const mysqlx = require('@mysql/xdevapi');
179182
const path = require('path');
180183

181-
mysqlx.getSession('mysqlx://localhost?ssl-ca=(/path/to/root/ca.pem)')
184+
mysqlx.getSession('mysqlx://localhost?ssl-mode=VERIFY_CA&ssl-ca=(/path/to/root/ca.pem)')
182185
.then(session => {
183186
// the connection succeeds if the server certificate was signed by the root CA
184187
console.log(session.inspect()); // { host: 'localhost', tls: true }
@@ -228,13 +231,13 @@ const mysqlx = require('@mysql/xdevapi');
228231
const path = require('path');
229232

230233
// /path/to/chain/ca.pem should contain the entire chain of trust
231-
mysqlx.getSession('mysqlx://localhost?ssl-ca=(/path/to/chain/ca.pem)')
234+
mysqlx.getSession('mysqlx://localhost?ssl-mode=VERIFY_CA&ssl-ca=(/path/to/chain/ca.pem)')
232235
.then(session => {
233236
// the connection succeeds if the server certificate was signed by the root CA
234237
console.log(session.inspect()); // { host: 'localhost', tls: true }
235238
});
236239

237-
mysqlx.getSession(`mysqlx://localhost?ssl-ca=${encodeURIComponent('/path/to/chain/ca.pem')}`)
240+
mysqlx.getSession(`mysqlx://localhost?ssl-mode=VERIFY_CA&ssl-ca=${encodeURIComponent('/path/to/chain/ca.pem')}`)
238241
.then(session => {
239242
// the connection succeeds if the server certificate was signed by the root CA
240243
console.log(session.inspect()); // { host: 'localhost', tls: true }
@@ -315,13 +318,13 @@ const fs = require('fs')
315318
const mysqlx = require('@mysql/xdevapi');
316319
const path = require('path');
317320

318-
mysqlx.getSession('mysqlx://localhost?ssl-ca=(/path/to/root/ca.pem)&ssl-crl=(/path/to/root/crl.pem)')
321+
mysqlx.getSession('mysqlx://localhost?ssl-mode=VERIFY_CA&ssl-ca=(/path/to/root/ca.pem)&ssl-crl=(/path/to/root/crl.pem)')
319322
.then(session => {
320323
// the connection succeeds if the server certificate was not revoked by the root CA
321324
console.log(session.inspect()); // { host: 'localhost', tls: true }
322325
});
323326

324-
mysqlx.getSession(`mysqlx://localhost?ssl-ca=${encodeURIComponent('/path/to/root/ca.pem')}&ssl-crl=${encodeURIComponent('/path/to/root/crl.pem')}`)
327+
mysqlx.getSession(`mysqlx://localhost?ssl-mode=VERIFY_CA&ssl-ca=${encodeURIComponent('/path/to/root/ca.pem')}&ssl-crl=${encodeURIComponent('/path/to/root/crl.pem')}`)
325328
.then(session => {
326329
// the connection succeeds if the server certificate was not revoked by the root CA
327330
console.log(session.inspect()); // { host: 'localhost', tls: true }
@@ -355,6 +358,101 @@ mysqlx.getSession(options)
355358
> **IMPORTANT**<br />
356359
> Due to the constraints imposed by connection strings, the fact that all core Node.js TLS-related APIs expect PEM file content as input, and the fact that Node.js and OpenSSL cannot verify CRLs concatenated in a single file, it is strongly recommended that applications always use a connection configuration object to specify one or more PEM files for CAs and CRLs using "fs.readFileSync()".
357360
361+
### Checking the Server Identity
362+
363+
Besides verifying if the server certificate was signed or revoked by a certificate in a given authority chain, it is also possible to additionally verify if the server is the effective owner of the certificate, by comparing the server hostname and the common name specifified by the certificate, according to a specific set of prerequisites. This check is only performed if the certificate first passes all the other checks, such as being issued by a given CA (required).
364+
365+
When using a connection configuration object, this can be done by providing an additional `checkServerIdentity` property as part of the secure context. If its value is `true`, the server identity check will happen using the builtin [`tls.checkServerIdentity()`](https://nodejs.org/docs/v12.0.0/api/tls.html#tls_tls_checkserveridentity_hostname_cert) function, which expects both the server hostname and certificate common name to be exactly the same.
366+
367+
```js
368+
const fs = require('fs')
369+
const mysqlx = require('@mysql/xdevapi');
370+
const path = require('path');
371+
372+
let options = {
373+
host: 'localhost',
374+
tls: {
375+
ca: path.join('/', 'path', 'to', 'ca.pem'),
376+
checkServerIdentity: true
377+
}
378+
};
379+
380+
// CN = 'localhost'
381+
mysqlx.getSession(options)
382+
.then(session => {
383+
console.log(session.inspect()); // { host: 'localhost', tls: true }
384+
});
385+
386+
// CN = 'example.com'
387+
mysqlx.getSession(options)
388+
.catch(err => {
389+
console.log(err.message); // Hostname/IP does not match certificate's altnames: Host: localhost. is not cert's CN: example.com
390+
});
391+
```
392+
393+
An application can specify its own set of requirements to determine if the server identity is valid. This can be done by providing a custom `checkServerIdentity()` function. As an example, this can be useful, for instance to allow subdomains.
394+
395+
```js
396+
const fs = require('fs')
397+
const mysqlx = require('@mysql/xdevapi');
398+
const path = require('path');
399+
400+
let options = {
401+
host: 'dev.mysql.com',
402+
tls: {
403+
ca: path.join('/', 'path', 'to', 'ca.pem'),
404+
checkServerIdentity (hostname, cert) {
405+
// Checks if the domain is the same.
406+
if (cert.subject.CN === hostname.substring(hostname.length - cert.subject.CN.length)) {
407+
return true;
408+
}
409+
410+
// Instead of being thrown, the error needs to be returned back to the client.
411+
return new Error(`"${hostname}" is not a valid subdomain of "${cert.subject.cn}"`);
412+
}
413+
}
414+
};
415+
416+
// CN = 'mysql.com'
417+
mysqlx.getSession(options)
418+
.then(session => {
419+
console.log(session.inspect()); // { host: 'localhost', tls: true }
420+
});
421+
422+
// CN = 'example.com'
423+
mysqlx.getSession(options)
424+
.catch(err => {
425+
console.log(err.message); // "dev.mysql.com" is not a valid subdomain of "example.com"
426+
});
427+
```
428+
429+
By default, if the `checkServerIdentity` property is not specified, the check will not be performed.
430+
431+
> **IMPORTANT**<br />
432+
> Custom `checkServerIdentity()` functions should "return" all errors instead "throwing" them. Further implementation details are availabe in the official Node.js [documentation](https://nodejs.org/docs/v12.0.0/api/tls.html#tls_tls_checkserveridentity_hostname_cert).
433+
434+
When using a connection string, the API is a little bit more restricted. The only possibility is to set the value of the `ssl-mode` option to `VERIFY_IDENTITY` in order to explicitly enable the server identity check using the builtin `tls.checkServerIdentity()` function.
435+
436+
```js
437+
const fs = require('fs')
438+
const mysqlx = require('@mysql/xdevapi');
439+
const path = require('path');
440+
441+
const ca = path.join('path', 'to', 'ca.pem');
442+
443+
// CN = 'localhost'
444+
mysqlx.getSession(`mysqlx://localhost?ssl-mode=VERIFY_IDENTITY&ssl-ca=${encodeURIComponent(ca)}`)
445+
.then(session => {
446+
console.log(session.inspect()); // { host: 'localhost', tls: true }
447+
});
448+
449+
// CN = 'example.com'
450+
mysqlx.getSession(`mysqlx://localhost?ssl-mode=VERIFY_IDENTITY&ssl-ca=${encodeURIComponent(ca)}`)
451+
.catch(err => {
452+
console.log(err.message); // Hostname/IP does not match certificate's altnames: Host: localhost. is not cert's CN: example.com
453+
});
454+
```
455+
358456
### Authentication Mechanisms
359457

360458
Currently, the MySQL X plugin supports the following authentication methods:

lib/DevAPI/Util/URIParser/parseSecurityOptions.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@
3030

3131
'use strict';
3232

33+
const errors = require('../../../constants/errors');
34+
const logger = require('../../../logger');
3335
const parseQueryParameters = require('./parseQueryParameters');
36+
const util = require('util');
37+
const warnings = require('../../../constants/warnings');
38+
39+
const log = logger('parser:uri:tls');
3440

3541
module.exports = parse;
3642

@@ -55,10 +61,9 @@ function parseItemList (input) {
5561
* @param {string} input - URI querystring
5662
* @returns {Object} Security section dictionary for URI properties object.
5763
*/
58-
function parse (input) {
59-
// TODO(Rui): use default agument values on node >= 6.0.0
60-
const match = (input || '').trim().match(/^\?([^#]+)/) || [];
61-
const params = parseQueryParameters(match[1], { allowDuplicates: false, ignoreCase: ['ssl-mode'] });
64+
function parse (input = '') {
65+
const match = input.trim().match(/^\?([^#]+)/) || [];
66+
const params = parseQueryParameters(match[1], { allowDuplicates: true, ignoreCase: ['ssl-mode'] });
6267

6368
const isSecure = params['ssl-mode'] !== 'disabled';
6469
const options = Object.assign({}, params, { 'ssl-enabled': isSecure });
@@ -76,9 +81,35 @@ function parse (input) {
7681

7782
delete options['ssl-mode'];
7883

79-
return Object.keys(options).reduce((result, key) => {
84+
const tlsOptions = Object.keys(options).reduce((result, key) => {
8085
const match = key.trim().match(/^ssl-(.+)$/) || key.trim().match(/^tls-(.+)$/) || [];
8186

8287
return !match[1] ? result : Object.assign(result, { [match[1]]: options[key] });
8388
}, {});
89+
90+
// If "ssl-mode" is "VERIFY_CA" or "VERIFY_IDENTITY", the path to the
91+
// certificate authority PEM file MUST be provided.
92+
if ((params['ssl-mode'] === 'verify_ca' || params['ssl-mode'] === 'verify_identity') && typeof tlsOptions.ca === 'undefined') {
93+
throw new Error(util.format(errors.MESSAGES.ER_DEVAPI_CERTIFICATE_AUTHORITY_REQUIRED, params['ssl-mode'].toUpperCase()));
94+
}
95+
96+
// If "ssl-mode" is neither "VERIFY_CA" nor "VERIFY_IDENTITY", any path
97+
// provided for the certificate authority PEM file or the certificate
98+
// revocation list PEM file should be ignored.
99+
// Previously, this behaviour was a bit more strict, so we should ensure
100+
// the user sees a warning.
101+
if (params['ssl-mode'] !== 'verify_ca' && params['ssl-mode'] !== 'verify_identity') {
102+
if (typeof params['ssl-ca'] !== 'undefined') {
103+
log.warning('ssl-ca', warnings.MESSAGES.WARN_STRICT_CERTIFICATE_VALIDATION);
104+
}
105+
106+
delete tlsOptions.ca;
107+
delete tlsOptions.crl;
108+
}
109+
110+
if (params['ssl-mode'] === 'verify_identity') {
111+
tlsOptions.checkServerIdentity = true;
112+
}
113+
114+
return tlsOptions;
84115
}

lib/constants/errors.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ exports.MESSAGES = {
6464
ER_DEVAPI_BAD_TLS_CA_PATH: 'The certificate authority (CA) file path is not valid.',
6565
ER_DEVAPI_BAD_TLS_CRL_PATH: 'The certificate revocation list (CRL) file path is not valid.',
6666
ER_DEVAPI_BAD_TLS_CIPHERSUITE_LIST: '%s is not a valid TLS ciphersuite list format.',
67-
ER_DEVAPI_BAD_TLS_OPTIONS: 'Additional TLS options cannot be specified when TLS is disabled.',
6867
ER_DEVAPI_BAD_TLS_VERSION: '"%s" is not a valid TLS protocol version. Should be one of %s.',
6968
ER_DEVAPI_BAD_TLS_VERSION_LIST: '"%s" is not a valid TLS protocol list format.',
69+
ER_DEVAPI_CERTIFICATE_AUTHORITY_REQUIRED: '%s requires a certificate authority.',
7070
ER_DEVAPI_COLLECTION_OPTIONS_NOT_SUPPORTED: 'Your MySQL server does not support the requested operation. Please update to MySQL 8.0.19 or a later version.',
7171
ER_DEVAPI_CONNECTION_CLOSED: 'This session was closed. Use "mysqlx.getSession()" or "mysqlx.getClient()" to create a new one.',
7272
ER_DEVAPI_CONNECTION_TIMEOUT: 'Connection attempt to the server was aborted. Timeout of %d ms was exceeded.',

lib/constants/warnings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ exports.MESSAGES = {
4444
WARN_DEPRECATED_TABLE_UPDATE_EXPR_ARGUMENT: 'Passing an expression in Table.update() is a deprecated behavior and will not be supported in future versions. Use TableUpdate.where() instead.',
4545
WARN_DEPRECATED_TABLE_INSERT_OBJECT_ARGUMENT: 'Passing objects to Table.insert() is a deprecated behavior and will not be supported in future versions.',
4646
WARN_DEPRECATED_RESULT_GET_AFFECTED_ROWS_COUNT: 'Result.getAffectedRowsCount() is deprecated and will be removed in future versions. Use Result.getAffectedItemsCount() instead.',
47-
WARN_DEPRECATED_SET_OFFSET: 'setOffset() is deprecated and will be removed in future versions. Use offset() instead.'
47+
WARN_DEPRECATED_SET_OFFSET: 'setOffset() is deprecated and will be removed in future versions. Use offset() instead.',
48+
WARN_STRICT_CERTIFICATE_VALIDATION: 'To verify if the server certificate was signed by a given certificate authority "ssl-mode" must be either "VERIFY_CA" or "VERIFY_IDENTITY".'
4849
};
4950

5051
exports.CODES = {

lib/tls/secure-context.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,21 @@ exports.create = function (options = {}) {
5151
// we use our own. We cannot have a default list of ciphersuites because
5252
// those are dependent on the TLS version that ends up being used.
5353
options = Object.assign({}, { versions: tlsVersions.allowed(), ciphersuites: [] }, options);
54-
// We need to create the proper securiy context containing any
54+
// Certificate identity verification can be performed using a builtin
55+
// function or a custom application-provided function.
56+
// https://nodejs.org/docs/v12.0.0/api/tls.html#tls_tls_checkserveridentity_hostname_cert
57+
if (typeof options.checkServerIdentity === 'undefined') {
58+
// If a custom function is not provided, we should use a no-op one that
59+
// skips the identity check.
60+
options.checkServerIdentity = () => { /* no-op */ };
61+
} else if (options.checkServerIdentity === true) {
62+
// If certificate identity verification is explicitly enabled, we can
63+
// rely on the builtin "checkServerIdentity" function, which simply
64+
// ensures the names match.
65+
// Deleting the property will ensure the builting function is used.
66+
delete options.checkServerIdentity;
67+
}
68+
// We need to create the proper security context containing any
5569
// certificates, TLS versions or ciphersuites that have been provided.
5670
// Applications can also provide any additional option supported by the
5771
// Node.js security context.
@@ -124,7 +138,6 @@ exports.create = function (options = {}) {
124138
* @private
125139
* @param {Object} params
126140
* @param {TLS} [params.tls] - wether TLS should be enabled or disabled
127-
* @param {boolean} [params.ssl] - deprecated property to enable or disable TLS
128141
* @param {DeprecatedSSLOptions} [params.sslOptions] - deprecated property to provide additional TLS options
129142
* @returns {boolean} Returns true all properties and values are valid.
130143
* @throws when TLS should be disabled and additional properties are provided,
@@ -133,13 +146,8 @@ exports.create = function (options = {}) {
133146
* one, or contains one that is not supported, or when the list of ciphersuites
134147
* does not include any valid one
135148
*/
136-
exports.validate = function ({ tls, ssl, sslOptions }) {
137-
const tlsIsDisabled = (tls && tls.enabled === false) || ssl === false;
149+
exports.validate = function ({ tls, sslOptions }) {
138150
const tlsOptions = Object.assign({}, tls, sslOptions);
139-
// If TLS is disabled, we should not allow any additional options.
140-
if (tlsIsDisabled === true && Object.keys(tlsOptions).some(k => k !== 'enabled')) {
141-
throw new Error(errors.MESSAGES.ER_DEVAPI_BAD_TLS_OPTIONS);
142-
}
143151

144152
// Validate any CA file path or CA file content.
145153
if (!isValidPEM({ value: tlsOptions.ca }) && !isValidArray({ value: tlsOptions.ca, validator: isValidPEM }) && !isValidString({ value: tlsOptions.ca })) {

test/functional/extended/connection/ssl-support.js renamed to test/fixtures/scripts/connection/implicit-ssl-mode.js

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020, 2021, Oracle and/or its affiliates.
2+
* Copyright (c) 2021, Oracle and/or its affiliates.
33
*
44
* This program is free software; you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License, version 2.0, as
@@ -30,26 +30,22 @@
3030

3131
'use strict';
3232

33-
/* eslint-env node, mocha */
33+
const mysqlx = require('../../../../');
3434

35-
const config = require('../../../config');
36-
const expect = require('chai').expect;
37-
const mysqlx = require('../../../..');
35+
const config = JSON.parse(process.env.MYSQLX_CLIENT_CONFIG);
36+
const baseConfig = Object.assign({}, config, { schema: undefined, socket: undefined });
3837

39-
describe('SSL/TLS support', () => {
40-
context('when TLS is it not enabled in the server', () => {
41-
// container as defined in docker-compose.yml
42-
const baseConfig = { host: 'mysql-with-ssl-disabled', schema: undefined, socket: undefined };
38+
// additional configuration properties are provided via a JSON command argument
39+
const additionalConfig = JSON.parse(process.argv[2] || null);
40+
const scriptConfig = Object.assign({}, baseConfig, additionalConfig);
41+
const uri = `mysqlx://${scriptConfig.user}:${scriptConfig.password}@${scriptConfig.host}:${scriptConfig.port}?ssl-ca=${encodeURIComponent(scriptConfig.tls.ca)}`;
4342

44-
it('fails to connect if the client requires it', () => {
45-
const secureConfig = Object.assign({}, config, baseConfig, { tls: { enabled: true } });
46-
const error = 'The X Plugin version installed in the server does not support TLS. Check https://dev.mysql.com/doc/refman/8.0/en/x-plugin-ssl-connections.html for more details on how to enable secure connections.';
47-
48-
return mysqlx.getSession(secureConfig)
49-
.then(() => expect.fail())
50-
.catch(err => {
51-
expect(err.message).to.equal(error);
52-
});
53-
});
43+
mysqlx.getSession(uri)
44+
.then(session => {
45+
return session.close();
46+
})
47+
.catch(err => {
48+
// errors in should be passed as JSON to the parent process via stderr
49+
console.error(JSON.stringify({ message: err.message, stack: err.stack }));
50+
return process.exit(1);
5451
});
55-
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIICqTCCAZGgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDARyb290
3+
MB4XDTIxMTIxNjE3NTI0MloXDTIyMTIyNjE3NTI0MlowDzENMAsGA1UEAwwEbmFk
4+
YTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMgZRWcR1BgW9xUVLGiV
5+
hcLKN4SvI+Ps1dRGGHD1SiuZ73imBJfQqd/70ZE8kuRe1bQ47Ub/3Ns6pog1SCVR
6+
jWKnrEmbtO4YQZIWW56kX2RA2S1MHqxzoJAZsOvpLLW4yN3Rm8K7FNnEQ15+sw0f
7+
Ad+J2z0CvkUIt7TixWEACTUPQP+gklq1/8wMxUVZXbmVbQ+Ojqe3dYMsqxZf3lKQ
8+
FYEqL3sIGso0GRzA8PMg2qJyAQc6Qn5WvvSsySWtq9LxbHFpJ/Jd9QGlCbe595F4
9+
RVZICZPSHg4Y7QbcIceVSx6XXL60JyK6zl1tPzPidHFDhW50wYMQcg45uYwitn1c
10+
SjMCAwEAAaMQMA4wDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAtjzu
11+
XTWvvVHFnJMEdoitsrpHR+SX0tE61ObLhGlrIiFFyZDogWos2LvB//+M7Xen1bNz
12+
z+zkCP2tkLg2ZOZs2BC+7gR/EM6XvloQ/xvjbMna04ixWNjew+kDhvY6i2FITiAu
13+
/htOL7lAFCXIzH/ZexkTxzkdXkekbkTl7rMAdcDvCI2axmlQWh3ZVYoQ/JnXK9l/
14+
v2VbdmjXkGD7I39Pi7a+0yVh+vX0HwqV/DYCjVB9Mi3uGZAv7/gPG6kywKGQ51Qn
15+
PIRKnZ3x64dgU5nZG5y0mbt7Ha354H9i84yRNbYEsGkJiDkq9LmoVUymvhN3KjJd
16+
l7GNzz9shTi948arug==
17+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)