diff --git a/modules/branch-keystore-node/src/branch_keystore.ts b/modules/branch-keystore-node/src/branch_keystore.ts index 8dcd3797..8f07d08f 100644 --- a/modules/branch-keystore-node/src/branch_keystore.ts +++ b/modules/branch-keystore-node/src/branch_keystore.ts @@ -16,6 +16,10 @@ import { decryptBranchKey, } from './branch_keystore_helpers' import { KMS_CLIENT_USER_AGENT, TABLE_FIELD } from './constants' +import { + createBranchAndBeaconKeys, + versionActiveBranchKey, +} from './key_helpers' import { IBranchKeyStorage, @@ -45,8 +49,15 @@ interface IBranchKeyStoreNode { //= type=implication //# - [GetKeyStoreInfo](#getkeystoreinfo) getKeyStoreInfo(): KeyStoreInfoOutput + //= aws-encryption-sdk-specification/framework/branch-key-store.md#operations + //= type=implication + //# - [VersionKey](#versionkey) + versionKey(input: VersionKeyInput): Promise + //= aws-encryption-sdk-specification/framework/branch-key-store.md#operations + //= type=implication + //# - [CreateKey](#createkey) + createKey(input?: CreateKeyInput): Promise } - //= aws-encryption-sdk-specification/framework/branch-key-store.md#getkeystoreinfo //= type=implication //# This MUST include: @@ -64,6 +75,19 @@ export interface KeyStoreInfoOutput { kmsConfiguration: KmsConfig } +export interface VersionKeyInput { + branchKeyIdentifier: string +} + +export interface CreateKeyInput { + branchKeyIdentifier?: string + encryptionContext?: { [key: string]: string } +} + +export interface CreateKeyOutput { + branchKeyIdentifier: string +} + export class BranchKeyStoreNode implements IBranchKeyStoreNode { public declare readonly logicalKeyStoreName: string public declare readonly kmsConfiguration: Readonly @@ -383,6 +407,83 @@ export class BranchKeyStoreNode implements IBranchKeyStoreNode { return branchKeyMaterials } + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //# The CreateKey caller MUST provide: + //# - An optional branch key id + //# - An optional encryption context + async createKey(input?: CreateKeyInput): Promise { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //# If the Keystore's KMS Configuration is `Discovery` or `MRDiscovery`, + //# this operation MUST fail. + needs( + typeof this.kmsConfiguration._config === 'object' && + ('identifier' in this.kmsConfiguration._config || + 'mrkIdentifier' in this.kmsConfiguration._config), + 'CreateKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //# If an optional branch key id is provided and no encryption context is provided + //# this operation MUST fail. + if (input?.branchKeyIdentifier) { + needs( + input.encryptionContext && + Object.keys(input.encryptionContext).length > 0, + 'If branch key identifier is provided, encryption context must also be provided' + ) + } + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //# If no branch key id is provided, then this operation MUST create a + //# version 4 UUID to be used as the branch key id. + const branchKeyIdentifier = input?.branchKeyIdentifier || v4() + const customEncryptionContext = input?.encryptionContext || {} + + await createBranchAndBeaconKeys({ + branchKeyIdentifier, + customEncryptionContext, + logicalKeyStoreName: this.logicalKeyStoreName, + kmsConfiguration: this.kmsConfiguration, + grantTokens: this.grantTokens, + kmsClient: this.kmsClient, + ddbClient: (this.storage as any).ddbClient, + ddbTableName: (this.storage as any).ddbTableName, + }) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //# If writing to the keystore succeeds, + //# the operation MUST return the branch-key-id that maps to both the branch key and the beacon key. + return { branchKeyIdentifier } + } + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# On invocation, the caller: + //# - MUST supply a `branch-key-id` + async versionKey(input: VersionKeyInput): Promise { + needs(input.branchKeyIdentifier, 'MUST supply a branch-key-id') + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# If the Keystore's KMS Configuration is `Discovery` or `MRDiscovery`, + //# this operation MUST immediately fail. + needs( + typeof this.kmsConfiguration._config === 'object' && + ('identifier' in this.kmsConfiguration._config || + 'mrkIdentifier' in this.kmsConfiguration._config), + 'VersionKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + + await versionActiveBranchKey({ + branchKeyIdentifier: input.branchKeyIdentifier, + logicalKeyStoreName: this.logicalKeyStoreName, + kmsConfiguration: this.kmsConfiguration, + grantTokens: this.grantTokens, + kmsClient: this.kmsClient, + ddbClient: (this.storage as any).ddbClient, + ddbTableName: (this.storage as any).ddbTableName, + storage: this.storage, + }) + } + //= aws-encryption-sdk-specification/framework/branch-key-store.md#getkeystoreinfo //= type=implication //# This operation MUST return the keystore information in this keystore configuration. diff --git a/modules/branch-keystore-node/src/key_helpers.ts b/modules/branch-keystore-node/src/key_helpers.ts new file mode 100644 index 00000000..4aaf9405 --- /dev/null +++ b/modules/branch-keystore-node/src/key_helpers.ts @@ -0,0 +1,455 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + KMSClient, + GenerateDataKeyWithoutPlaintextCommand, + ReEncryptCommand, +} from '@aws-sdk/client-kms' +import { + DynamoDBClient, + TransactWriteItemsCommand, +} from '@aws-sdk/client-dynamodb' +import { v4 } from 'uuid' +import { needs } from '@aws-crypto/material-management' +import { KmsKeyConfig } from './kms_config' +import { + BRANCH_KEY_IDENTIFIER_FIELD, + TYPE_FIELD, + BRANCH_KEY_FIELD, + KEY_CREATE_TIME_FIELD, + HIERARCHY_VERSION_FIELD, + TABLE_FIELD, + BRANCH_KEY_TYPE_PREFIX, + BRANCH_KEY_ACTIVE_TYPE, + BRANCH_KEY_ACTIVE_VERSION_FIELD, + BEACON_KEY_TYPE_VALUE, + CUSTOM_ENCRYPTION_CONTEXT_FIELD_PREFIX, + KMS_FIELD, +} from './constants' +import { IBranchKeyStorage } from './types' + +interface CreateKeyParams { + branchKeyIdentifier: string + customEncryptionContext: { [key: string]: string } + logicalKeyStoreName: string + kmsConfiguration: Readonly + grantTokens?: ReadonlyArray + kmsClient: KMSClient + ddbClient: DynamoDBClient + ddbTableName: string +} + +interface VersionKeyParams { + branchKeyIdentifier: string + logicalKeyStoreName: string + kmsConfiguration: Readonly + grantTokens?: ReadonlyArray + kmsClient: KMSClient + ddbClient: DynamoDBClient + ddbTableName: string + storage: IBranchKeyStorage +} + +//= aws-encryption-sdk-specification/framework/branch-key-store.md#branch-key-and-beacon-key-creation +//# - `timestamp`: a timestamp for the current time. +//# This timestamp MUST be in ISO 8601 format in UTC, to microsecond precision +//# (e.g. "YYYY-MM-DDTHH:mm:ss.ssssssZ") +function getCurrentTimestamp(): string { + const now = new Date() + return now.toISOString().replace('Z', '000Z') +} + +//= aws-encryption-sdk-specification/framework/branch-key-store.md#active-encryption-context +//# The ACTIVE encryption context value of the `type` attribute MUST equal to `"branch:ACTIVE"`. +//# The ACTIVE encryption context MUST have a `version` attribute. +//# The `version` attribute MUST store the branch key version formatted like `"branch:version:"` + `version`. +function buildActiveEncryptionContext(decryptOnlyContext: { + [key: string]: string +}): { [key: string]: string } { + const activeContext = { ...decryptOnlyContext } + activeContext[BRANCH_KEY_ACTIVE_VERSION_FIELD] = activeContext[TYPE_FIELD] + activeContext[TYPE_FIELD] = BRANCH_KEY_ACTIVE_TYPE + return activeContext +} + +function toAttributeMap( + encryptionContext: { [key: string]: string }, + ciphertextBlob: Uint8Array +): { [key: string]: any } { + const item: { [key: string]: any } = {} + + for (const [key, value] of Object.entries(encryptionContext)) { + if (key === TABLE_FIELD) continue + //= aws-encryption-sdk-specification/framework/branch-key-store.md#writing-branch-key-and-beacon-key-to-keystore + //# - "hierarchy-version" (N): 1 + if (key === HIERARCHY_VERSION_FIELD) { + item[key] = { N: value } + } else { + item[key] = { S: value } + } + } + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#writing-branch-key-and-beacon-key-to-keystore + //# - "enc" (B): the wrapped DECRYPT_ONLY Branch Key `CiphertextBlob` from the KMS operation + item[BRANCH_KEY_FIELD] = { B: ciphertextBlob } + + return item +} + +function getKmsKeyArn( + kmsConfiguration: Readonly +): string | undefined { + return typeof kmsConfiguration._config === 'object' && + 'identifier' in kmsConfiguration._config + ? kmsConfiguration._config.identifier + : typeof kmsConfiguration._config === 'object' && + 'mrkIdentifier' in kmsConfiguration._config + ? kmsConfiguration._config.mrkIdentifier + : undefined +} + +//= aws-encryption-sdk-specification/framework/branch-key-store.md#decrypt_only-encryption-context +//# The DECRYPT_ONLY encryption context MUST NOT have a `version` attribute. +//# The `type` attribute MUST stores the branch key version formatted like `"branch:version:"` + `version`. +function buildDecryptOnlyEncryptionContext( + branchKeyIdentifier: string, + branchKeyVersion: string, + timestamp: string, + logicalKeyStoreName: string, + kmsArn: string, + customEncryptionContext: { [key: string]: string } +): { [key: string]: string } { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#encryption-context + //# - MUST have a `branch-key-id` attribute + //# - MUST have a `type` attribute + //# - MUST have a `create-time` attribute + //# - MUST have a `tablename` attribute to store the logicalKeyStoreName + //# - MUST have a `kms-arn` attribute + //# - MUST have a `hierarchy-version` + //# - MUST NOT have a `enc` attribute + const context: { [key: string]: string } = { + [BRANCH_KEY_IDENTIFIER_FIELD]: branchKeyIdentifier, + [TYPE_FIELD]: `${BRANCH_KEY_TYPE_PREFIX}${branchKeyVersion}`, + [KEY_CREATE_TIME_FIELD]: timestamp, + [TABLE_FIELD]: logicalKeyStoreName, + [KMS_FIELD]: kmsArn, + [HIERARCHY_VERSION_FIELD]: '1', + } + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#custom-encryption-context + //# To avoid name collisions each added attribute from the custom encryption context + //# MUST be prefixed with `aws-crypto-ec:`. + for (const [key, value] of Object.entries(customEncryptionContext)) { + context[`${CUSTOM_ENCRYPTION_CONTEXT_FIELD_PREFIX}${key}`] = value + } + + return context +} + +//= aws-encryption-sdk-specification/framework/branch-key-store.md#beacon-key-encryption-context +//# The Beacon key encryption context value of the `type` attribute MUST equal to `"beacon:ACTIVE"`. +//# The Beacon key encryption context MUST NOT have a `version` attribute. +function buildBeaconEncryptionContext(decryptOnlyContext: { + [key: string]: string +}): { [key: string]: string } { + const beaconContext = { ...decryptOnlyContext } + beaconContext[TYPE_FIELD] = BEACON_KEY_TYPE_VALUE + return beaconContext +} + +//= aws-encryption-sdk-specification/framework/branch-key-store.md#branch-key-and-beacon-key-creation +//# This operation MUST create a branch key and a beacon key +//# according to the Branch Key and Beacon Key Creation section. +export async function createBranchAndBeaconKeys( + params: CreateKeyParams +): Promise { + const { + branchKeyIdentifier, + customEncryptionContext, + logicalKeyStoreName, + kmsConfiguration, + grantTokens, + kmsClient, + ddbClient, + ddbTableName, + } = params + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#branch-key-and-beacon-key-creation + //# - `version`: a new guid. This guid MUST be version 4 UUID + const branchKeyVersion = v4() + const timestamp = getCurrentTimestamp() + + const kmsKeyArn = getKmsKeyArn(kmsConfiguration) + needs(kmsKeyArn, 'KMS Key ARN is required') + + const decryptOnlyContext = buildDecryptOnlyEncryptionContext( + branchKeyIdentifier, + branchKeyVersion, + timestamp, + logicalKeyStoreName, + kmsKeyArn, + customEncryptionContext + ) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#wrapped-branch-key-creation + //# The operation MUST call AWS KMS API GenerateDataKeyWithoutPlaintext + const decryptOnlyResponse = await kmsClient.send( + new GenerateDataKeyWithoutPlaintextCommand({ + KeyId: kmsKeyArn, + NumberOfBytes: 32, + EncryptionContext: decryptOnlyContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + needs( + decryptOnlyResponse.CiphertextBlob, + 'Failed to generate DECRYPT_ONLY branch key' + ) + + const activeContext = buildActiveEncryptionContext(decryptOnlyContext) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#wrapped-branch-key-creation + //# The operation MUST call AWS KMS API ReEncrypt + const activeResponse = await kmsClient.send( + new ReEncryptCommand({ + SourceKeyId: kmsKeyArn, + SourceEncryptionContext: decryptOnlyContext, + CiphertextBlob: decryptOnlyResponse.CiphertextBlob, + DestinationKeyId: kmsKeyArn, + DestinationEncryptionContext: activeContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + needs(activeResponse.CiphertextBlob, 'Failed to generate ACTIVE branch key') + + const beaconContext = buildBeaconEncryptionContext(decryptOnlyContext) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#branch-key-and-beacon-key-creation + //# The operation MUST call AWS KMS GenerateDataKeyWithoutPlaintext for beacon key + const beaconResponse = await kmsClient.send( + new GenerateDataKeyWithoutPlaintextCommand({ + KeyId: kmsKeyArn, + NumberOfBytes: 32, + EncryptionContext: beaconContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + needs(beaconResponse.CiphertextBlob, 'Failed to generate beacon key') + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#writing-branch-key-and-beacon-key-to-keystore + //# The call to Amazon DynamoDB TransactWriteItems MUST use the configured Amazon DynamoDB Client to make the call. + await ddbClient.send( + new TransactWriteItemsCommand({ + TransactItems: [ + { + Put: { + TableName: ddbTableName, + Item: toAttributeMap( + decryptOnlyContext, + decryptOnlyResponse.CiphertextBlob + ), + ConditionExpression: 'attribute_not_exists(#bkid)', + ExpressionAttributeNames: { + '#bkid': BRANCH_KEY_IDENTIFIER_FIELD, + }, + }, + }, + { + Put: { + TableName: ddbTableName, + Item: toAttributeMap(activeContext, activeResponse.CiphertextBlob), + ConditionExpression: 'attribute_not_exists(#bkid)', + ExpressionAttributeNames: { + '#bkid': BRANCH_KEY_IDENTIFIER_FIELD, + }, + }, + }, + { + Put: { + TableName: ddbTableName, + Item: toAttributeMap(beaconContext, beaconResponse.CiphertextBlob), + ConditionExpression: 'attribute_not_exists(#bkid)', + ExpressionAttributeNames: { + '#bkid': BRANCH_KEY_IDENTIFIER_FIELD, + }, + }, + }, + ], + }) + ) +} +//= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey +//# On invocation, the caller: +//# - MUST supply a `branch-key-id` +export async function versionActiveBranchKey( + params: VersionKeyParams +): Promise { + const { + branchKeyIdentifier, + logicalKeyStoreName, + kmsConfiguration, + grantTokens, + kmsClient, + ddbClient, + ddbTableName, + storage, + } = params + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# VersionKey MUST first get the active version for the branch key from the keystore + //# by calling AWS DDB `GetItem` using the `branch-key-id` as the Partition Key + //# and `"branch:ACTIVE"` value as the Sort Key. + const activeKey = await storage.getEncryptedActiveBranchKey( + branchKeyIdentifier + ) + + needs( + activeKey.branchKeyId === branchKeyIdentifier, + 'Unexpected branch key id' + ) + + needs( + activeKey.encryptionContext[TABLE_FIELD] === logicalKeyStoreName, + 'Unexpected logical table name' + ) + + const kmsKeyArn = getKmsKeyArn(kmsConfiguration) + needs(kmsKeyArn, 'KMS Key ARN is required') + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# The `kms-arn` field of DDB response item MUST be compatible with + //# the configured `KMS ARN` in the AWS KMS Configuration for this keystore. + const oldActiveContext = activeKey.encryptionContext + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#authenticating-a-keystore-item + //# The operation MUST call AWS KMS API ReEncrypt with a request constructed as follows: + //# - `SourceEncryptionContext` MUST be the encryption context constructed above + //# - `SourceKeyId` MUST be compatible with the configured KMS Key in the AWS KMS Configuration for this keystore. + //# - `CiphertextBlob` MUST be the `enc` attribute value on the AWS DDB response item + //# - `GrantTokens` MUST be the configured grant tokens. + //# - `DestinationKeyId` MUST be compatible with the configured KMS Key in the AWS KMS Configuration for this keystore. + //# - `DestinationEncryptionContext` MUST be the encryption context constructed above + await kmsClient.send( + new ReEncryptCommand({ + SourceKeyId: kmsKeyArn, + SourceEncryptionContext: oldActiveContext, + CiphertextBlob: activeKey.ciphertextBlob, + DestinationKeyId: kmsKeyArn, + DestinationEncryptionContext: oldActiveContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#branch-key-and-beacon-key-creation + //# - `version`: a new guid. This guid MUST be version 4 UUID + const branchKeyVersion = v4() + const timestamp = getCurrentTimestamp() + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# The wrapped Branch Keys, DECRYPT_ONLY and ACTIVE, + //# MUST be created according to Wrapped Branch Key Creation. + const decryptOnlyContext: { [key: string]: string } = { + ...oldActiveContext, + [TYPE_FIELD]: `${BRANCH_KEY_TYPE_PREFIX}${branchKeyVersion}`, + [KEY_CREATE_TIME_FIELD]: timestamp, + } + delete decryptOnlyContext[BRANCH_KEY_ACTIVE_VERSION_FIELD] + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#wrapped-branch-key-creation + //# The operation MUST call AWS KMS API GenerateDataKeyWithoutPlaintext + //# with a request constructed as follows: + //# - `KeyId` MUST be the configured `AWS KMS Key ARN` in the AWS KMS Configuration for this keystore. + //# - `NumberOfBytes` MUST be 32. + //# - `EncryptionContext` MUST be the DECRYPT_ONLY encryption context for branch keys. + //# - GenerateDataKeyWithoutPlaintext `GrantTokens` MUST be this keystore's grant tokens. + const decryptOnlyResponse = await kmsClient.send( + new GenerateDataKeyWithoutPlaintextCommand({ + KeyId: kmsKeyArn, + NumberOfBytes: 32, + EncryptionContext: decryptOnlyContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + needs( + decryptOnlyResponse.CiphertextBlob, + 'Failed to generate new DECRYPT_ONLY branch key' + ) + + const newActiveContext = buildActiveEncryptionContext(decryptOnlyContext) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#wrapped-branch-key-creation + //# The operation MUST call AWS KMS API ReEncrypt with a request constructed as follows: + //# - `SourceEncryptionContext` MUST be the DECRYPT_ONLY encryption context for branch keys. + //# - `SourceKeyId` MUST be the configured `AWS KMS Key ARN` in the AWS KMS Configuration for this keystore. + //# - `CiphertextBlob` MUST be the wrapped DECRYPT_ONLY Branch Key. + //# - `DestinationKeyId` MUST be the configured `AWS KMS Key ARN` in the AWS KMS Configuration for this keystore. + //# - `DestinationEncryptionContext` MUST be the ACTIVE encryption context for branch keys. + const activeResponse = await kmsClient.send( + new ReEncryptCommand({ + SourceKeyId: kmsKeyArn, + SourceEncryptionContext: decryptOnlyContext, + CiphertextBlob: decryptOnlyResponse.CiphertextBlob, + DestinationKeyId: kmsKeyArn, + DestinationEncryptionContext: newActiveContext, + GrantTokens: grantTokens ? [...grantTokens] : undefined, + }) + ) + + needs( + activeResponse.CiphertextBlob, + 'Failed to generate new ACTIVE branch key' + ) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# To add the new branch key to the keystore, + //# the operation MUST call Amazon DynamoDB API TransactWriteItems. + //# The call to Amazon DynamoDB TransactWriteItems MUST use the configured Amazon DynamoDB Client to make the call. + await ddbClient.send( + new TransactWriteItemsCommand({ + TransactItems: [ + { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# - PUT: + //# - ConditionExpression: `attribute_not_exists(branch-key-id)` + Put: { + TableName: ddbTableName, + Item: toAttributeMap( + decryptOnlyContext, + decryptOnlyResponse.CiphertextBlob + ), + ConditionExpression: 'attribute_not_exists(#bkid)', + ExpressionAttributeNames: { + '#bkid': BRANCH_KEY_IDENTIFIER_FIELD, + }, + }, + }, + { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //# - PUT: + //# - ConditionExpression: `attribute_exists(branch-key-id) AND enc = :encOld` + //# - ExpressionAttributeValues: `{":encOld" := DDB.AttributeValue.B(oldCiphertextBlob)}` + Put: { + TableName: ddbTableName, + Item: toAttributeMap( + newActiveContext, + activeResponse.CiphertextBlob + ), + ConditionExpression: 'attribute_exists(#bkid) AND #enc = :encOld', + ExpressionAttributeNames: { + '#bkid': BRANCH_KEY_IDENTIFIER_FIELD, + '#enc': BRANCH_KEY_FIELD, + }, + ExpressionAttributeValues: { + ':encOld': { B: activeKey.ciphertextBlob }, + }, + }, + }, + ], + }) + ) +} diff --git a/modules/branch-keystore-node/test/branch_keystore.test.ts b/modules/branch-keystore-node/test/branch_keystore.test.ts index d581d8e3..75613a6a 100644 --- a/modules/branch-keystore-node/test/branch_keystore.test.ts +++ b/modules/branch-keystore-node/test/branch_keystore.test.ts @@ -20,6 +20,7 @@ import { BRANCH_KEY_ACTIVE_VERSION, BRANCH_KEY_ACTIVE_VERSION_UTF8_BYTES, BRANCH_KEY_ID, + BRANCH_KEY_ID_WITH_EC, DDB_TABLE_NAME, INCORRECT_LOGICAL_NAME, KEY_ARN, @@ -750,4 +751,298 @@ describe('Test Branch keystore', () => { ) ).to.be.rejectedWith(IncorrectKeyException)) }) + + describe('VersionKey', () => { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //= type=test + //# On invocation, the caller: + //# - MUST supply a `branch-key-id` + it('MUST fail if no branch-key-id is provided', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect( + keyStore.versionKey({ branchKeyIdentifier: '' }) + ).to.be.rejectedWith('MUST supply a branch-key-id') + + await expect( + keyStore.versionKey({ branchKeyIdentifier: undefined as any }) + ).to.be.rejectedWith('MUST supply a branch-key-id') + + await expect( + keyStore.versionKey({ branchKeyIdentifier: null as any }) + ).to.be.rejectedWith('MUST supply a branch-key-id') + }) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#versionkey + //= type=test + //# If the Keystore's KMS Configuration is `Discovery` or `MRDiscovery`, + //# this operation MUST immediately fail. + it('MUST fail with Discovery KMS Configuration', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: 'discovery', + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect( + keyStore.versionKey({ branchKeyIdentifier: BRANCH_KEY_ID }) + ).to.be.rejectedWith( + 'VersionKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + }) + + it('MUST fail with MRDiscovery KMS Configuration', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { region: 'us-west-2' }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect( + keyStore.versionKey({ branchKeyIdentifier: BRANCH_KEY_ID }) + ).to.be.rejectedWith( + 'VersionKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + }) + + it('MUST fail if branch key does not exist', async () => { + const kmsClient = new KMSClient({}) + const ddbClient = new DynamoDBClient({}) + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME, ddbClient }, + keyManagement: { kmsClient }, + }) + + await expect( + keyStore.versionKey({ + branchKeyIdentifier: 'non-existent-branch-key-id', + }) + ).to.be.rejectedWith('was not found') + }) + + it('Test version key for existing branch key', async () => { + const kmsClient = new KMSClient({}) + const ddbClient = new DynamoDBClient({}) + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME, ddbClient }, + keyManagement: { kmsClient }, + }) + + // Get active key before versioning + const before = await keyStore.getActiveBranchKey(BRANCH_KEY_ID_WITH_EC) + const oldVersion = before.branchKeyVersion.toString('utf8') + + // Version the key + await keyStore.versionKey({ + branchKeyIdentifier: BRANCH_KEY_ID_WITH_EC, // Use BRANCH_KEY_ID_WITH_EC to avoid mutating the primary test fixture. + }) + + // Get active key after versioning + const after = await keyStore.getActiveBranchKey(BRANCH_KEY_ID_WITH_EC) + const newVersion = after.branchKeyVersion.toString('utf8') + + // New version must differ from old + expect(newVersion).to.not.equal(oldVersion) + + // New version must be a valid UUID + expect(validate(newVersion)).to.be.true + expect(version(newVersion)).to.equal(4) + + // Branch key ID unchanged + expect(after.branchKeyIdentifier).to.equal(BRANCH_KEY_ID_WITH_EC) + + // Decrypted key is 32 bytes + expect(after.branchKey().length).to.equal(32) + + // Old version is still retrievable + const oldMaterial = await keyStore.getBranchKeyVersion( + BRANCH_KEY_ID_WITH_EC, + oldVersion + ) + expect(oldMaterial.branchKey().length).to.equal(32) + expect(oldMaterial.branchKeyIdentifier).to.equal(BRANCH_KEY_ID_WITH_EC) + }) + }) + + describe('CreateKey', () => { + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //= type=test + //# If the Keystore's KMS Configuration is `Discovery` or `MRDiscovery`, + //# this operation MUST fail. + it('MUST fail with Discovery KMS Configuration', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: 'discovery', + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect(keyStore.createKey()).to.be.rejectedWith( + 'CreateKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + }) + + it('MUST fail with MRDiscovery KMS Configuration', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { region: 'us-west-2' }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect(keyStore.createKey()).to.be.rejectedWith( + 'CreateKey is not supported with Discovery or MRDiscovery KMS Configuration' + ) + }) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //= type=test + //# If an optional branch key id is provided and no encryption context is provided + //# this operation MUST fail. + it('MUST fail if branch key id provided without encryption context', async () => { + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME }, + }) + + await expect( + keyStore.createKey({ branchKeyIdentifier: 'some-id' }) + ).to.be.rejectedWith( + 'If branch key identifier is provided, encryption context must also be provided' + ) + + await expect( + keyStore.createKey({ + branchKeyIdentifier: 'some-id', + encryptionContext: {}, + }) + ).to.be.rejectedWith( + 'If branch key identifier is provided, encryption context must also be provided' + ) + }) + + //= aws-encryption-sdk-specification/framework/branch-key-store.md#createkey + //= type=test + //# If no branch key id is provided, then this operation MUST create a + //# version 4 UUID to be used as the branch key id. + it('Test create key with auto-generated ID', async () => { + const kmsClient = new KMSClient({}) + const ddbClient = new DynamoDBClient({}) + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME, ddbClient }, + keyManagement: { kmsClient }, + }) + + const result = await keyStore.createKey() + + // Must return a valid v4 UUID + expect(result.branchKeyIdentifier).to.be.a('string') + expect(validate(result.branchKeyIdentifier)).to.be.true + expect(version(result.branchKeyIdentifier)).to.equal(4) + + // Must be retrievable + const material = await keyStore.getActiveBranchKey( + result.branchKeyIdentifier + ) + expect(material.branchKey().length).to.equal(32) + expect(material.branchKeyIdentifier).to.equal(result.branchKeyIdentifier) + }) + + it('Test create key with custom ID and encryption context', async () => { + const kmsClient = new KMSClient({}) + const ddbClient = new DynamoDBClient({}) + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME, ddbClient }, + keyManagement: { kmsClient }, + }) + + const customId = v4() + const result = await keyStore.createKey({ + branchKeyIdentifier: customId, + encryptionContext: { department: 'test' }, + }) + + expect(result.branchKeyIdentifier).to.equal(customId) + + // Active key must be retrievable + const material = await keyStore.getActiveBranchKey(customId) + expect(material.branchKey().length).to.equal(32) + + // Custom encryption context must be preserved. + // Custom encryption context is returned with the prefix stripped, + // matching the Dafny implementation behavior. + expect(material.encryptionContext).to.have.property('department', 'test') + }) + }) + + describe('CreateKey + VersionKey lifecycle', () => { + it('Create with custom EC, retrieve, version, retrieve new, retrieve old', async () => { + const kmsClient = new KMSClient({}) + const ddbClient = new DynamoDBClient({}) + const keyStore = new BranchKeyStoreNode({ + kmsConfiguration: { identifier: KEY_ARN }, + logicalKeyStoreName: LOGICAL_KEYSTORE_NAME, + storage: { ddbTableName: DDB_TABLE_NAME, ddbClient }, + keyManagement: { kmsClient }, + }) + + // 1. Create a new branch key with custom encryption context + const customEc = { department: 'engineering', project: 'lifecycle' } + const { branchKeyIdentifier } = await keyStore.createKey({ + branchKeyIdentifier: v4(), + encryptionContext: customEc, + }) + + // 2. Retrieve the active key and verify EC + // Custom encryption context is returned with the prefix stripped, + // matching the Dafny implementation behavior. + const v1 = await keyStore.getActiveBranchKey(branchKeyIdentifier) + const v1Version = v1.branchKeyVersion.toString('utf8') + expect(v1.branchKey().length).to.equal(32) + expect(v1.encryptionContext).to.have.property('department', 'engineering') + expect(v1.encryptionContext).to.have.property('project', 'lifecycle') + + // 3. Version the key + await keyStore.versionKey({ branchKeyIdentifier }) + + // 4. Retrieve the new active key — must be different version, EC preserved + const v2 = await keyStore.getActiveBranchKey(branchKeyIdentifier) + const v2Version = v2.branchKeyVersion.toString('utf8') + expect(v2.branchKey().length).to.equal(32) + expect(v2Version).to.not.equal(v1Version) + // Custom encryption context is returned with the prefix stripped, + // matching the Dafny implementation behavior. + expect(v2.encryptionContext).to.have.property('department', 'engineering') + expect(v2.encryptionContext).to.have.property('project', 'lifecycle') + + // 5. Old version is still retrievable with EC preserved + const oldMaterial = await keyStore.getBranchKeyVersion( + branchKeyIdentifier, + v1Version + ) + expect(oldMaterial.branchKey().length).to.equal(32) + expect(oldMaterial.branchKeyIdentifier).to.equal(branchKeyIdentifier) + // Custom encryption context is returned with the prefix stripped, + // matching the Dafny implementation behavior. + expect(oldMaterial.encryptionContext).to.have.property( + 'department', + 'engineering' + ) + expect(oldMaterial.encryptionContext).to.have.property( + 'project', + 'lifecycle' + ) + }) + }) })