Skip to content

Commit

Permalink
FT: Provide AWS KMS connector for bucket ciphering with TLS
Browse files Browse the repository at this point in the history
Implement KMS Client using aws nodejs official client.
  • Loading branch information
outscale-fba authored and nicolas2bert committed Sep 12, 2024
1 parent 7eb2701 commit 4dc8435
Show file tree
Hide file tree
Showing 5 changed files with 609 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/network/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export const probe = { ProbeServer };
export { default as RoundRobin } from './RoundRobin';
export { default as kmip } from './kmip';
export { default as kmipClient } from './kmip/Client';
export { default as awsClient } from './kmsAWS/Client';
export * as rpc from './rpc/rpc';
export * as level from './rpc/level-net';
276 changes: 276 additions & 0 deletions lib/network/kmsAWS/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
'use strict'; // eslint-disable-line
/* eslint new-cap: "off" */

import errors from '../../errors';
import { Agent } from "https";
import { SecureVersion } from "tls";
import * as werelogs from 'werelogs';
import { KMSClient, CreateKeyCommand, ScheduleKeyDeletionCommand, EncryptCommand, DecryptCommand, GenerateDataKeyCommand, DataKeySpec } from "@aws-sdk/client-kms";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import { AwsCredentialIdentity } from "@smithy/types";
import assert from 'assert';

/**
* Normalize errors according to arsenal definitions
* @param err - an Error instance or a message string
* @returns - arsenal error
*
* @note Copied from the KMIP implementation
*/
function _arsenalError(err: string | Error) {
const messagePrefix = 'AWS_KMS:';
if (typeof err === 'string') {
return errors.InternalError
.customizeDescription(`${messagePrefix} ${err}`);
} else if (
err instanceof Error ||
// INFO: The second part is here only for Jest, to remove when we'll be
// fully migrated to TS
// @ts-expect-error
(err && typeof err.message === 'string')
) {
return errors.InternalError
.customizeDescription(`${messagePrefix} ${err.message}`);
}
return errors.InternalError
.customizeDescription(`${messagePrefix} Unspecified error`);
}

export default class Client {
client: KMSClient;
options: any;

/**
* Construct a high level KMIP driver suitable for cloudserver
* @param options - Instance options
* @param options.kmsAWS - AWS client options
* @param options.kmsAWS.region - KMS region
* @param options.kmsAWS.endpoint - Endpoint URL of the KMS service
* @param options.kmsAWS.ak - Application Key
* @param options.kmsAWS.sk - Secret Key
* @param options.kmsAWS.tls.rejectUnauthorized - default to true, reject unauthenticated TLS connections (set to false to accept auto-signed certificates, useful in development ONLY)
* @param options.kmsAWS.tls.ca - override CA definition(s)
* @param options.kmsAWS.tls.cert - certificate or list of certificates
* @param options.kmsAWS.tls.minVersion - min TLS version accepted, One of 'TLSv1.3', 'TLSv1.2', 'TLSv1.1', or 'TLSv1' (see https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions)
* @param options.kmsAWS.tls.maxVersion - max TLS version accepted, One of 'TLSv1.3', 'TLSv1.2', 'TLSv1.1', or 'TLSv1' (see https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions)
* @param options.kmsAWS.tls.key - private key or list of private keys
*
* This client also looks in the standard AWS configuration files (https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html).
* If no option is passed to this constructor, the client will try to get it from the configuration file.
*
* TLS configuration options are those of nodejs, you can refere to https://nodejs.org/api/tls.html#tlsconnectoptions and https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
*/
constructor(
options: {
kmsAWS: {
region?: string,
endpoint?: string,
ak?: string,
sk?: string,
tls?: {
rejectUnauthorized?: boolean,
ca?: [Buffer] | Buffer,
cert?: [Buffer] | Buffer,
minVersion?: string,
maxVersion?: string,
key?: [Buffer] | Buffer,
}
}
},
) {
let requestHandler: {requestHandler: NodeHttpHandler} | null = null;
const tlsOpts = options.kmsAWS.tls;
if (tlsOpts) {
const agent = new Agent({
rejectUnauthorized: tlsOpts?.rejectUnauthorized,
ca: tlsOpts?.ca,
cert: tlsOpts?.cert,
minVersion: <SecureVersion>tlsOpts?.minVersion,
maxVersion: <SecureVersion>tlsOpts?.maxVersion,
key: tlsOpts?.key,
});

requestHandler = {requestHandler: new NodeHttpHandler({
httpAgent: agent,
httpsAgent: agent,
})}
}

let credentials: {credentials: AwsCredentialIdentity} | null = null;
if (options.kmsAWS.ak && options.kmsAWS.sk) {
credentials = {credentials: {
accessKeyId: options.kmsAWS.ak,
secretAccessKey: options.kmsAWS.sk,
}};
}

this.client = new KMSClient({
region: options.kmsAWS.region,
endpoint: options.kmsAWS.endpoint,
...credentials,
...requestHandler
});
}

/**
* Create a new cryptographic key managed by the server,
* for a specific bucket
* @param bucketName - The bucket name
* @param logger - Werelog logger object
* @param cb - The callback(err: Error, bucketKeyId: String)
*/
createBucketKey(bucketName: string, logger: werelogs.Logger, cb: any) {
logger.debug("AWS KMS: createBucketKey", {bucketName});

const command = new CreateKeyCommand({});
this.client.send(command, (err, data) => {
if (err) {
const error = _arsenalError(err);
logger.error("AWS_KMS::createBucketKey", {err, bucketName});
cb (error);
} else {
logger.debug("AWS KMS: createBucketKey", {bucketName, KeyMetadata: data?.KeyMetadata});
cb(null, data?.KeyMetadata?.KeyId);
}
});
}

/**
* Destroy a cryptographic key managed by the server, for a specific bucket.
* @param bucketKeyId - The bucket key Id
* @param logger - Werelog logger object
* @param cb - The callback(err: Error)
*/
destroyBucketKey(bucketKeyId: string, logger: werelogs.Logger, cb: any) {
logger.debug("AWS KMS: destroyBucketKey", {bucketKeyId: bucketKeyId});

// Schedule a deletion in 7 days (the minimum value on this API)
const command = new ScheduleKeyDeletionCommand({KeyId: bucketKeyId, PendingWindowInDays: 7});
this.client.send(command, (err, data) => {
if (err) {
const error = _arsenalError(err);
logger.error("AWS_KMS::destroyBucketKey", {err});
cb (error);
} else {
// Sanity check
if (data?.KeyState != "PendingDeletion") {
const error = _arsenalError("Key is not in PendingDeletion state")
logger.error("AWS_KMS::destroyBucketKey", {err, data});
cb(error);
} else {
cb();
}
}
});
}

/**
* @param cryptoScheme - crypto scheme version number
* @param masterKeyId - key to retrieve master key
* @param logger - werelog logger object
* @param cb - callback
* @callback called with (err, plainTextDataKey: Buffer, cipheredDataKey: Buffer)
*/
generateDataKey(
cryptoScheme: number,
masterKeyId: string,
logger: werelogs.Logger,
cb: any,
) {
logger.debug("AWS KMS: generateDataKey", {cryptoScheme, masterKeyId});

// Only support cryptoScheme v1
assert.strictEqual (cryptoScheme, 1);

const command = new GenerateDataKeyCommand({KeyId: masterKeyId, KeySpec: DataKeySpec.AES_256});
this.client.send(command, (err, data) => {
if (err) {
const error = _arsenalError(err);
logger.error("AWS_KMS::generateDataKey", {err});
cb (error);
} else if (!data) {
const error = _arsenalError("generateDataKey: empty response");
logger.error("AWS_KMS::generateDataKey empty reponse");
cb (error);
} else {
// Convert to a buffer. This allows the wrapper to use .toString("base64")
cb(null, Buffer.from(data.Plaintext!), Buffer.from(data.CiphertextBlob!));
}
});
}

/**
*
* @param cryptoScheme - crypto scheme version number
* @param masterKeyId - key to retrieve master key
* @param plainTextDataKey - data key
* @param logger - werelog logger object
* @param cb - callback
* @callback called with (err, cipheredDataKey: Buffer)
*/
cipherDataKey(
cryptoScheme: number,
masterKeyId: string,
plainTextDataKey: Buffer,
logger: werelogs.Logger,
cb: any,
) {
logger.debug("AWS KMS: cipherDataKey", {cryptoScheme, masterKeyId});

// Only support cryptoScheme v1
assert.strictEqual (cryptoScheme, 1);

const command = new EncryptCommand({KeyId: masterKeyId, Plaintext: plainTextDataKey});
this.client.send(command, (err, data) => {
if (err) {
const error = _arsenalError(err);
logger.error("AWS_KMS::cipherDataKey", {err});
cb (error);
} else if (!data) {
const error = _arsenalError("cipherDataKey: empty response");
logger.error("AWS_KMS::cipherDataKey empty reponse");
cb (error);
} else {
// Convert to a buffer. This allows the wrapper to use .toString("base64")
cb(null, Buffer.from(data.CiphertextBlob!));
}
});
}

/**
*
* @param cryptoScheme - crypto scheme version number
* @param masterKeyId - key to retrieve master key
* @param cipheredDataKey - data key
* @param logger - werelog logger object
* @param cb - callback
* @callback called with (err, plainTextDataKey: Buffer)
*/
decipherDataKey(
cryptoScheme: number,
masterKeyId: string,
cipheredDataKey: Buffer,
logger: werelogs.Logger,
cb: any,
) {
logger.debug("AWS KMS: decipherDataKey", {cryptoScheme, masterKeyId});

// Only support cryptoScheme v1
assert.strictEqual (cryptoScheme, 1);

const command = new DecryptCommand({CiphertextBlob: cipheredDataKey});
this.client.send(command, (err, data) => {
if (err) {
const error = _arsenalError(err);
logger.error("AWS_KMS::decipherDataKey", {err});
cb (error);
} else if (!data) {
const error = _arsenalError("decipherDataKey: empty response");
logger.error("AWS_KMS::decipherDataKey empty reponse");
cb (error);
} else {
cb(null, Buffer.from(data?.Plaintext!));
}
});
}
}
66 changes: 66 additions & 0 deletions lib/network/kmsAWS/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# AWS KMS Connector

Allows using AWS KMS backend for object encryption. Currently supports AK+SK
for authentication. mTLS can be used for additional security.

## Configuration

Configuration is done using the configuration file.

Supported parameters:

| Config File | Description |
|--------------------|---------------------------------------------------------|
| kmsAWS.region | AWS region to use |
| kmsAWS.endpoint | Endpoint URL |
| kmsAWS.ak | Credentials, Access Key |
| kmsAWS.sk | Credentials, Secret Key |
| kmsAWS.tls | TLS configuration (Object, see below) |

TLS configuration attributes:

| Config File | Description |
|---------------------|--------------------------------------------------------|
| rejectUnauthorized | `false` to disable TLS cert checks (useful in |
| | development, **DON'T** disable in production) |
| minVersion | Min TLS version: 'TLSv1.3', 'TLSv1.2', 'TLSv1.1', or |
| | 'TLSv1' (See [Node.js TLS](https://nodejs.org/api/tls) |
| maxVersion | Max TLS version: 'TLSv1.3', 'TLSv1.2', 'TLSv1.1', or |
| | 'TLSv1' (See [Node.js TLS](https://nodejs.org/api/tls) |
| ca | Filename or array of filenames for CA(s) |
| cert | Filename or array of filenames for certificate(s) |
| key | Filename or array of filenames for private key(s) |

All TLS attributes follow Node.js definitions. See
[Node.js TLS Connect Options](https://nodejs.org/api/tls.html#tlsconnectoptions)
and
[Node.js TLS Secure Context](https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions).

### Configuration Example

```json
{
"kmsAWS": {
"region": "us-east-1",
"endpoint": "https://kms.us-east-1.amazonaws.com",
"ak": "xxxxxxx",
"sk": "xxxxxxx"
}
}

With TLS configuration:

```json
"kmsAWS": {
"region": "us-east-1",
"endpoint": "https://kms.us-east-1.amazonaws.com",
"ak": "xxxxxxx",
"sk": "xxxxxxx",
"tls": {
"rejectUnauthorized": false,
"cert": "mtls.crt.pem",
"key": "mtls.key.pem",
"minVersion": "TLSv1.3"
}
},
```
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"homepage": "https://github.com/scality/Arsenal#readme",
"dependencies": {
"@aws-sdk/client-kms": "^3.485.0",
"@js-sdsl/ordered-set": "^4.4.2",
"@types/async": "^3.2.12",
"@types/utf8": "^3.0.1",
Expand Down Expand Up @@ -63,6 +64,7 @@
"@types/jest": "^27.4.1",
"@types/node": "^17.0.21",
"@types/xml2js": "^0.4.11",
"aws-sdk-client-mock": "^3.0.1",
"eslint": "^8.12.0",
"eslint-config-airbnb": "6.2.0",
"eslint-config-scality": "scality/Guidelines#7.10.2",
Expand Down
Loading

0 comments on commit 4dc8435

Please sign in to comment.