Verifying data integrity with KMS asymmetric keys

arpadt

Arpad Toth

Posted on December 8, 2022

Verifying data integrity with KMS asymmetric keys

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
Enter fullscreen mode Exit fullscreen mode

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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
arpadt
Arpad Toth

Posted on December 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related