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.