Kensio Software Blog »

@kensio/yulin v0.38.0 adds simulated KMS

Hugh Grigg | Kensio Software |

Version v0.38.0 of the @kensio/yulin package adds a simulated KMS service for local development and isolated testing.

The simulator supports symmetric encryption keys with CreateKeyCommand. It can encrypt and decrypt with EncryptCommand and DecryptCommand, including encryption context.

Envelope encryption is supported with GenerateDataKeyCommand, by KeySpec or NumberOfBytes.

Keys can be described and listed with DescribeKeyCommand and ListKeysCommand, and their policies read and replaced with GetKeyPolicyCommand and PutKeyPolicyCommand.

Aliases are created and listed with CreateAliasCommand and ListAliasesCommand, and the key lifecycle commands EnableKeyCommand, DisableKeyCommand, ScheduleKeyDeletionCommand and CancelKeyDeletionCommand are all supported.

The encryption is real encryption. Each simulated key holds real AES-256 key material and the operations run through Node.js’s own crypto, so a ciphertext genuinely cannot be read without its key, and attempting to decrypt with the wrong encryption context genuinely fails.

That runs fast enough in tests and local dev, but it adds a bit of extra realism to the simulation, and gives more confidence about how the code will behave against real KMS.

Creating a key and using it looks similar to real use of the AWS SDK:

import {
  CreateKeyCommand,
  DecryptCommand,
  EncryptCommand,
} from "@aws-sdk/client-kms";
import { SimAws } from "@kensio/yulin";

const simAws = new SimAws();
const simKms = simAws.kms();

const createKeyOutput = await simKms.createKey(
  new CreateKeyCommand({ Description: "Example application key" }),
);

const encrypted = await simKms.encrypt(
  new EncryptCommand({
    KeyId: createKeyOutput.KeyMetadata?.Arn,
    Plaintext: Buffer.from("example-secret", "utf8"),
  }),
);

const decrypted = await simKms.decrypt(
  new DecryptCommand({ CiphertextBlob: encrypted.CiphertextBlob }),
);

console.log(Buffer.from(decrypted.Plaintext ?? []).toString("utf8"));

Decrypt does not need a KeyId for a symmetric key, because the ciphertext already names the key that produced it.

The ciphertext blob is opaque. It is not the plaintext, and it is not portable to real AWS or between two different SimAws instances. SimAws instances are always independent of each other and share no state.

An encryption context supplied to Encrypt is bound to that ciphertext, so decrypting with a different one throws SimKmsInvalidCiphertextException. The context is an unordered map, so the same pairs written in a different order still decrypt.

As in real AWS, Encrypt takes at most 4096 bytes, which is the limit that makes envelope encryption necessary. GenerateDataKey returns a data key twice, once in the clear to encrypt your data with, and once encrypted under the KMS key to store alongside it.

Simulated KMS also simulates key policies in IAM, which is a source of a lot of surprises in real AWS projects. Being able to simulate it in Yulin helps to reduce the chance of those mistakes. As always, it’s still important to also test changes in a real AWS environment before going to production.

Every simulated KMS key has a policy which cannot be removed. An IAM policy granting kms:Decrypt does not allow the action unless the key’s own policy also admits the caller.

The way in which the key’s policy allows a caller determines what else is needed for authorisation. A statement naming the caller grants access outright, so a Role with no permissions of its own can still use the key.

A statement naming the Account root, which is what the default key policy contains, only delegates to that Account’s IAM service. The caller still needs an identity policy allowing the action.

Sim KMS evaluates all that through the same simulated IAM service used by the other simulated services.

import { CreateRoleCommand } from "@aws-sdk/client-iam";
import { CreateKeyCommand, EncryptCommand } from "@aws-sdk/client-kms";
import { SimAws } from "@kensio/yulin";
import { SimIamAccessDenied } from "@kensio/yulin/iam";

const simAws = new SimAws();
const accountId = simAws.defaultAccountId;

const createRoleOutput = await simAws.iam().createRole(
  new CreateRoleCommand({
    RoleName: "ExampleEncrypterRole",
    AssumeRolePolicyDocument: JSON.stringify({
      Version: "2012-10-17",
      Statement: {
        Effect: "Allow",
        Principal: { AWS: `arn:aws:iam::${accountId}:root` },
        Action: "sts:AssumeRole",
      },
    }),
  }),
);

const createKeyOutput = await simAws.kms().createKey(
  new CreateKeyCommand({
    Policy: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Principal: { AWS: createRoleOutput.Role.Arn },
          Action: "kms:Encrypt",
          Resource: "*",
        },
      ],
    }),
  }),
);

await simAws.kms().encrypt(
  new EncryptCommand({
    KeyId: createKeyOutput.KeyMetadata?.Arn,
    Plaintext: Buffer.from("example-secret", "utf8"),
  }),
  { caller: { kind: "arn", arn: createRoleOutput.Role.Arn } },
);

try {
  await simAws.kms().encrypt(
    new EncryptCommand({
      KeyId: createKeyOutput.KeyMetadata?.Arn,
      Plaintext: Buffer.from("example-secret", "utf8"),
    }),
  );
} catch (error) {
  console.log(error instanceof SimIamAccessDenied);
}

That key policy names the Role directly and delegates nothing to the overall Account, so the Role can encrypt with no identity policy of its own, and the Account root cannot encrypt at all.

Replacing a key policy with PutKeyPolicyCommand can lock an Account out of its own key. That is real KMS behaviour, so the simulator allows for testing that out.

Every operation takes its target in the KeyId field, which can be any of the four forms accepted by real KMS: a key ID, a key ARN, an alias name such as alias/example-app-key, or an alias ARN.

Aliases beginning alias/aws/ are reserved for AWS managed keys, so CreateAlias refuses to create one. Referencing one brings the key into existence, the way an AWS managed key appears on real AWS when a service first needs it.

Keys belong to an Account and a Region, so a key created in one Region cannot be found or used from another, and a ciphertext produced under it cannot be decrypted elsewhere.

Deletion of a key is not immediate. ScheduleKeyDeletion sets a recovery window of 7 to 30 days during which use of the key is denied. It can still be recovered with CancelKeyDeletion, which leaves it disabled rather than enabled.

A disabled key fails cryptographic operations with DisabledException, and a key pending deletion fails with KMSInvalidStateException.

Function code requiring @aws-sdk/client-kms inside a simulated Lambda handler is routed into the same simulated AWS environment, with the function’s execution role as the caller. A handler that decrypts a value therefore has to be allowed by both the key policy and the role’s identity policy, the same as on real AWS.

Sim KMS implements the behaviour that tests most commonly need rather than full KMS parity.

Only symmetric encryption keys are simulated, so asymmetric keys, HMAC keys, Sign, Verify and ReEncrypt are not. Grants, automatic key rotation, Multi-Region keys and imported key material are not simulated either.

AWS::KMS::Key and AWS::KMS::Alias are not supported when deploying CloudFormation or CDK templates yet, so keys have to be created through the SDK for now.

The KMS-specific condition keys are not derived, including kms:ViaService and kms:EncryptionContext:*, so a policy relying on them will not match. Ordinary condition operators on values supported by sim IAM work as usual.

No other simulated service encrypts anything with KMS yet. Sim S3, sim DynamoDB and sim Lambda environment variables do not use simulated keys, and do not check kms:Decrypt.

Key material lives in process memory for the lifetime of the SimAws instance. That is fine for a simulator, but it is not a security boundary, and anything sharing the process can reach it. The sim KMS source and tests show what is covered so far.

The KMS simulator integrates with the rest of Yulin, so IAM, Lambda, S3, CloudFormation and CDK can all participate in the same simulated environment for local development and system tests.

Related Technologies

AWS   TypeScript   Node.js