diff --git a/lib/auth/Vault.ts b/lib/auth/Vault.ts index ef80e9a85..1e07af8c9 100644 --- a/lib/auth/Vault.ts +++ b/lib/auth/Vault.ts @@ -384,4 +384,45 @@ export default class Vault { return callback(null, respBody); }); } + + /** + * Calls Vault to retrieve the default encryption key id of the account, or creates it if it doesn't exist. + * + * @param {string} canonicalID - The canonical id of the account for which + * the encryption key id is being retrieved or created. + * @param {Logger} log - logger + * @param {(err: Error | null, data?: { + * canonicalId: string, + * encryptionKeyId: string, + * action: 'retrieved' | 'created' + * }) => void} + * - canonicalId: The canonical id of the account. + * - encryptionKeyId: The retrieved or newly created encryption key id. + * - action: Describes if the key was 'retrieved' or 'created'. + * + * @returns {void} + */ + getOrCreateEncryptionKeyId( + canonicalID: string, + log: Logger, + callback: (err: Error | null, data?: { + canonicalId: string, + encryptionKeyId: string, + action: 'retrieved' | 'created' + }) => void + ) { + log.trace('sending request context params to vault to get or create encryption key id'); + this.client.getOrCreateEncryptionKeyId(canonicalID, { + // @ts-ignore + reqUid: log.getSerializedUids(), + }, (err: Error | null, info?: any) => { + if (err) { + log.debug('received error message from auth provider', + { error: err }); + return callback(err); + } + const result = info.message.body; + return callback(null, result); + }); + } } diff --git a/lib/auth/in_memory/Backend.ts b/lib/auth/in_memory/Backend.ts index 201f0da6a..0d1c8062b 100644 --- a/lib/auth/in_memory/Backend.ts +++ b/lib/auth/in_memory/Backend.ts @@ -179,6 +179,38 @@ class Backend { }; return cb(null, vaultReturnObject); } + + /** + * Retrieves or creates an encryption key id for the specified canonical id. + * + * @param {string} canonicalId - The canonical id of the account for which to retrieve or create the encryption key. + * @param {any} _options - An options object, currently unused. + * @param {(err: Error | null, data?: { + * canonicalId: string, + * encryptionKeyId: string, + * action: 'retrieved' | 'created' + * }) => void} + * - canonicalId: The canonical id of the account. + * - encryptionKeyId: The retrieved or newly created encryption key id. + * - action: Describes if the key was 'retrieved' or 'created'. + * + * @returns {void} + */ + getOrCreateEncryptionKeyId( + canonicalId: string, + _options: any, + cb: (err: null, data: { message: { body: { canonicalId: string, encryptionKeyId: string, action: string } } }) => void + ): void { + return cb(null, { + message: { + body: { + canonicalId, + encryptionKeyId: 'account-level-master-encryption-key', + action: 'retrieved', + } + } + }); + } } class S3AuthBackend extends Backend { diff --git a/lib/models/BucketInfo.ts b/lib/models/BucketInfo.ts index f52931a4a..6d094a2e5 100644 --- a/lib/models/BucketInfo.ts +++ b/lib/models/BucketInfo.ts @@ -28,6 +28,7 @@ export type SSE = { masterKeyId: string; configuredMasterKeyId: string; mandatory: boolean; + isAccountEncryptionEnabled: boolean; }; export type VersioningConfiguration = { @@ -153,10 +154,13 @@ export default class BucketInfo { configuredMasterKeyId, mandatory } = serverSideEncryption; assert.strictEqual(typeof cryptoScheme, 'number'); assert.strictEqual(typeof algorithm, 'string'); - assert.strictEqual(typeof masterKeyId, 'string'); assert.strictEqual(typeof mandatory, 'boolean'); + assert.ok(masterKeyId !== undefined || configuredMasterKeyId !== undefined, 'At least one of masterKeyId or configuredMasterKeyId must be defined'); + if (masterKeyId !== undefined) { + assert.strictEqual(typeof masterKeyId, 'string', 'masterKeyId must be a string'); + } if (configuredMasterKeyId !== undefined) { - assert.strictEqual(typeof configuredMasterKeyId, 'string'); + assert.strictEqual(typeof configuredMasterKeyId, 'string', 'configuredMasterKeyId must be a string'); } } if (versioningConfiguration) { @@ -546,6 +550,21 @@ export default class BucketInfo { } return this._serverSideEncryption.masterKeyId; } + + /** + * Checks if the default encryption is set at the account level instead of the legacy bucket level. + * This method helps to prevent deletion of the account-level master encryption key when deleting buckets. + * + * @returns {boolean} - Returns true if account-level default encryption is enabled, + * false if it uses the legacy bucket level. + */ + isAccountEncryptionEnabled() { + if (!this._serverSideEncryption) { + return false; + } + + return this._serverSideEncryption.isAccountEncryptionEnabled; + } /** * Get bucket name. * @return - bucket name diff --git a/lib/network/index.ts b/lib/network/index.ts index 96e9a41ec..20e54de83 100644 --- a/lib/network/index.ts +++ b/lib/network/index.ts @@ -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 KmsAWSClient } from './kmsAWS/Client'; export * as rpc from './rpc/rpc'; export * as level from './rpc/level-net'; diff --git a/lib/network/kmip/Client.ts b/lib/network/kmip/Client.ts index 05a226329..998aad874 100644 --- a/lib/network/kmip/Client.ts +++ b/lib/network/kmip/Client.ts @@ -2,11 +2,11 @@ /* eslint new-cap: "off" */ import async from 'async'; -import errors from '../../errors'; import TTLVCodec from './codec/ttlv'; import TlsTransport from './transport/tls'; import KMIP from '.'; import * as werelogs from 'werelogs'; +import { arsenalErrorKMIP } from '../utils' const CRYPTOGRAPHIC_OBJECT_TYPE = 'Symmetric Key'; const CRYPTOGRAPHIC_ALGORITHM = 'AES'; @@ -45,31 +45,6 @@ const searchFilter = { 'Response Message/Batch Item/Response Payload/Data', }; -/** - * Normalize errors according to arsenal definitions - * @param err - an Error instance or a message string - * @returns - arsenal error - */ -function _arsenalError(err: string | Error) { - const messagePrefix = 'KMIP:'; - 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`); -} - - /** * Negotiate with the server the use of a recent version of the protocol and * update the low level driver with this new knowledge. @@ -93,7 +68,7 @@ function _negotiateProtocolVersion(client: any, logger: werelogs.Logger, cb: any ]), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::negotiateProtocolVersion', { error, vendorIdentification: client.vendorIdentification }); @@ -105,7 +80,7 @@ function _negotiateProtocolVersion(client: any, logger: werelogs.Logger, cb: any response.lookup(searchFilter.protocolVersionMinor); if (majorVersions.length === 0 || majorVersions.length !== minorVersions.length) { - const error = _arsenalError('No suitable protocol version'); + const error = arsenalErrorKMIP('No suitable protocol version'); logger.error('KMIP::negotiateProtocolVersion', { error, vendorIdentification: client.vendorIdentification }); @@ -128,7 +103,7 @@ function _mapExtensions(client: any, logger: werelogs.Logger, cb: any) { KMIP.Enumeration('Query Function', 'Query Extension Map'), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::mapExtensions', { error, vendorIdentification: client.vendorIdentification }); @@ -137,7 +112,7 @@ function _mapExtensions(client: any, logger: werelogs.Logger, cb: any) { const extensionNames = response.lookup(searchFilter.extensionName); const extensionTags = response.lookup(searchFilter.extensionTag); if (extensionNames.length !== extensionTags.length) { - const error = _arsenalError('Inconsistent extension list'); + const error = arsenalErrorKMIP('Inconsistent extension list'); logger.error('KMIP::mapExtensions', { error, vendorIdentification: client.vendorIdentification }); @@ -161,7 +136,7 @@ function _queryServerInformation(client: any, logger: werelogs.Logger, cb: any) KMIP.Enumeration('Query Function', 'Query Server Information'), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.warn('KMIP::queryServerInformation', { error }); /* no error returned, caller can keep going */ @@ -196,7 +171,7 @@ function _queryOperationsAndObjects(client: any, logger: werelogs.Logger, cb: an KMIP.Enumeration('Query Function', 'Query Objects'), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::queryOperationsAndObjects', { error, vendorIdentification: client.vendorIdentification }); @@ -336,7 +311,7 @@ export default class Client { KMIP.TextString('Unique Identifier', keyIdentifier), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::_activateBucketKey', { error, serverInformation: this.serverInformation }); @@ -345,7 +320,7 @@ export default class Client { const uniqueIdentifier = response.lookup(searchFilter.uniqueIdentifier)[0]; if (uniqueIdentifier !== keyIdentifier) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server did not return the expected identifier'); logger.error('KMIP::cipherDataKey', { error, uniqueIdentifier }); @@ -389,7 +364,7 @@ export default class Client { KMIP.Structure('Template-Attribute', attributes), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::createBucketKey', { error, serverInformation: this.serverInformation }); @@ -400,7 +375,7 @@ export default class Client { const uniqueIdentifier = response.lookup(searchFilter.uniqueIdentifier)[0]; if (createdObjectType !== CRYPTOGRAPHIC_OBJECT_TYPE) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server created an object of wrong type'); logger.error('KMIP::createBucketKey', { error, createdObjectType }); @@ -433,7 +408,7 @@ export default class Client { ]), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::_revokeBucketKey', { error, serverInformation: this.serverInformation }); @@ -442,7 +417,7 @@ export default class Client { const uniqueIdentifier = response.lookup(searchFilter.uniqueIdentifier)[0]; if (uniqueIdentifier !== bucketKeyId) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server did not return the expected identifier'); logger.error('KMIP::_revokeBucketKey', { error, uniqueIdentifier }); @@ -461,7 +436,7 @@ export default class Client { destroyBucketKey(bucketKeyId: string, logger: werelogs.Logger, cb: any) { return this._revokeBucketKey(bucketKeyId, logger, err => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::destroyBucketKey: revocation failed', { error, serverInformation: this.serverInformation }); @@ -471,7 +446,7 @@ export default class Client { KMIP.TextString('Unique Identifier', bucketKeyId), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::destroyBucketKey', { error, serverInformation: this.serverInformation }); @@ -480,7 +455,7 @@ export default class Client { const uniqueIdentifier = response.lookup(searchFilter.uniqueIdentifier)[0]; if (uniqueIdentifier !== bucketKeyId) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server did not return the expected identifier'); logger.error('KMIP::destroyBucketKey', { error, uniqueIdentifier }); @@ -521,7 +496,7 @@ export default class Client { KMIP.ByteString('IV/Counter/Nonce', CRYPTOGRAPHIC_DEFAULT_IV), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::cipherDataKey', { error, serverInformation: this.serverInformation }); @@ -531,7 +506,7 @@ export default class Client { response.lookup(searchFilter.uniqueIdentifier)[0]; const data = response.lookup(searchFilter.data)[0]; if (uniqueIdentifier !== masterKeyId) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server did not return the expected identifier'); logger.error('KMIP::cipherDataKey', { error, uniqueIdentifier }); @@ -571,7 +546,7 @@ export default class Client { KMIP.ByteString('IV/Counter/Nonce', CRYPTOGRAPHIC_DEFAULT_IV), ], (err, response) => { if (err) { - const error = _arsenalError(err); + const error = arsenalErrorKMIP(err); logger.error('KMIP::decipherDataKey', { error, serverInformation: this.serverInformation }); @@ -581,7 +556,7 @@ export default class Client { response.lookup(searchFilter.uniqueIdentifier)[0]; const data = response.lookup(searchFilter.data)[0]; if (uniqueIdentifier !== masterKeyId) { - const error = _arsenalError( + const error = arsenalErrorKMIP( 'Server did not return the right identifier'); logger.error('KMIP::decipherDataKey', { error, uniqueIdentifier }); diff --git a/lib/network/kmsAWS/Client.ts b/lib/network/kmsAWS/Client.ts new file mode 100644 index 000000000..3fcdddaa2 --- /dev/null +++ b/lib/network/kmsAWS/Client.ts @@ -0,0 +1,290 @@ +'use strict'; // eslint-disable-line +/* eslint new-cap: "off" */ + +import errors from '../../errors'; +import { arsenalErrorAWSKMS } from '../utils' +import { Agent } from 'https'; +import { KMS, AWSError } from 'aws-sdk'; +import * as werelogs from 'werelogs'; +import assert from 'assert'; + +type TLSVersion = 'TLSv1.3' | 'TLSv1.2' | 'TLSv1.1' | 'TLSv1'; + +interface KMSOptions { + region?: string; + endpoint?: string; + ak?: string; + sk?: string; + tls?: { + rejectUnauthorized?: boolean; + ca?: Buffer | Buffer[]; + cert?: Buffer | Buffer[]; + minVersion?: TLSVersion; + maxVersion?: TLSVersion; + key?: Buffer | Buffer[]; + }; +} + +interface ClientOptions { + kmsAWS: KMSOptions; +} + +export default class Client { + private _supportsDefaultKeyPerAccount: boolean; + private client: KMS; + + constructor(options: ClientOptions) { + this._supportsDefaultKeyPerAccount = true; + const { tls, ak, sk, region, endpoint } = options.kmsAWS; + + const httpOptions = tls ? { + agent: new Agent({ + rejectUnauthorized: tls.rejectUnauthorized, + ca: tls.ca, + cert: tls.cert, + minVersion: tls.minVersion, + maxVersion: tls.maxVersion, + key: tls.key, + }), + } : undefined; + + const credentials = (ak && sk) ? { + credentials: { + accessKeyId: ak, + secretAccessKey: sk, + }, + } : undefined; + + this.client = new KMS({ + region, + endpoint, + httpOptions, + ...credentials, + }); + } + + get supportsDefaultKeyPerAccount(): boolean { + return this._supportsDefaultKeyPerAccount; + } + + /** + * Safely handles the plaintext buffer by copying it to an isolated buffer + * and zeroing out the original buffer to prevent unauthorized access. + * + * @param plaintext - The original plaintext buffer from AWS KMS. + * @returns A new Buffer containing the isolated plaintext data. + */ + private safePlaintext(plaintext: Buffer): Buffer { + // allocate a new buffer and initialize it directly with plaintext data + const isolatedPlaintext = Buffer.alloc(plaintext.length, plaintext); + // zero out the original plaintext buffer to prevent data leakage + plaintext.fill(0); + + return isolatedPlaintext; + } + + // createBucketKey is a method used by CloudServer to create a default master encryption key per bucket. + // New KMS backends like AWS KMS now allow the customer to use the default master encryption key per account. + // To achieve this, Vault will call createMasterKey and store the master encryption ID in the account metadata. + createBucketKey(bucketName: string, logger: werelogs.Logger, cb: (err: Error | null, keyId?: string) => void): void { + logger.debug("AWS KMS: creating encryption key managed at the bucket level", { bucketName }); + this.createMasterKey(logger, cb); + } + + createMasterKey(logger: werelogs.Logger, cb: (err: Error | null, keyId?: string) => void): void { + logger.debug("AWS KMS: creating master encryption key"); + this.client.createKey({}, (err: AWSError, data) => { + if (err) { + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS: failed to create master encryption key", { err }); + cb(error); + return; + } + logger.debug("AWS KMS: master encryption key created", { KeyMetadata: data?.KeyMetadata }); + cb(null, data?.KeyMetadata?.KeyId); + }); + } + + // destroyBucketKey is a method used by CloudServer to remove the default master encryption key for a bucket. + // New KMS backends like AWS KMS allow customers to delete the default master encryption key at the account level. + // To achieve this, Vault will call deleteMasterKey before deleting the account. + destroyBucketKey(bucketKeyId: string, logger: werelogs.Logger, cb: (err: Error | null) => void): void { + logger.debug("AWS KMS: deleting encryption key managed at the bucket level", { bucketKeyId }); + this.deleteMasterKey(bucketKeyId, logger, cb); + } + + deleteMasterKey(masterKeyId: string, logger: werelogs.Logger, cb: (err: Error | null) => void): void { + logger.debug("AWS KMS: deleting master encryption key", { masterKeyId }); + const params = { + KeyId: masterKeyId, + PendingWindowInDays: 7, + }; + this.client.scheduleKeyDeletion(params, (err: AWSError, data) => { + if (err) { + if (err.code === 'NotFoundException' || err.code === 'KMSInvalidStateException') { + // master key does not exist or is already pending deletion + logger.info('AWS KMS: key does not exist or is already pending deletion', { masterKeyId, error: err }); + cb(null); + return; + } + + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS: failed to delete master encryption key", { err }); + cb(error); + return; + } + + if (data?.KeyState && data.KeyState !== "PendingDeletion") { + const error = arsenalErrorAWSKMS("key is not in PendingDeletion state"); + logger.error("AWS KMS: failed to delete master encryption key", { err, data }); + cb(error); + return; + } + + cb(null); + }); + } + + generateDataKey( + cryptoScheme: number, + masterKeyId: string, + logger: werelogs.Logger, + cb: (err: Error | null, plainTextDataKey?: Buffer, cipheredDataKey?: Buffer) => void + ): void { + logger.debug("AWS KMS: generating data key", { cryptoScheme, masterKeyId }); + assert.strictEqual(cryptoScheme, 1); + + const params = { + KeyId: masterKeyId, + KeySpec: 'AES_256', + }; + + this.client.generateDataKey(params, (err: AWSError, data) => { + if (err) { + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS: failed to generate data key", { err }); + cb(error); + return; + } + + if (!data) { + const error = arsenalErrorAWSKMS("failed to generate data key: empty response"); + logger.error("AWS KMS: failed to generate data key: empty response"); + cb(error); + return; + } + + const isolatedPlaintext = this.safePlaintext(data.Plaintext as Buffer); + + logger.debug("AWS KMS: data key generated"); + cb(null, isolatedPlaintext, Buffer.from(data.CiphertextBlob as Uint8Array)); + }); + } + + cipherDataKey( + cryptoScheme: number, + masterKeyId: string, + plainTextDataKey: Buffer, + logger: werelogs.Logger, + cb: (err: Error | null, cipheredDataKey?: Buffer) => void + ): void { + logger.debug("AWS KMS: ciphering data key", { cryptoScheme, masterKeyId }); + assert.strictEqual(cryptoScheme, 1); + + const params = { + KeyId: masterKeyId, + Plaintext: plainTextDataKey, + }; + + this.client.encrypt(params, (err: AWSError, data) => { + if (err) { + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS: failed to cipher data key", { err }); + cb(error); + return; + } + + if (!data) { + const error = arsenalErrorAWSKMS("failed to cipher data key: empty response"); + logger.error("AWS KMS: failed to cipher data key: empty response"); + cb(error); + return; + } + + logger.debug("AWS KMS: data key ciphered"); + cb(null, Buffer.from(data.CiphertextBlob as Uint8Array)); + return; + }); + } + + decipherDataKey( + cryptoScheme: number, + masterKeyId: string, + cipheredDataKey: Buffer, + logger: werelogs.Logger, + cb: (err: Error | null, plainTextDataKey?: Buffer) => void + ): void { + logger.debug("AWS KMS: deciphering data key", { cryptoScheme, masterKeyId }); + assert.strictEqual(cryptoScheme, 1); + + const params = { + CiphertextBlob: cipheredDataKey, + }; + + this.client.decrypt(params, (err: AWSError, data) => { + if (err) { + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS: failed to decipher data key", { err }); + cb(error); + return; + } + + if (!data) { + const error = arsenalErrorAWSKMS("failed to decipher data key: empty response"); + logger.error("AWS KMS: failed to decipher data key: empty response"); + cb(error); + return; + } + + const isolatedPlaintext = this.safePlaintext(data.Plaintext as Buffer); + + logger.debug("AWS KMS: data key deciphered"); + cb(null, isolatedPlaintext); + }); + } + + /** + * NOTE1: S3C-4833 KMS healthcheck is disabled in CloudServer + * NOTE2: The best approach for implementing the AWS KMS health check is still under consideration. + * In the meantime, this method is commented out to prevent potential issues related to costs or permissions. + * + * Reasons for commenting out: + * - frequent API calls can lead to increased expenses. + * - access key secret key used must have `kms:ListKeys` permissions + * + * Future potential actions: + * - implement caching mechanisms to reduce the number of API calls. + * - differentiate between error types (e.g., 500 vs. 403) for more effective error handling. + */ + /* + healthcheck(logger: werelogs.Logger, cb: (err: Error | null) => void): void { + logger.debug("AWS KMS: performing healthcheck"); + + const params = { + Limit: 1, + }; + + this.client.listKeys(params, (err, data) => { + if (err) { + const error = arsenalErrorAWSKMS(err); + logger.error("AWS KMS healthcheck: failed to list keys", { err }); + cb(error); + return; + } + + logger.debug("AWS KMS healthcheck: list keys succeeded"); + cb(null); + }); + } + */ +} diff --git a/lib/network/kmsAWS/README.md b/lib/network/kmsAWS/README.md new file mode 100644 index 000000000..5002ea325 --- /dev/null +++ b/lib/network/kmsAWS/README.md @@ -0,0 +1,67 @@ +# 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" + } + }, +``` diff --git a/lib/network/utils.ts b/lib/network/utils.ts new file mode 100644 index 000000000..3c7fe1c2b --- /dev/null +++ b/lib/network/utils.ts @@ -0,0 +1,33 @@ +import errors from '../errors'; + +/** + * Normalize errors according to arsenal definitions with a custom prefix + * @param err - an Error instance or a message string + * @param messagePrefix - prefix for the error message + * @returns - arsenal error + */ +function _normalizeArsenalError(err: string | Error, messagePrefix: string) { + 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 function arsenalErrorKMIP(err: string | Error) { + return _normalizeArsenalError(err, 'KMIP:'); +} + +export function arsenalErrorAWSKMS(err: string | Error) { + return _normalizeArsenalError(err, 'AWS_KMS:'); +} diff --git a/package.json b/package.json index 8bff9c782..f87625ede 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "engines": { "node": ">=16" }, - "version": "7.10.46-2", + "version": "7.10.46-3", "description": "Common utilities for the S3 project components", "main": "build/index.js", "repository": { diff --git a/tests/functional/kmsAWS/highlevel.spec.js b/tests/functional/kmsAWS/highlevel.spec.js new file mode 100644 index 000000000..ab6255634 --- /dev/null +++ b/tests/functional/kmsAWS/highlevel.spec.js @@ -0,0 +1,301 @@ +const sinon = require('sinon'); +const assert = require('assert'); +const Client = require('../../../lib/network/kmsAWS/Client').default; + +describe('KmsAWSClient', () => { + const logger = { + info: () => {}, + debug: () => {}, + error: () => {}, + }; + + let client; + let createKeyStub; + let scheduleKeyDeletionStub; + let generateDataKeyStub; + let encryptStub; + let decryptStub; + let listKeysStub; + + beforeEach(() => { + client = new Client({ + kmsAWS: { + region: 'us-east-1', + ak: 'ak', + sk: 'sk', + }, + }); + + const kmsInstance = client.client; + createKeyStub = sinon.stub(kmsInstance, 'createKey'); + scheduleKeyDeletionStub = sinon.stub(kmsInstance, 'scheduleKeyDeletion'); + generateDataKeyStub = sinon.stub(kmsInstance, 'generateDataKey'); + encryptStub = sinon.stub(kmsInstance, 'encrypt'); + decryptStub = sinon.stub(kmsInstance, 'decrypt'); + listKeysStub = sinon.stub(kmsInstance, 'listKeys'); + }); + + afterEach(() => { + createKeyStub.restore(); + scheduleKeyDeletionStub.restore(); + generateDataKeyStub.restore(); + encryptStub.restore(); + decryptStub.restore(); + listKeysStub.restore(); + }); + + it('should support default encryption key per account', () => { + assert.strictEqual(client.supportsDefaultKeyPerAccount, true); + }); + + it('should create a new master encryption key', done => { + const mockResponse = { + KeyMetadata: { + KeyId: 'mock-key-id', + }, + }; + createKeyStub.yields(null, mockResponse); + + client.createMasterKey(logger, (err, keyId) => { + assert.ifError(err); + assert.strictEqual(keyId, 'mock-key-id'); + assert(createKeyStub.calledOnce); + done(); + }); + }); + + it('should handle errors creating a new master encryption key', done => { + const mockError = new Error('mock error'); + createKeyStub.yields(mockError, null); + + client.createMasterKey(logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(createKeyStub.calledOnce); + done(); + }); + }); + + it('should create a bucket-level key', done => { + const mockResponse = { + KeyMetadata: { + KeyId: 'mock-bucket-key-id', + }, + }; + createKeyStub.yields(null, mockResponse); + + client.createBucketKey('bucketName', logger, (err, keyId) => { + assert.ifError(err); + assert.strictEqual(keyId, 'mock-bucket-key-id'); + assert(createKeyStub.calledOnce); + done(); + }); + }); + + it('should handle errors creating a bucket-level key', done => { + const mockError = new Error('mock error'); + createKeyStub.yields(mockError, null); + + client.createBucketKey('bucketName', logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(createKeyStub.calledOnce); + done(); + }); + }); + + it('should delete an existing key on bucket deletion', done => { + const mockResponse = { + KeyState: 'PendingDeletion', + }; + scheduleKeyDeletionStub.yields(null, mockResponse); + + client.destroyBucketKey('mock-key-id', logger, err => { + assert.ifError(err); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should handle errors deleting an existing key on bucket deletion', done => { + const mockError = new Error('mock delete error'); + scheduleKeyDeletionStub.yields(mockError, null); + + client.destroyBucketKey('mock-key-id', logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should delete an existing key on account deletion', done => { + const mockResponse = { + KeyId: 'mocked-kms-key-id', + KeyState: 'PendingDeletion', + PendingWindowInDays: 7, + }; + scheduleKeyDeletionStub.yields(null, mockResponse); + + client.deleteMasterKey('mock-key-id', logger, err => { + assert.ifError(err); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should delete an existing key on account deletion without KeyState', done => { + const mockResponse = { + KeyId: 'mocked-kms-key-id', + PendingWindowInDays: 7, + }; + scheduleKeyDeletionStub.yields(null, mockResponse); + + client.deleteMasterKey('mock-key-id', logger, err => { + assert.ifError(err); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should handle errors deleting an existing key on account deletion', done => { + const mockError = new Error('mock delete error'); + scheduleKeyDeletionStub.yields(mockError, null); + + client.deleteMasterKey('mock-key-id', logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should handle NotFoundException when deleting master key', done => { + const mockError = new Error('NotFoundException'); + mockError.code = 'NotFoundException'; + + scheduleKeyDeletionStub.yields(mockError, null); + + client.deleteMasterKey('mock-key-id', logger, err => { + assert.ifError(err); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should handle KMSInvalidStateException when deleting master key', done => { + const mockError = new Error('KMSInvalidStateException'); + mockError.code = 'KMSInvalidStateException'; + + scheduleKeyDeletionStub.yields(mockError, null); + + client.deleteMasterKey('mock-key-id', logger, err => { + assert.ifError(err); + assert(scheduleKeyDeletionStub.calledOnce); + done(); + }); + }); + + it('should generate a data key for ciphering', done => { + const mockResponse = { + Plaintext: Buffer.from('plaintext'), + CiphertextBlob: Buffer.from('ciphertext'), + KeyId: 'mocked-kms-key-id', + }; + generateDataKeyStub.yields(null, mockResponse); + + client.generateDataKey(1, 'mock-key-id', logger, (err, plainText, cipherText) => { + assert.ifError(err); + assert.strictEqual(plainText.toString(), 'plaintext'); + assert.strictEqual(cipherText.toString(), 'ciphertext'); + assert(generateDataKeyStub.calledOnce); + done(); + }); + }); + + it('should handle errors generating a data key', done => { + const mockError = new Error('mock error'); + generateDataKeyStub.yields(mockError, null); + + client.generateDataKey(1, 'mock-key-id', logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(generateDataKeyStub.calledOnce); + done(); + }); + }); + + it('should allow ciphering a data key', done => { + const mockResponse = { + CiphertextBlob: Buffer.from('ciphertext'), + KeyId: 'mocked-kms-key-id', + }; + encryptStub.yields(null, mockResponse); + + client.cipherDataKey(1, 'mock-key-id', Buffer.from('plaintext'), logger, (err, cipherText) => { + assert.ifError(err); + assert.strictEqual(cipherText.toString(), 'ciphertext'); + assert(encryptStub.calledOnce); + done(); + }); + }); + + it('should handle errors ciphering a data key', done => { + const mockError = new Error('mock cipher error'); + encryptStub.yields(mockError, null); + + client.cipherDataKey(1, 'mock-key-id', Buffer.from('plaintext'), logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(encryptStub.calledOnce); + done(); + }); + }); + + it('should allow deciphering a data key', done => { + const mockResponse = { + Plaintext: Buffer.from('plaintext'), + KeyId: 'mocked-kms-key-id', + }; + decryptStub.yields(null, mockResponse); + + client.decipherDataKey(1, 'mock-key-id', Buffer.from('ciphertext'), logger, (err, plainText) => { + assert.ifError(err); + assert.strictEqual(plainText.toString(), 'plaintext'); + assert(decryptStub.calledOnce); + done(); + }); + }); + + it('should handle errors deciphering a data key', done => { + const mockError = new Error('mock decipher error'); + decryptStub.yields(mockError, null); + + client.decipherDataKey(1, 'mock-key-id', Buffer.from('ciphertext'), logger, err => { + assert.strictEqual(err.message, 'InternalError'); + assert(decryptStub.calledOnce); + done(); + }); + }); + + it.skip('should check the health of the KMS connection', done => { + const mockResponse = { + Keys: [ + { KeyId: 'mock-key-id' }, + ], + }; + listKeysStub.yields(null, mockResponse); + + client.healthcheck(logger, err => { + assert.ifError(err); + assert(listKeysStub.calledOnce); + done(); + }); + }); + + it.skip('should return a failed health check when list keys is unsuccessful', done => { + const mockError = new Error('mock listKeys error'); + listKeysStub.yields(mockError, null); + + client.healthcheck(logger, err => { + assert(err); + assert.strictEqual(err.message, 'InternalError'); + assert(listKeysStub.calledOnce); + done(); + }); + }); +});