We can use asymmetric KMS keys to verify if our classified data have changed in transit. Asymmetric keys also come in handy when we want to check if the data come from a reliable source. The AWS SDK provides a programmatic way for applications to sign and verify messages.
1. Data integrity
When we transfer data from one application to another we want to ensure that:
- no one can read our data
- the sender is one who says they are
- no one can change the data in transit.
TLS and data encryption can address the first bullet point. Key Management Service (KMS) provides both symmetric keys with envelope encryption and asymmetric keypairs to encrypt and decrypt data.
But we can use asymmetric keys to fulfilling the requirements of the last two points, namely sign and verify data.
2. About asymmetric keys
With symmetric keys, we use the same key for both encryption and decryption. On the other hand, an asymmetric key pair consists of a private and a public key, and we should use them together.
We use the private key to sign (and encrypt) data. It's like our secret ID, so we should never share it with anyone. The signature ensures that the message's sender is the one we expect them to be because they have the private key.
Public keys, on the other hand, are what their name indicates: public. We use the public key for verifying the sender's identity and the integrity of the data.
We must use both keys together. We derive the public key from the private key using complicated mathematical operations that include large prime numbers and other scary concepts. We can share the public key because it's impossible to reverse-engineer the private key from it.
3. Asymmetric keys in KMS
Aside from symmetric keys, we can create asymmetric keypairs in KMS too. It's straightforward to generate an asymmetric keypair in the Console. We should select Asymmetric and Sign and verify when prompted to create a new key.
The private key never leaves KMS, so we can only use it via an API.
On the other hand, we can see the public key in the Console or view it using the AWS CLI with the get-public-key
command:
aws kms get-public-key --key-id KEY_ID
The response will be something like this:
{
"KeyId": "arn:aws:kms:us-east-1:124556789012:key/KEY_ID",
"PublicKey": "PUBLIC_KEY",
"CustomerMasterKeySpec": "ECC_NIST_P256",
"KeySpec": "ECC_NIST_P256",
"KeyUsage": "SIGN_VERIFY",
"SigningAlgorithms": [
"ECDSA_SHA_256"
]
}
We can use the public key in our application using the SDK. This way, we can perform the data verification locally instead of letting KMS do it for us.
4. Let's code it
The pre-requisite for this little exercise is to have a public/private key pair (single or multi-region) in KMS, which we have configured to sign and verify messages.
I used two Lambda functions, one for signing the message and another one for verifying it. In this example, I'll send the signed data to an SQS queue. The verification function will poll the queue for available messages. It will then verify if the data comes from the expected sender and if no one has tampered with it.
We'll provide the queue URL and the KMS key ID as environment variables for the functions. The code uses Node.js v18 and the AWS SDK for JavaScript for Node.js v3 to interact with the AWS APIs.
4.1. The sign side
The sign function can have the following code:
import { KMSClient, SignCommand, MessageType, SigningAlgorithmSpec } from '@aws-sdk/client-kms';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { createHash } from 'crypto';
const { QUEUE_URL, KMS_KEY_ID } = process.env;
const kmsClient = new KMSClient();
const sqsClient = new SQSClient();
export async function handler(event) {
// 1. Get the object which we want to sign
const data = event.data;
// 2. Create a digest from the message
const hash = createHash('sha256');
hash.update(Buffer.from(JSON.stringify(data)));
const digest = hash.digest();
// 3. Sign the message
const signCommandInput = {
KeyId: KMS_KEY_ID,
MessageType: 'DIGEST',
SigningAlgorithm: 'ECDSA_SHA_256',
Message: digest,
};
const signCommand = new SignCommand(signCommandInput);
let signature;
try {
const response = await kmsClient.send(signCommand);
signature = response.Signature;
} catch (error) {
throw error;
}
// 4. Send the message to the recipient
const sendMessageInput = {
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify({ signature: signature.toString(), data }),
};
const sendMessageCommand = new SendMessageCommand(sendMessageInput);
try {
const response = await sqsClient.send(sendMessageCommand);
console.log('message sent to queue: ', response);
} catch (error) {
throw error;
}
}
The main points are as follows.
The function receives the data from the event
object, but it can come from any source.
We'll then create a digest of the message. This operation is mandatory if the data size is more than 4kB. If it's less than 4kB, then KMS will create it for us.
It basically means that we'll create a hash of the data, which will have the same length regardless of the original data size.
In Node.js we can do it in the following way:
const hash = createHash('sha256');
hash.update(Buffer.from(JSON.stringify(data)));
const digest = hash.digest();
In this example, we use the sha256
algorithm. First, we have to JSON.stringify
the object and then turn it into binary data.
Next, we'll sign the message. The value of the MessageType
property is DIGEST
since we want to sign the digest and not the raw data. We can choose from multiple SigningAlgorithm
values, and let's choose ECDSA_SHA_256
here. The acronym refers to the Elliptic Curve Digital Signature Algorithm, and I'll have a link to an article that explains it in detail at the bottom.
We'll call the sign
method in the KMS API to (surprise) sign the message. KMS will use the private key for this operation. KMS stores the private key inside a hardware security module, so the key never leaves the service.
If the signing process is successful, the sign
method will return the Signature
along with a few other properties.
The last step is to stringify
the object that contains both the data and the signature.
4.2. The verify side
The second function performs the verification. The code is similar to the first function, and it can look like this:
import { KMSClient, VerifyCommand } from '@aws-sdk/client-kms';
import { createHash } from 'crypto';
const { KMS_KEY_ID } = process.env;
const kmsClient = new KMSClient();
export async function handler(event) {
// 1. unwrap the message
const record = event.Records[0];
const message = JSON.parse(record.body);
const data = message.data;
// 2. Create the same digest
const hash = createHash('sha256');
hash.update(Buffer.from(JSON.stringify(data)));
const digest = hash.digest();
// 3. Verify the integrity of the data
const verifyCommandInput = {
KeyId: KMS_KEY_ID,
MessageType: 'DIGEST',
SigningAlgorithm: 'ECDSA_SHA_256',
Message: digest,
Signature: new Uint8Array(message.signature.split(','))
};
const verifyCommand = new VerifyCommand(verifyCommandInput);
try {
const response = await kmsClient.send(verifyCommand);
console.log('verification response: ', response);
} catch (error) {
throw error
}
}
First, we extract data
and signature
from the message. It's a standard SQS-Lambda trigger, and it's not part of this post to explain how it works.
Next, we'll create the same hash as in the sign function. We should do it so that KMS can compare the original signed message to the one we submit for verification.
We'll then call the verify
endpoint with the data's digest and the signature. It's the same signature we received when the sign
function signed the message. Originally it was a Uint8Array
, so when we submit it for verification, the signature's format should be the same. We can use the Uint8Array
Node.js constructor here.
KMS will use the public key to decide if the message is still the same as at the point of the signature. It will also verify if the private key is valid, i.e., it belongs to the same KMS key pair (the public key is a mathematic derivation of the private key).
If the verification is successful, the endpoint returns SignatureValid: true
:
{
'$metadata': {
httpStatusCode: 200,
requestId: '69a98201-67a9-4ae2-bd67-d5f01c388e9b',
extendedRequestId: undefined,
cfId: undefined,
attempts: 1,
totalRetryDelay: 0
},
KeyId: 'arn:aws:kms:us-east-1:123456789012:key/KEY_ID',
SignatureValid: true,
SigningAlgorithm: 'ECDSA_SHA_256'
}
Otherwise, it will throw an error.
5. Errors
I found it hard to prepare this exercise partly because the AWS documentation doesn't always explain the steps well. So it came as no surprise to me that I encountered some errors while playing with the keys. I'll share the most interesting ones and their resolutions here.
Digest already called - This error comes from the hash
object when we invoke the Lambda function more than once. We should move the hash
object creation inside the handler. If not, Lambda will reuse the already existing object. We can't verify a digest more than once, so in this case, it's ok to have more logic inside the Lambda handler.
Digest is invalid length for algorithm ECDSA_SHA_256 - The digest we send to KMS for signing must be of a Buffer type.
Cannot read properties of undefined (reading 'byteLength') - This error is interesting. Most sources indicate that the error comes up when we don't configure the credentials. I received this error when I had Message
in SignCommandInput
in a string format when it should be Buffer.
ValidationException: 1 validation error detected: Value at 'signature' failed to satisfy constraint: Member must have length greater than or equal to 1 - The error can occur while we try to verify the signature. It means that the Uint8Array
is empty. The error is probably SQS-specific in how we send messages to the queue. The Uint8Array
won't be of the same format after parsing the stringified object. In this case, we can use toString()
on the sign
end and split
the string into the new Uint8Array
before we submit the signature for verification.
6. Summary
We can generate not only symmetric keys but asymmetric keypairs in KMS. We can use asymmetric keypairs to encrypt and decrypt data so as to sign and verify messages.
Signing the message with the private key and verifying it with its public pair ensures that we'll receive the original data as intended. We can also be sure that the sender is a trusted entity.
7. Further reading
ECDSA: Elliptic Curve Signatures - Explanation of the ECDSA signature process - hard core mathematics fans only
Asymmetric key concepts - Basic information about public/private key pairs
Asymmetric keys in AWS KMS - The title says it all
Digital signing with the new asymmetric keys feature of AWS KMS - Use the CLI to sign and verify data
Top comments (0)