AWS Security Blog

How to Protect the Integrity of Your Encrypted Data by Using AWS Key Management Service and EncryptionContext

by Greg Rubin | on | in How-to guides | | Comments

One of the most important and critical concepts in AWS Key Management Service (KMS) for advanced and secure data usage is EncryptionContext. Using EncryptionContext properly can help significantly improve the security of your applications. In this blog post, I will show the importance of EncryptionContext and will provide a simple example showing how you can use it to protect the integrity and authenticity of your encrypted data.

At its core, EncryptionContext is a key-value map (both strings) that is provided to KMS with each encryption and decryption request. The maps at encryption and decryption must match, or the decryption request will fail.

EncryptionContext provides three benefits:

  1. Additional authenticated data (AAD)
  2. Audit trail
  3. Authorization context

I will focus on the first benefit, AAD, but all three of these benefits build on the existing cryptographic primitive of authenticated encryption with associated data (AEAD).

What is AEAD?

A security best practice is to require that secret data remain secret (confidentiality) and unmodified (integrity/authenticity). Unfortunately, many older forms of encryption (such as AES-CBC) don’t provide any integrity guarantees, and thus open their users to potential vulnerabilities such as being able to change the meaning of a message without decrypting or re-encrypting it. To avoid these situations, you can use AEAD encryption. AEAD encryption is really two related parts of a single concept: authenticated encryption (the “AE” part of “AEAD”) and associated data (the “AD” part of “AEAD”). I will look at these parts one at a time.

Authenticated encryption

At its core, using authenticated encryption prevents tampering with ciphertext itself. Authenticated encryption is built into KMS, so if you can successfully decrypt a message using KMS, an authorized user must have created that message. You can almost think of this as providing a “signature” over the ciphertext.

For example, take a look at the following code in which KMS throws an InvalidCiphertextException upon receiving ciphertext that has been tampered with.

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

import com.amazonaws.services.kms.*;
import com.amazonaws.services.kms.model.*;

public class Example1 {
  public static void main(final String[] args) {
    final AWSKMS kms = new AWSKMSClient();

    final String plaintext = "My very secret message";
    final byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
    System.out.println("Plaintext: " + plaintext);

    // Encrypt the data
    final EncryptRequest encReq = new EncryptRequest();
    encReq.setKeyId("alias/EcDemo");
    encReq.setPlaintext(ByteBuffer.wrap(plaintextBytes));
    final ByteBuffer ciphertext = kms.encrypt(encReq).getCiphertextBlob();

    // Decrypt the data
    final DecryptRequest decReq1 = new DecryptRequest();
    decReq1.setCiphertextBlob(ciphertext);
    final ByteBuffer decrypted = kms.decrypt(decReq1).getPlaintext();
    final String decryptedStr = new String(decrypted.array(), StandardCharsets.UTF_8);
    System.out.println("Decrypted: " + decryptedStr);

    // Attempt to tamper with the ciphertext
    final byte[] tamperedCt = ciphertext.array().clone();
    // Flip all the bits in a byte 24 bytes from the end
    tamperedCt[tamperedCt.length - 24] ^= 0xff; 

    final DecryptRequest decReq2 = new DecryptRequest();
    decReq2.setCiphertextBlob(ByteBuffer.wrap(tamperedCt));

    try {
      kms.decrypt(decReq2).getPlaintext();
    } catch (final InvalidCiphertextException ex) {
      ex.printStackTrace();
    }
  }
}

Associated data

Though authenticated encryption prevents tampering with the ciphertext itself, the problem with the preceding code is that it doesn’t protect the context of the message. Encrypted data is seldom completely self-contained, but rather depends on unencrypted context. Somebody might be able to modify that context—for example, by copying the ciphertext from one location to another—and exploit the system in that way.

To fix this, most modern forms of authenticated encryption (including KMS) support AAD. AAD is not included in ciphertext directly, but AAD’s integrity is protected by using AEAD encryption. You can think of this as extending the signature over the ciphertext to cover additional data as well. In general, AAD should not contain any secret information, but should be contextual information used to understand the secret information.

What is EncryptionContext?

EncryptionContext is KMS’s implementation of AAD. I highly recommend that you use it to ensure that unencrypted data related to the ciphertext is protected against tampering. Data that is commonly used for AAD might include header information, unencrypted database fields in the same record, file names, or other metadata. It’s important to remember that EncryptionContext should contain only nonsensitive information because it is stored in plaintext JSON files in AWS CloudTrail and can be seen by anyone with access to the bucket containing the information.

The following scenario illustrates the use of EncryptionContext as AAD. For this example, imagine that I have a shared address book that users can use to save and retrieve their physical address. For privacy and security purposes, I will encrypt the addresses before storing them in an Amazon DynamoDB table. (The table will have the string hash key, EmailAddress, which means each physical mailing address is associated with a corresponding email address.)

First, I’ll do this the wrong way and build an insecure implementation. (I have commented out the methods you shouldn’t use in order to prevent accidental use.) In this insecure implementation, if user Mallory can modify the DynamoDB table, she can replace Alice’s address with her own. Mallory can do this even without access to the encryption keys by simply swapping the encrypted addresses between the records, which doesn’t require her to encrypt or decrypt anything. Depending on the circumstances, this could completely defeat the purpose of encrypting the addresses. After swapping the records, Mallory can easily view Alice’s address as if it was her own, and anything that Alice orders for herself will be delivered to Mallory’s address instead.

The following code demonstrates this purposefully insecure implementation.

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;

import com.amazonaws.services.dynamodbv2.*;
import com.amazonaws.services.kms.*;
import com.amazonaws.services.kms.model.*;

public class Example2 {
  private static final String ADDRESS = "Address";
  private static final String EMAIL = "EmailAddress";
  private static final String TABLE = "EcDemoAddresses";
  final static AWSKMS kms = new AWSKMSClient();
  final static AmazonDynamoDB ddb = new AmazonDynamoDBClient();

  public static void main(final String[] args) {
    // Alice stores her address
    saveAddress("alice@example.com", "Alice Lovelace, 123 Anystreet Rd., Anytown, USA");
    // Mallory stores her address
    saveAddress("mallory@example.com",
        "Mallory Evesdotir, 321 Evilstreed Ave., Despair, USA");

    // Output saved addresses
    System.out.println("Alice's Address: " + getAddress("alice@example.com"));
    System.out.println("Mallory's Address: " + getAddress("mallory@example.com"));

    // Mallory tampers with the database by swapping the encrypted addresses.
    // Note that this doesn't require modifying the ciphertext at all.
    // First, retrieve the records from DynamoDB
    final Map<String, AttributeValue> mallorysRecord = ddb
        .getItem(
            TABLE,
            Collections.singletonMap(EMAIL,
                new AttributeValue().withS("mallory@example.com"))).getItem();
    final Map<String, AttributeValue> alicesRecord = ddb.getItem(TABLE,
        Collections.singletonMap(EMAIL, new AttributeValue().withS("alice@example.com")))
        .getItem();

    // Second, extract the encrypted addresses
    final ByteBuffer mallorysEncryptedAddress = mallorysRecord.get(ADDRESS).getB();
    final ByteBuffer alicesEncryptedAddress = alicesRecord.get(ADDRESS).getB();

    // Third, swap the encrypted addresses
    mallorysRecord.put(ADDRESS, new AttributeValue().withB(alicesEncryptedAddress));
    alicesRecord.put(ADDRESS, new AttributeValue().withB(mallorysEncryptedAddress));

    // Finally, store them back in DynamoDB
    ddb.putItem(TABLE, mallorysRecord);
    ddb.putItem(TABLE, alicesRecord);

    // Now, when Alice tries to use her address (say to get something shipped to her)
    // it goes to Mallory instead.
    System.out.println("Alice's Address: " + getAddress("alice@example.com"));
    // Likewise, if Mallory tries to look up her address, she can view Alice's instead
    System.out.println("Mallory's Address: " + getAddress("mallory@example.com"));
  }

// DO NOT USE:   private static void saveAddress(final String email, final String address) {
// DO NOT USE:     final EncryptRequest enc = new EncryptRequest();
// DO NOT USE:     enc.setKeyId("alias/EcDemo");
// DO NOT USE:     enc.setPlaintext(ByteBuffer.wrap(address.getBytes(StandardCharsets.UTF_8)));
// DO NOT USE:     final ByteBuffer ciphertext = kms.encrypt(enc).getCiphertextBlob();
// DO NOT USE: 
// DO NOT USE:     final Map<String, AttributeValue> item = new HashMap<>();
// DO NOT USE:     item.put(EMAIL, new AttributeValue().withS(email));
// DO NOT USE:     item.put(ADDRESS, new AttributeValue().withB(ciphertext));
// DO NOT USE:     ddb.putItem(TABLE, item);
// DO NOT USE:   }
// DO NOT USE: 
// DO NOT USE:   private static String getAddress(final String email) {
// DO NOT USE:     final Map<String, AttributeValue> item = ddb.getItem(TABLE,
// DO NOT USE:         Collections.singletonMap(EMAIL, new AttributeValue().withS(email))).getItem();
// DO NOT USE:     final DecryptRequest dec = new DecryptRequest();
// DO NOT USE:     dec.setCiphertextBlob(item.get(ADDRESS).getB());
// DO NOT USE:     final ByteBuffer plaintext = kms.decrypt(dec).getPlaintext();
// DO NOT USE:     return new String(plaintext.array(), StandardCharsets.UTF_8);
// DO NOT USE:   }
}

In this purposefully insecure implementation, Mallory can still attack the system even without the ability to modify the ciphertext. She can do this because she can change the context of the ciphertext so that it is interpreted differently. In this case she is “just” changing addresses, but it should be clear that this same attack could be used to expose sensitive information or even take over accounts.

We can fix this by including the unencrypted email address associated with the encrypted physical address as EncryptionContext. Now, when the system attempts to decrypt the record that has been tampered with, an InvalidCiphertextException is thrown and the threat is mitigated. This is because the EncryptionContext parameter that was provided at encryption (in this case, Alice’s email address) does not match the EncryptionContext provided at decryption (in this case, Mallory’s email address).

The following code improves the security of the implementation.

private static void saveAddress(final String email, final String address) {
  final EncryptRequest enc = new EncryptRequest();
  enc.setKeyId("alias/EcDemo");
  enc.setPlaintext(ByteBuffer.wrap(address.getBytes(StandardCharsets.UTF_8)));
  enc.setEncryptionContext(Collections.singletonMap(EMAIL, email));
  final ByteBuffer ciphertext = kms.encrypt(enc).getCiphertextBlob();

  final Map<String, AttributeValue> item = new HashMap<>();
  item.put(EMAIL, new AttributeValue().withS(email));
  item.put(ADDRESS, new AttributeValue().withB(ciphertext));
  ddb.putItem(TABLE, item);
}

private static String getAddress(final String email) {
  final Map<String, AttributeValue> item = ddb.getItem(TABLE,
      Collections.singletonMap(EMAIL, new AttributeValue().withS(email))).getItem();
  final DecryptRequest dec = new DecryptRequest();
  dec.setCiphertextBlob(item.get(ADDRESS).getB());
  dec.setEncryptionContext(Collections.singletonMap(EMAIL, email));
  final ByteBuffer plaintext = kms.decrypt(dec).getPlaintext();
  return new String(plaintext.array(), StandardCharsets.UTF_8);
}

Of course, there might be other things an attacker could do, such as move the entire record from one DynamoDB table to another. This is why EncryptionContext should include all of the information associated with the ciphertext that you will later need to interpret it. A good rule is to always include at least enough information to uniquely identify the location of the ciphertext (for example, a URI, file path, or database table and primary keys).

Of course, the best code is the code you don’t need to write, allowing you to concentrate on the things that matter to you (which is rarely cryptography) and leave the cryptographic code to groups that specialize in it. In this case, we can use the aws-dynamodb-encryption-java library. It includes in EncryptionContext not only DynamoDBHashKey (and RangeKey, if available) but also the table name and cryptographic algorithms used.

This final code sample demonstrates an improved and more secure implementation of our example application that takes advantage of the aws-dynamodb-encryption-java library.

import java.nio.ByteBuffer;
import java.security.*;
import java.util.*;

import com.amazonaws.services.dynamodbv2.*;
import com.amazonaws.services.dynamodbv2.datamodeling.encryption.*;
import com.amazonaws.services.dynamodbv2.datamodeling.encryption.
providers.DirectKmsMaterialProvider;
import com.amazonaws.services.dynamodbv2.model.AttributeValue;
import com.amazonaws.services.kms.*;

public class Example4 {
  private static final String ADDRESS = "Address";
  private static final String EMAIL = "EmailAddress";
  private static final String TABLE = "EcDemoAddresses";
  final static AWSKMS kms = new AWSKMSClient();
  final static AmazonDynamoDB ddb = new AmazonDynamoDBClient();

  // Set up the aws-dynamodb-encryption-java library
  final static DynamoDBEncryptor cryptor = DynamoDBEncryptor.getInstance(
      new DirectKmsMaterialProvider(kms, "alias/EcDemo"));
  // Despite the similar name, the DynamoDb EncryptionContext is used to guide
  // the DynamoDBEncryptor for key and algorithm selection (among other things)
  // and not just for the KMS EncryptionContext (though it is used for that as well).
  final static EncryptionContext ddbCtx = new EncryptionContext.Builder()
      .withTableName(TABLE)
      .withHashKeyName(EMAIL)
      .build();

  public static void main(final String[] args) throws GeneralSecurityException {
    // Alice stores her address
    saveAddress("alice@example.com", "Alice Lovelace, 123 Anystreet Rd., Anytown, USA");
    // Mallory stores her address
    saveAddress("mallory@example.com",
        "Mallory Evesdotir, 321 Evilstreed Ave., Despair, USA");

    // Output saved addresses
    System.out.println("Alice's Address: " + getAddress("alice@example.com"));
    System.out.println("Mallory's Address: " + getAddress("mallory@example.com"));

    // Mallory tampers with the database by swapping the encrypted addresses.
    // Note that this doesn't require modifying the ciphertext at all.
    // First, retrieve the records from DynamoDB
    final Map<String, AttributeValue> mallorysRecord = ddb
        .getItem(
            TABLE,
            Collections.singletonMap(EMAIL,
                new AttributeValue().withS("mallory@example.com"))).getItem();
    final Map<String, AttributeValue> alicesRecord = ddb.getItem(TABLE,
        Collections.singletonMap(EMAIL, new AttributeValue().withS("alice@example.com")))
        .getItem();

    // Second, extract the encrypted addresses
    final ByteBuffer mallorysEncryptedAddress = mallorysRecord.get(ADDRESS).getB();
    final ByteBuffer alicesEncryptedAddress = alicesRecord.get(ADDRESS).getB();

    // Third, swap the encrypted addresses
    mallorysRecord.put(ADDRESS, new AttributeValue().withB(alicesEncryptedAddress));
    alicesRecord.put(ADDRESS, new AttributeValue().withB(mallorysEncryptedAddress));

    // Finally, store the encrypted addresses back in DynamoDB
    ddb.putItem(TABLE, mallorysRecord);
    ddb.putItem(TABLE, alicesRecord);

    // Now, when Alice tries to use her address we attempt to decrypt the tampered data
    // get a SignatureException
    try {
      System.out.println("Alice's Address: " + getAddress("alice@example.com"));
      // Likewise, if Mallory tries to look up her address, she can view Alice's instead
      System.out.println("Mallory's Address: " + getAddress("mallory@example.com"));
    } catch (final SignatureException ex) {
      ex.printStackTrace();
    }
  }

  private static void saveAddress(final String email, final String address)
      throws GeneralSecurityException {
    final Map<String, AttributeValue> item = new HashMap<>();
    item.put(EMAIL, new AttributeValue().withS(email));
    item.put(ADDRESS, new AttributeValue().withS(address));
    final Map<String, AttributeValue> encryptedItem = cryptor.encryptAllFieldsExcept(
        item, ddbCtx, EMAIL);
    ddb.putItem(TABLE, encryptedItem);
  }

  private static String getAddress(final String email) throws GeneralSecurityException {
    final Map<String, AttributeValue> encryptedItem = ddb.getItem(TABLE,
        Collections.singletonMap(EMAIL, new AttributeValue().withS(email))).getItem();
    final Map<String, AttributeValue> item = cryptor.decryptAllFieldsExcept(
        encryptedItem,
        ddbCtx, EMAIL);
    return item.get(ADDRESS).getS();
  }

Authenticated encryption with associated data encryption is one of the more important advances in cryptography from the past twenty years. You’ve seen here a few examples of just how critical AAD can be to the security of your systems. From my personal experience, the majority of data encrypted with KMS should have an associated EncryptionContext. I encourage you to review your systems and new development efforts to see how best to leverage this powerful tool.

If you have questions or comments about this post, either post them below or visit the KMS forum.

– Greg