Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow update of PGPSecret and PrivateKey #20

Merged
merged 9 commits into from
Dec 27, 2018
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions build-custom-resource-handlers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
set -euo pipefail

compile="tsc --alwaysStrict
--inlineSourceMap
--lib ES2017
--module CommonJS
--moduleResolution Node
--noFallthroughCasesInSwitch
--noImplicitAny
--noImplicitReturns
--noImplicitThis
--noUnusedLocals
--noUnusedParameters
--removeComments
--strict
--target ES2017
--types node"

for handler in pgp-secret private-key certificate-signing-request
do
echo "Building CustomResource handler ${handler}"
${compile} --outDir "./custom-resource-handlers/bin/${handler}" "./custom-resource-handlers/src/${handler}.ts" ./custom-resource-handlers/src/_*.ts
mv "./custom-resource-handlers/bin/${handler}/${handler}.js" "./custom-resource-handlers/bin/${handler}/index.js"
done
95 changes: 95 additions & 0 deletions custom-resource-handlers/src/_cloud-formation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import https = require('https');
import url = require('url');

/**
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html
*/
export function sendResponse(event: Event,
status: Status,
physicalResourceId: string,
data: { [name: string]: string | undefined },
reason?: string) {
const responseBody = JSON.stringify({
Data: data,
LogicalResourceId: event.LogicalResourceId,
PhysicalResourceId: physicalResourceId,
Reason: reason,
RequestId: event.RequestId,
StackId: event.StackId,
Status: status,
}, null, 2);

// tslint:disable-next-line:no-console
console.log(`Response body: ${responseBody}`);

const parsedUrl = url.parse(event.ResponseURL);
const options: https.RequestOptions = {
headers: {
'content-length': responseBody.length,
'content-type': '',
},
hostname: parsedUrl.hostname,
method: 'PUT',
path: parsedUrl.path,
port: parsedUrl.port || 443,
};

return new Promise((ok, ko) => {
// tslint:disable-next-line:no-console
console.log('Sending response...');

const req = https.request(options, resp => {
// tslint:disable-next-line:no-console
console.log(`Received HTTP ${resp.statusCode} (${resp.statusMessage})`);
if (resp.statusCode === 200) {
return ok();
}
ko(new Error(`Unexpected error sending resopnse to CloudFormation: HTTP ${resp.statusCode} (${resp.statusMessage})`));
});

req.on('error', ko);
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
req.write(responseBody);

req.end();
});
}

export enum Status {
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}

export enum RequestType {
CREATE = 'Create',
UPDATE = 'Update',
DELETE = 'Delete',
}

/** @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-requests.html */
export type Event = CreateEvent | UpdateEvent | DeleteEvent;

export interface CloudFormationEventBase {
readonly RequestType: RequestType;
readonly ResponseURL: string;
readonly StackId: string;
readonly RequestId: string;
readonly ResourceType: string;
readonly LogicalResourceId: string;
readonly ResourceProperties: { [name: string]: any };
}

export interface CreateEvent extends CloudFormationEventBase {
readonly RequestType: RequestType.CREATE;
readonly PhysicalResourceId: undefined;
}

export interface UpdateEvent extends CloudFormationEventBase {
readonly RequestType: RequestType.UPDATE;
readonly PhysicalResourceId: string;
readonly OldResourceProperties: { [name: string]: any };
}

export interface DeleteEvent extends CloudFormationEventBase {
readonly RequestType: RequestType.DELETE;
readonly PhysicalResourceId: string;
}
19 changes: 19 additions & 0 deletions custom-resource-handlers/src/_exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import childProcess = require('child_process');
import process = require('process');

export = function _exec(command: string, ...args: string[]): Promise<string> {
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
return new Promise<string>((ok, ko) => {
const child = childProcess.spawn(command, args, { env: process.env, shell: false, stdio: ['ignore', 'pipe', 'inherit'] });
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
const chunks = new Array<Buffer>();

child.stdout.on('data', chunk => chunks.push(chunk));

child.once('error', ko);
child.once('exit', (code, signal) => {
if (code === 0) {
return ok(Buffer.concat(chunks).toString('utf8'));
}
ko(new Error(signal != null ? `Killed by ${signal}` : `Exited with status ${code}`));
});
});
};
90 changes: 90 additions & 0 deletions custom-resource-handlers/src/_lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
* @see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html
*/
export interface Context {
/**
* The name of the Lambda function
*/
readonly functionName: string;

/**
* The version of the function
*/
readonly functionVersion: string;

/**
* The Amazon Resource Name (ARN) used to invoke the function. Indicates if the invoker specified a version number
* or alias.
*/
readonly invokedFunctionArn: string;

/**
* The amount of memory configured on the function.
*/
readonly memoryLimitInMB: number;

/**
* The identifier of the invocation request?
*/
readonly awsRequestId: string;

/**
* The log group for the function.
*/
readonly logGroupName: string;

/**
* The log stream for the function instance.
*/
readonly logStreamName: string;

/**
* Set to false to send the response right away when the callback executes, instead of waiting for the Node.js event
* loop to be empty. If false, any outstanding events will continue to run during the next invocation.
*/
callbackWaitsForEmptyEventLoop: boolean;

/**
* For mobile apps, information about the Amazon Cognito identity that authorized the request.
*/
identity?: {
/**
* The authenticated Amazon Cognito identity.
*/
cognitoIdentityId: string;

/**
* The Amazon Cognito identity pool that authorized the invocation.
*/
cognitoIdentityPoolId: string;
};

/**
* For mobile apps, client context provided to the Lambda invoker by the client application.
*/
clientContext?: {
client: {
installation_id: string;
app_title: string;
app_version_name: string;
app_version_code: string;
app_package_name: string;
};
env: {
platform_version: string;
platform: string;
make: string;
model: string;
locale: string;
};
/**
* Custom values set by the mobile application.
*/
Custom: { [name: string]: any };
}

/**
* Returns the number of milliseconds left before the execution times out.
*/
getRemainingTimeInMillis(): number;
}
15 changes: 15 additions & 0 deletions custom-resource-handlers/src/_rmrf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import fs = require('fs');
import path = require('path');
import util = require('util');

export = async function _rmrf(filePath: string): Promise<void> {
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
const stat = await util.promisify(fs.stat)(filePath);
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
if (stat.isDirectory()) {
for (const child of await util.promisify(fs.readdir)(filePath)) {
await _rmrf(path.join(filePath, child));
}
await util.promisify(fs.rmdir)(filePath);
} else {
await util.promisify(fs.unlink)(filePath);
}
};
17 changes: 17 additions & 0 deletions custom-resource-handlers/src/_secrets-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import aws = require('aws-sdk');

export async function resolveCurrentVersionId(secretId: string,
client: aws.SecretsManager = new aws.SecretsManager()): Promise<string> {
const request: aws.SecretsManager.ListSecretVersionIdsRequest = { SecretId: secretId };
do {
const response = await client.listSecretVersionIds(request).promise();
request.NextToken = response.NextToken;
if (!response.Versions) { continue; }
for (const version of response.Versions) {
if (version.VersionId && version.VersionStages && version.VersionStages.indexOf('AWSCURRENT') !== -1) {
return version.VersionId;
}
}
} while (request.NextToken != null);
throw new Error(`Unable to determine the current VersionId of ${secretId}`);
}
113 changes: 113 additions & 0 deletions custom-resource-handlers/src/certificate-signing-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import aws = require('aws-sdk');
import fs = require('fs');
import os = require('os');
import path = require('path');
import util = require('util');

import cfn = require('./_cloud-formation');
import _exec = require('./_exec');
import lambda = require('./_lambda');
import _rmrf = require('./_rmrf');

const secretsManager = new aws.SecretsManager();

export async function main(event: cfn.Event, context: lambda.Context): Promise<void> {
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
try {
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
// tslint:disable-next-line:no-console
console.log(`Input event: ${JSON.stringify(event)}`);
const attributes = await handleEvent(event, context);
await cfn.sendResponse(event,
cfn.Status.SUCCESS,
event.LogicalResourceId || event.PhysicalResourceId || context.logStreamName,
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
attributes);
} catch (e) {
// tslint:disable-next-line:no-console
console.error(e);
await cfn.sendResponse(event,
cfn.Status.FAILED,
event.LogicalResourceId,
{ CSR: '', SelfSignedCertificate: '' },
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
e.message);
}
}

interface ResourceAttributes {
CSR: string;
SelfSignedCertificate: string;

[name: string]: string | undefined;
}

async function handleEvent(event: cfn.Event, _context: lambda.Context): Promise<ResourceAttributes> {
switch (event.RequestType) {
case cfn.RequestType.CREATE:
case cfn.RequestType.UPDATE:
return _createSelfSignedCertificate(event);
case cfn.RequestType.DELETE:
// Nothing to do - this is not a "Physical" resource
return { CSR: '', SelfSignedCertificate: '' };
}
}

async function _createSelfSignedCertificate(event: cfn.Event): Promise<ResourceAttributes> {
const tempDir = await util.promisify(fs.mkdtemp)(path.join(os.tmpdir(), 'x509CSR-'));
RomainMuller marked this conversation as resolved.
Show resolved Hide resolved
try {
const configFile = await _makeCsrConfig(event, tempDir);
const pkeyFile = await _retrievePrivateKey(event, tempDir);
const csrFile = path.join(tempDir, 'csr.pem');
await _exec('openssl', 'req', '-config', configFile,
'-key', pkeyFile,
'-out', csrFile,
'-new');
const certFile = path.join(tempDir, 'cert.pem');
await _exec('openssl', 'x509', '-in', csrFile,
'-out', certFile,
'-req',
'-signkey', pkeyFile,
'-days', '365');
return {
CSR: await util.promisify(fs.readFile)(csrFile, { encoding: 'utf8' }),
SelfSignedCertificate: await util.promisify(fs.readFile)(certFile, { encoding: 'utf8' }),
};
} finally {
await _rmrf(tempDir);
}
}

async function _makeCsrConfig(event: cfn.Event, dir: string): Promise<string> {
const file = path.join(dir, 'csr.config');
await util.promisify(fs.writeFile)(file, [
'[ req ]',
'default_md = sha256',
'distinguished_name = dn',
'prompt = no',
'req_extensions = extensions',
'string_mask = utf8only',
'utf8 = yes',
'',
'[ dn ]',
`CN = ${event.ResourceProperties.DnCommonName}`,
`C = ${event.ResourceProperties.DnCountry}`,
`ST = ${event.ResourceProperties.DnStateOrProvince}`,
`L = ${event.ResourceProperties.DnLocality}`,
`O = ${event.ResourceProperties.DnOrganizationName}`,
`OU = ${event.ResourceProperties.DnOrganizationalUnitName}`,
`emailAddress = ${event.ResourceProperties.DnEmailAddress}`,
'',
'[ extensions ]',
`extendedKeyUsage = ${event.ResourceProperties.ExtendedKeyUsage}`,
`keyUsage = ${event.ResourceProperties.KeyUsage}`,
'subjectKeyIdentifier = hash',
].join('\n'), { encoding: 'utf8' });
return file;
}

async function _retrievePrivateKey(event: cfn.Event, dir: string): Promise<string> {
const file = path.join(dir, 'private_key.pem');
const secret = await secretsManager.getSecretValue({
SecretId: event.ResourceProperties.PrivateKeySecretId,
VersionId: event.ResourceProperties.PrivateKeySecretVersionId,
}).promise();
await util.promisify(fs.writeFile)(file, secret.SecretString!, { encoding: 'utf8' });
return file;
}
Loading