Skip to content

Commit

Permalink
feat(client): Allow specifying grpc CallCredentials (#1261)
Browse files Browse the repository at this point in the history
  • Loading branch information
mjameswh authored Oct 17, 2023
1 parent 35c6005 commit c4bb5cc
Show file tree
Hide file tree
Showing 7 changed files with 407 additions and 13 deletions.
55 changes: 44 additions & 11 deletions packages/client/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,47 @@ export interface ConnectionOptions {
address?: string;

/**
* TLS configuration.
* Pass a falsy value to use a non-encrypted connection or `true` or `{}` to
* connect with TLS without any customization.
* TLS configuration. Pass a falsy value to use a non-encrypted connection,
* or `true` or `{}` to connect with TLS without any customization.
*
* For advanced scenario, a prebuilt {@link grpc.ChannelCredentials} object
* may instead be specified using the {@link credentials} property.
*
* Either {@link credentials} or this may be specified for configuring TLS
*
* @default TLS is disabled
*/
tls?: TLSConfig | boolean | null;

/**
* Channel credentials, create using the factory methods defined {@link https://grpc.github.io/grpc/node/grpc.credentials.html | here}
* gRPC channel credentials.
*
* `ChannelCredentials` are things like SSL credentials that can be used to secure a connection.
* There may be only one `ChannelCredentials`. They can be created using some of the factory
* methods defined {@link https://grpc.github.io/grpc/node/grpc.credentials.html | here}
*
* Specifying a prebuilt `ChannelCredentials` should only be required for advanced use cases.
* For simple TLS use cases, using the {@link tls} property is recommended. To register
* `CallCredentials` (eg. metadata-based authentication), use the {@link callCredentials} property.
*
* Either {@link tls} or this may be specified for configuring TLS
*/
credentials?: grpc.ChannelCredentials;

/**
* gRPC call credentials.
*
* `CallCredentials` generaly modify metadata; they can be attached to a connection to affect all method
* calls made using that connection. They can be created using some of the factory methods defined
* {@link https://grpc.github.io/grpc/node/grpc.credentials.html | here}
*
* If `callCredentials` are specified, they will be composed with channel credentials
* (either the one created implicitely by using the {@link tls} option, or the one specified
* explicitly through {@link credentials}). Notice that gRPC doesn't allow registering
* `callCredentials` on insecure connections.
*/
callCredentials?: grpc.CallCredentials[];

/**
* GRPC Channel arguments
*
Expand Down Expand Up @@ -84,7 +110,9 @@ export interface ConnectionOptions {
connectTimeout?: Duration;
}

export type ConnectionOptionsWithDefaults = Required<Omit<ConnectionOptions, 'tls' | 'connectTimeout'>> & {
export type ConnectionOptionsWithDefaults = Required<
Omit<ConnectionOptions, 'tls' | 'connectTimeout' | 'callCredentials'>
> & {
connectTimeoutMs: number;
};

Expand Down Expand Up @@ -114,7 +142,7 @@ function addDefaults(options: ConnectionOptions): ConnectionOptionsWithDefaults
* - Add default port to address if port not specified
*/
function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
const { tls: tlsFromConfig, credentials, ...rest } = options || {};
const { tls: tlsFromConfig, credentials, callCredentials, ...rest } = options || {};
if (rest.address) {
// eslint-disable-next-line prefer-const
let [host, port] = rest.address.split(':', 2);
Expand All @@ -128,10 +156,9 @@ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
}
return {
...rest,
credentials: grpc.credentials.createSsl(
tls.serverRootCACertificate,
tls.clientCertPair?.key,
tls.clientCertPair?.crt
credentials: grpc.credentials.combineChannelCredentials(
grpc.credentials.createSsl(tls.serverRootCACertificate, tls.clientCertPair?.key, tls.clientCertPair?.crt),
...(callCredentials ?? [])
),
channelArgs: {
...rest.channelArgs,
Expand All @@ -144,7 +171,13 @@ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions {
},
};
} else {
return rest;
return {
...rest,
credentials: grpc.credentials.combineChannelCredentials(
credentials ?? grpc.credentials.createInsecure(),
...(callCredentials ?? [])
),
};
}
}

Expand Down
108 changes: 106 additions & 2 deletions packages/test/src/test-client-connection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import util from 'node:util';
import path from 'node:path';
import fs from 'node:fs/promises';
import test from 'ava';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
Expand All @@ -20,6 +21,23 @@ async function bindLocalhost(server: grpc.Server): Promise<number> {
return await util.promisify(server.bindAsync.bind(server))('127.0.0.1:0', grpc.ServerCredentials.createInsecure());
}

async function bindLocalhostTls(server: grpc.Server): Promise<number> {
const caCert = await fs.readFile(path.resolve(__dirname, `../tls_certs/test-ca.crt`));
const serverChainCert = await fs.readFile(path.resolve(__dirname, `../tls_certs/test-server-chain.crt`));
const serverKey = await fs.readFile(path.resolve(__dirname, `../tls_certs/test-server.key`));
const credentials = grpc.ServerCredentials.createSsl(
caCert,
[
{
cert_chain: serverChainCert,
private_key: serverKey,
},
],
true
);
return await util.promisify(server.bindAsync.bind(server))('localhost:0', credentials);
}

test('withMetadata / withDeadline set the CallContext for RPC call', async (t) => {
const server = new grpc.Server();
let gotTestHeaders = false;
Expand All @@ -32,7 +50,7 @@ test('withMetadata / withDeadline set the CallContext for RPC call', async (t) =
temporal.api.workflowservice.v1.IRegisterNamespaceRequest,
temporal.api.workflowservice.v1.IRegisterNamespaceResponse
>,
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IRegisterNamespaceResponse>
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse>
) {
const [testValue] = call.metadata.get('test');
const [otherValue] = call.metadata.get('otherKey');
Expand Down Expand Up @@ -111,7 +129,7 @@ test('grpc retry passes request and headers on retry, propagates responses', asy
temporal.api.workflowservice.v1.IDescribeWorkflowExecutionRequest,
temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse
>,
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IRegisterNamespaceResponse>
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse>
) {
const { namespace } = call.request;
if (typeof namespace === 'string') {
Expand Down Expand Up @@ -172,3 +190,89 @@ test('Default keepalive settings are set while maintaining user provided channel
// User setting overrides default
t.is(channelArgs['grpc.keepalive_permit_without_calls'], 0);
});

test('Can configure TLS + call credentials', async (t) => {
const meta = Array<string[]>();

const server = new grpc.Server();

server.addService(workflowServiceProtoDescriptor.temporal.api.workflowservice.v1.WorkflowService.service, {
getSystemInfo(
call: grpc.ServerUnaryCall<
temporal.api.workflowservice.v1.IGetSystemInfoRequest,
temporal.api.workflowservice.v1.IGetSystemInfoResponse
>,
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IGetSystemInfoResponse>
) {
const [aValue] = call.metadata.get('a');
const [authorizationValue] = call.metadata.get('authorization');
if (typeof aValue === 'string' && typeof authorizationValue === 'string') {
meta.push([aValue, authorizationValue]);
}

const response: temporal.api.workflowservice.v1.IGetSystemInfoResponse = {
serverVersion: 'test',
capabilities: undefined,
};
callback(null, response);
},

describeWorkflowExecution(
call: grpc.ServerUnaryCall<
temporal.api.workflowservice.v1.IDescribeWorkflowExecutionRequest,
temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse
>,
callback: grpc.sendUnaryData<temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse>
) {
const [aValue] = call.metadata.get('a');
const [authorizationValue] = call.metadata.get('authorization');
if (typeof aValue === 'string' && typeof authorizationValue === 'string') {
meta.push([aValue, authorizationValue]);
}

const response: temporal.api.workflowservice.v1.IDescribeWorkflowExecutionResponse = {
workflowExecutionInfo: { execution: { workflowId: 'test' } },
};
callback(null, response);
},
});
const port = await bindLocalhostTls(server);
server.start();

let callNumber = 0;
const oauth2Client: grpc.OAuth2Client = {
getRequestHeaders: async () => {
const accessToken = `oauth2-access-token-${++callNumber}`;
return { authorization: `Bearer ${accessToken}` };
},
};

// Default interceptor config with backoff factor of 1 to speed things up
// const interceptor = makeGrpcRetryInterceptor(defaultGrpcRetryOptions({ factor: 1 }));
const conn = await Connection.connect({
address: `localhost:${port}`,
metadata: { a: 'bc' },
tls: {
serverRootCACertificate: await fs.readFile(path.resolve(__dirname, `../tls_certs/test-ca.crt`)),
clientCertPair: {
crt: await fs.readFile(path.resolve(__dirname, `../tls_certs/test-client-chain.crt`)),
key: await fs.readFile(path.resolve(__dirname, `../tls_certs/test-client.key`)),
},
serverNameOverride: 'Server',
},
callCredentials: [grpc.credentials.createFromGoogleCredential(oauth2Client)],
});

// Make three calls
await conn.workflowService.describeWorkflowExecution({ namespace: 'a' });
await conn.workflowService.describeWorkflowExecution({ namespace: 'b' });
await conn.workflowService.describeWorkflowExecution({ namespace: 'c' });

// Check that both connection level metadata and call credentials metadata are sent correctly
t.deepEqual(meta, [
['bc', 'Bearer oauth2-access-token-1'],
['bc', 'Bearer oauth2-access-token-2'],
['bc', 'Bearer oauth2-access-token-3'],
['bc', 'Bearer oauth2-access-token-4'],
]);
});
31 changes: 31 additions & 0 deletions packages/test/tls_certs/test-ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFYzCCA0ugAwIBAgIUK/OctBa1W2oRcLVgf08rSIeRuE4wDQYJKoZIhvcNAQEL
BQAwQTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMSUwIwYDVQQDDBwgVGVzdCBS
b290IENBIC0gRE8gTk9UIFRSVVNUMB4XDTIzMTAxNjIxMDcwMVoXDTMzMTAxMzIx
MDcwMVowQTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMSUwIwYDVQQDDBwgVGVz
dCBSb290IENBIC0gRE8gTk9UIFRSVVNUMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
MIICCgKCAgEAx6jqT2kK4dFoqj+4rWAdLO6h3rTfsh1PTYBiPZrZCvi3+DEoZRi8
yy8XkJzH4wZ43EG0Q52CYVNKAM9auh9ahu5g00h4kOCL3hjVIsG9Pnw9k/ArXNcC
WRC3R5Gv18cDq9U8mDxJk4P6d3Tx0iWEZ5/+6dEtSWlIWhHFj1zU+VoCB0FLvQrr
tbPYzV8DCkXo62h78EssFQbz4Fqs5htpRN4EUESofQKq3VOlKzgq1NyJsxw9xILY
sYeHo9PVMbPtGvPY118qQWGS3/eCAVmHknOYWVYjk1TRYNgE/EdoD82Psm1Xcf7+
/2NQnGsQgGUvls68Q2kVzJbybxmlAF8u188DPy5qHrn3hAViDFKfbjOuW/GoF5uA
ZHrCAlG+zrhAEdDo5gtCE2MFE2J+mk7R29VOqDOy7IElJwNkh8NXrIdsWwkLTPSo
hXFjZkh2yxqmHJp2mEZoI7mrPEjpTkmIGKq/QQXh4e/GAKUmA/bYUGXeAWRQNH+P
/mygHYRJXtN7ulx1vAb2WlV8fuy9X3cqW95B3pLNwpcD5nwVrLGcMWtZd5kKJifW
ZzDlxEfGcubIGHCqyhw90nNJwsrRMGpTIl4c174MrYqy/t3uPwhmsg5tipedn5xn
GqQ1TO2jpOuiBMv1x4ccsg5cV28ZGawWIpp+KpYY+HcgZ+W3SNm6zYUCAwEAAaNT
MFEwHQYDVR0OBBYEFPlVgrLSBFw9+0jg8CZdAzLBNhSuMB8GA1UdIwQYMBaAFPlV
grLSBFw9+0jg8CZdAzLBNhSuMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL
BQADggIBAGI7XXLC3qwHM01H6vlmmCkgeQMcHCeCaQSxB77039R1gBizX1SXMHmi
I5ThmmOAGaXHgQkT6js2j4nUqAZV++bNWAJVmHnMIGKbLMH5VAOx0XczENllpAnb
5jK/vE5Zz473DERY7Tj3YHE+JeD+XIBDz0ngcCHMJo3yig05kl2Oq29QvseUAwiQ
mZ5Rt3LmnG2q/21JykELqB/eowFoKqAt/Y+KIISJH1gKtZNKUP2LUCZsR76AUcsD
UqZ2FnpuD6c1zW3vOx3r3g3iAdDOy0NvVJbTsfPDMxL2Kxq+f7m0JiifJ3lmdDfd
2tjP4O2ZwC8lgKS2T/HYaHZ8zL06DYaVceG5u1f0qit+ubTDYXp47bk5LEiGDhxo
m7kKoxAuh2vzf2QRO70QfJKScMY5/mKPq5Pku4DkW2N7m9X1HabDeo82sGzNlj2E
UxDs2p1gPuIy/USF/PC06TYDuUkbYQCS6YZSwnw2XZ7mOZgprsiOROSuhFuNTODM
yQLdw2i/Z/PaSzeDjf2JNf0Rou/Qmfc8wbtQ8rKIm9J24a6Tg0odHTw49ipr4d5e
jRNXZCHoVmQg9ar6vSf+ncz9Iw9iAhZqPV9RL5Jr7IdYVEqRjt96vsITwF5s2A+0
d3pn2Qj1WDucVFguQqJM+5BIcMg4RjJEaDDRYDSMHGTW1e3ACt3O
-----END CERTIFICATE-----
61 changes: 61 additions & 0 deletions packages/test/tls_certs/test-client-chain.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
-----BEGIN CERTIFICATE-----
MIIFMzCCAxugAwIBAgIUEIHOmHY61jeOj4iMtUsM2CgIcmcwDQYJKoZIhvcNAQEL
BQAwQTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMSUwIwYDVQQDDBwgVGVzdCBS
b290IENBIC0gRE8gTk9UIFRSVVNUMB4XDTIzMTAxNjIxMDk1MVoXDTMzMTAxMzIx
MDk1MVowETEPMA0GA1UEAwwGQ2xpZW50MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
MIICCgKCAgEAvxSSdvnEvhCoRs+rN82BE/bUi/5bPtQJJdo4Qb2CDosu0Yqn2JgP
k6dnIYn7/PXqsH+xNANQuFP9CQl54k4WtKAhq6J1krhyTDo0D8F6+jz/CisThVYF
bfU4Uos7qiRxIZ8Iqek3RPsWvfd4FLrys4rW5cQ3bzlN3soH0TxPifYv1M9kVl3m
7IkJIGzsQQcbMmt8/Fu9gfAJyWCx1BSVKdcWGtoKddok7FWlMcENE1h130r8oNvP
CnMJ1pYTRAx+oKdeTK+88gxCt4LlhMmr6UHHJ/1NrjsLtouYw/gImeikkefEW23g
b6irKlJCYu3H+MntclbkJYEHp92KIP3BNQ5NI2d5Tig8YyJLsQ00VzTe+RK9dvsN
4ocD4aXToSvFRJaS/a6J35rKt7+5gN+v4dX7G2s3d/39HkKhBeVegXecCl4oUIRl
3l4xaIXKNCHLho5g+S7wxa+xW+uwI7Z1LnxxpN77XhEEISjQQ/B8qYdrE86tibr8
TQ6j0lQ1kj3foRDc2nfi+rq0trgIry2GRKP0KJ41nLtS0S2EUPiaBjV8Pgm0N3Um
mErrxsb1dRDNPMNpIq/sc9Xs0j5QqeZ5EoKYOwc21n+KpArZAq+wQ2BBsjzIA4Fb
lmdCHF+mIE2TFmJ9K2wdpXaGN62LFGLO2eA2HDoUAJhKZWtSGz8W4cECAwEAAaNT
MFEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUwR2Ye/aZnGUGN93QEXRc6g+r
/30wHwYDVR0jBBgwFoAU+VWCstIEXD37SODwJl0DMsE2FK4wDQYJKoZIhvcNAQEL
BQADggIBAIaoNgVnmlxUY51sC2SxDrFN+k0P9dAF+ZAmtY+S6dsQP9YevLWb0l+i
PtI66uIuZ+2YdGsyuNecudstNw4+pA2gGow8ZzuPE4VH9HfQZBFNv9dK7e1rWbol
XpChLDvjFJtmuyPJeafeEs6u3xbjPVcQ7VPSGQbe0n7LhrFRFn+hRdyHVXBzcuUJ
Z4/FQs52P0ontgR707Jc4+xNcEUgkCSmNbxenRGr6NPIA/C6fIOZ4qQiNK4qAtOe
k9nQsc5Dda1XbrfYOaNY5wmme7jQFqJtvbS+JuAAjWuPfyw28mUJ7PwlQGo/lU15
DzV+eUw7OEXBygpy9YXE6rDb96cbx8Ne7065gzq24Ucl5bo+tCnoONNv8Pvb/NHM
Ewy2RYXSq5iTtxdiRE4J1PW1dxEAddBhvT6kf0Rr0rZqIdmtEVZOoLDu3c96wbB4
9EzfqIQOGbnEgCvneRL3VMFeezVksMdRR6XTvsdZlhYcxjkmNdbQOh9pPebnn9cm
5mJiGoRvv0Hmhwsiecs9fSVgWY0qZm/2m8bfxueAgbSVjy7Cd7gqiKB//s8Ws1bi
mhWOd5CTifpjjqxX9SBFei+Z2BB04DD107Hkf4d/DRbKk29FNFjjrVzpsvItvwud
1bhj31jtdKvcs7dNKOwRF38jE6dDM9x12SFTviCR/WLQs3+tomQB
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFYzCCA0ugAwIBAgIUK/OctBa1W2oRcLVgf08rSIeRuE4wDQYJKoZIhvcNAQEL
BQAwQTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMSUwIwYDVQQDDBwgVGVzdCBS
b290IENBIC0gRE8gTk9UIFRSVVNUMB4XDTIzMTAxNjIxMDcwMVoXDTMzMTAxMzIx
MDcwMVowQTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMSUwIwYDVQQDDBwgVGVz
dCBSb290IENBIC0gRE8gTk9UIFRSVVNUMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
MIICCgKCAgEAx6jqT2kK4dFoqj+4rWAdLO6h3rTfsh1PTYBiPZrZCvi3+DEoZRi8
yy8XkJzH4wZ43EG0Q52CYVNKAM9auh9ahu5g00h4kOCL3hjVIsG9Pnw9k/ArXNcC
WRC3R5Gv18cDq9U8mDxJk4P6d3Tx0iWEZ5/+6dEtSWlIWhHFj1zU+VoCB0FLvQrr
tbPYzV8DCkXo62h78EssFQbz4Fqs5htpRN4EUESofQKq3VOlKzgq1NyJsxw9xILY
sYeHo9PVMbPtGvPY118qQWGS3/eCAVmHknOYWVYjk1TRYNgE/EdoD82Psm1Xcf7+
/2NQnGsQgGUvls68Q2kVzJbybxmlAF8u188DPy5qHrn3hAViDFKfbjOuW/GoF5uA
ZHrCAlG+zrhAEdDo5gtCE2MFE2J+mk7R29VOqDOy7IElJwNkh8NXrIdsWwkLTPSo
hXFjZkh2yxqmHJp2mEZoI7mrPEjpTkmIGKq/QQXh4e/GAKUmA/bYUGXeAWRQNH+P
/mygHYRJXtN7ulx1vAb2WlV8fuy9X3cqW95B3pLNwpcD5nwVrLGcMWtZd5kKJifW
ZzDlxEfGcubIGHCqyhw90nNJwsrRMGpTIl4c174MrYqy/t3uPwhmsg5tipedn5xn
GqQ1TO2jpOuiBMv1x4ccsg5cV28ZGawWIpp+KpYY+HcgZ+W3SNm6zYUCAwEAAaNT
MFEwHQYDVR0OBBYEFPlVgrLSBFw9+0jg8CZdAzLBNhSuMB8GA1UdIwQYMBaAFPlV
grLSBFw9+0jg8CZdAzLBNhSuMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL
BQADggIBAGI7XXLC3qwHM01H6vlmmCkgeQMcHCeCaQSxB77039R1gBizX1SXMHmi
I5ThmmOAGaXHgQkT6js2j4nUqAZV++bNWAJVmHnMIGKbLMH5VAOx0XczENllpAnb
5jK/vE5Zz473DERY7Tj3YHE+JeD+XIBDz0ngcCHMJo3yig05kl2Oq29QvseUAwiQ
mZ5Rt3LmnG2q/21JykELqB/eowFoKqAt/Y+KIISJH1gKtZNKUP2LUCZsR76AUcsD
UqZ2FnpuD6c1zW3vOx3r3g3iAdDOy0NvVJbTsfPDMxL2Kxq+f7m0JiifJ3lmdDfd
2tjP4O2ZwC8lgKS2T/HYaHZ8zL06DYaVceG5u1f0qit+ubTDYXp47bk5LEiGDhxo
m7kKoxAuh2vzf2QRO70QfJKScMY5/mKPq5Pku4DkW2N7m9X1HabDeo82sGzNlj2E
UxDs2p1gPuIy/USF/PC06TYDuUkbYQCS6YZSwnw2XZ7mOZgprsiOROSuhFuNTODM
yQLdw2i/Z/PaSzeDjf2JNf0Rou/Qmfc8wbtQ8rKIm9J24a6Tg0odHTw49ipr4d5e
jRNXZCHoVmQg9ar6vSf+ncz9Iw9iAhZqPV9RL5Jr7IdYVEqRjt96vsITwF5s2A+0
d3pn2Qj1WDucVFguQqJM+5BIcMg4RjJEaDDRYDSMHGTW1e3ACt3O
-----END CERTIFICATE-----
52 changes: 52 additions & 0 deletions packages/test/tls_certs/test-client.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC/FJJ2+cS+EKhG
z6s3zYET9tSL/ls+1Akl2jhBvYIOiy7RiqfYmA+Tp2chifv89eqwf7E0A1C4U/0J
CXniTha0oCGronWSuHJMOjQPwXr6PP8KKxOFVgVt9ThSizuqJHEhnwip6TdE+xa9
93gUuvKzitblxDdvOU3eygfRPE+J9i/Uz2RWXebsiQkgbOxBBxsya3z8W72B8AnJ
YLHUFJUp1xYa2gp12iTsVaUxwQ0TWHXfSvyg288KcwnWlhNEDH6gp15Mr7zyDEK3
guWEyavpQccn/U2uOwu2i5jD+AiZ6KSR58RbbeBvqKsqUkJi7cf4ye1yVuQlgQen
3Yog/cE1Dk0jZ3lOKDxjIkuxDTRXNN75Er12+w3ihwPhpdOhK8VElpL9ronfmsq3
v7mA36/h1fsbazd3/f0eQqEF5V6Bd5wKXihQhGXeXjFohco0IcuGjmD5LvDFr7Fb
67AjtnUufHGk3vteEQQhKNBD8Hyph2sTzq2JuvxNDqPSVDWSPd+hENzad+L6urS2
uAivLYZEo/QonjWcu1LRLYRQ+JoGNXw+CbQ3dSaYSuvGxvV1EM08w2kir+xz1ezS
PlCp5nkSgpg7BzbWf4qkCtkCr7BDYEGyPMgDgVuWZ0IcX6YgTZMWYn0rbB2ldoY3
rYsUYs7Z4DYcOhQAmEpla1IbPxbhwQIDAQABAoICAEuk4MCx60WVAZEa2DzcoZte
LVGIbeXm+gIerAO2epy4U94HRqAzvoLlFCpOXlALqI+b1XJyV4vJUBQ6SKKi6FE0
TXANff8J/tGXfxG3tjAHYq3LVMyFu9uGZvgif4nBKHo3Y64kEcnAnwWwSLzoL3mN
XrqSHaHt7RpkH4khF5nVuKTGP4IDZY5BR7gq9rJdllI1BENBLDoa5TzwByYeydhI
+krCA78ZD2HyG9YhB0Sf0fYGURF7QzDvTrdBLTpUufJun6G7NpEZ8nWEn8kcL27F
qAp4OD7fyCjJhb4a3IjVdQT/3BeX3XBGtRAphXd1i6M9iT8pD+Oa+4VkajDaVBg0
vEAtK+Z7Zlei8m4eHxiPCJCpZTma0M7sUPGlSSYqGCItRcWDkBqWVxUshJs0LMTZ
ewrjfxmur6m0Z4QkRhEW5G0dYhLy/rNRk0cao+AEG2A7lBuW+PCKQXjsF5R0xwkj
JVldPfplADta3Nv2EGYsad1CTWgBTVf2d/EsdXRPjAIIDYvVRioSfB/LiO9gvudt
khfo6xKfQeAoxfa9BjiTxtMLBfNOoPVLb4nH3YVLjEDuD09Z1lc+DV0VKOS3hjcb
/LXKdh45GkcR2iZtHBUgPkJrVsPkQ/9YiarFdrYTLhdqAy6vlV/CD6WT6tQwhZy0
Sh4b2VXfTpOB5NML3PWjAoIBAQD8gFET3vKhGPb7obx48rJmuxU4zTRuNFNgJqfk
if7UvEMkGOJYXQieJCtwXXFk6S6Cdq5DDn1fF3K7Q5KUP6DYBAw8HO0ttCP/NaBD
dqE9RQpj1FNBp5eYYBKpgaeu8nUF1f6tezV1oaxOHM50TDQC/B9q+Z1S4qv5p0ps
npcgub+1sfVVpCBB9mfKVOmAwediVO/Xb7m1GUuyWBKTv+kA/Qnc3u7QLX3WgUO6
cvOl/sciU4ss4XUmNZFmNRUypCl7uLrKwvzWZB6558dhOQWrjAfngss/ts7QeY39
FOCrtmWJzdrwObGmkpP2EqcPOCoiJGWOybbYXFySQ6y5cuDPAoIBAQDBumFxAu3E
VioNeOPYBHXXR+xWZ5qcGbks0L5zt/H3TO3MK6UQmu1Fx59zj0YpMwC446LuzVNZ
S5q5Z4oLC+i4SUlqsyq60L9YqF4zvjACL++ybCxpTTr8UNXgJphdTR3hU7eS1Rgi
RKitDZuIqvLGC4uxFHTS47oY486iQu3m0iQyMDCf61qFoMck6XcqIdby74V5Zj5V
EFOcWuVT4DiMEcSumNL2WGqd9/OOSlHs9aMyqG/Hs7u9BpHQdz+filjqBelH5Pxl
ZvO1lfPFhMMA+/4KZ+9uXjBADi5gL0MEjqjxFyZsHLcSA3/sN2+CNpKYfd07Av2G
Kad0LPTgjBhvAoIBAQC1xnqX34y0RRCpHkpcl/uu0Uf52GDCZZEQS0Pa1y1JYS7E
sdVg37jwgAF1pw+XIfeFnILfa3L+HhdOkNrZNuVIHcHhFMH6gRDZDXYOmzyAq09L
hvNY9JnB7IgC14AsBggQ53ms0mIuCPHOWhaWyrU24OKNVJ3Zqa080R5XC+SofpBw
8Wg4+yrt0cHueyqOswksVRFE+v2qTkecYKMfEq1fNMsA4szxuY45+l0TwOV+vugE
4jfzW2vG0hGWjuhvyJbEprxyc+UpQnKMSzvR1gcq9GhMVnCTsbs5ggiiBYGonGqE
xlmzTxWBYUx7ffoejsRmR+WE4dpr8DIEagvShc0pAoIBAGhUVXGlICMiPBdxSVLm
ZAgCJdcKiIsUl1L5P/fV0itadJ4Fyyk8Jhf0P3zeZex+GR95CCAO15o5SyQaDI+S
ZEwKu80InDRrgwDd+41mGTi0VCQEeY5kFyYW6neCkX99rl/Q1AXxWMPrseMwdtiN
J51cTmiZGakRFwGcfYWJAdHSzcdxiF43Q1K/mT/Zs2EvRDEkqP8N/veUiVKk6OfY
0tssHn3gs4wTZaeZBsNUZvZz+uWnLDuiIbLUvOZFsi/bM7MkZ9NeEEcTwJ/EF7oB
m3sGOnOkMZ+Aff+hI3yNN0xW/8iGrRyAAl9jHxs5Z4X6mcwhzGihXaNI/3NjWqUr
DWUCggEAfmBqdu/DZXA0zdtk1huuPqfrEFykgeOEjeIzLbdGcoWqNmNnG26Xk7eR
zdbBQuMq8r15L3oDEwyX3her3jpOgOHohlZtMnuz7P1NoCB7dfQuNiNhFOxmLuNC
YxjR9+mFr8K+zYRgTmfL1ReKKHZrbWCLQozBmJfbdJPVMfYAZS26WiOgDLK5PeVu
9C4hQ0PKKqtCekqzdZSuC2Q8vQ6/6F+9H1xD2rDoZhw/a3RBsEXg+3t68bBSW9Vr
NotKVWnjtgb+TWHnTqCSJ/WNRqRXOCuuUFFGyBQlx4ceEgytDI/xqPYJg2TI6oBM
Zr1g+Q1GkJfMU7DDI5U9/RYdKJbu9A==
-----END PRIVATE KEY-----
Loading

0 comments on commit c4bb5cc

Please # to comment.