Ultra Secure Password Storage with NitroPepper

Ultra Secure Password Storage with NitroPepper

Nitro Enclaves provide isolated compute environments for secure processing of very sensitive data. In this article we will use Nitro Enclaves to build an extremely safe password storage mechanism.

Something was bugging me after publishing my recent articles about EC2 Nitro Enclaves (part 1, part 2). It took me a few days until I realized what it was: aside from the ACM reference architecture I couldn’t come up with a valid use case for Nitro Enclaves. This left me wondering - are Nitro Enclaves a solution in search of a problem?

A couple of hours of sketching and thinking left me with a few infeasible or convoluted implementation ideas. But then it hit me. Nitro Enclaves can be used to provide a super secure password storage mechanism. At the end of this post we will have built a Nitro Enclave application that will protect user passwords, even when an attacker has full control over the application server and database. This Nitro Enclave application is called NitroPepper.

[Update November 7th, 2020] An earlier version of this post used PBKDF2. That received some fair comments on Hacker News. PBKDF2 has now been replaced with bcrypt. The pull request for this replacement can be found here.

This article consists of three sections. Section 1 covers password storage mechanisms and explains the problem solved by NitroPepper. Section 2 covers the implementation details of NitroPepper. In section 3 we will walk through all the steps to get NitroPepper running on your own EC2 instances.

The NitroPepper architecture requires a few components, all of which we will discuss in depth in section 2. The components we use are:

  • A simple Python Flask frontend (GitHub) exposing two REST API methods: new_user and login. This frontend only serves demo purposes.
  • The NitroPepper application (GitHub), written in Python, running in the Nitro Enclave.
  • The AWS Nitro Enclaves NSM API, extended with Python interfaces (GitHub). This API provides an interface between NitroPepper and the Nitro Security Module (NSM).
  • A traffic forwarder to send KMS traffic from NitroPepper to KMS.
  • A KMS Proxy to send KMS traffic from the parent EC2 instance to KMS.

NitroPepper Overview

Section 1: a brief history of password storage

Any system with a user login requires some way to store a user’s password. Decades ago, it was acceptable to store passwords in plain text. But as more and more valuable data was stored online, people started attacking databases and extracting their contents. To protect against password leaks, gradually more sophisticated methods for storing passwords were developed. In this section we will briefly go trough the history of password storage.

If you’re already familiar with hashing algorithms, salt and pepper, feel free to skip this section and jump to the paragraph “The case for a unique pepper per user”.

One caveat: in the sections below I use MD5 as an example hashing mechanism. MD5 is known to be very insecure and you should never use it for real world password hashing. Instead, you would use SHA-512, SHA3 or another highly secure algorithm. The reason I use MD5 in this article is because its output is nice and short, which makes for easier reading.

The olden days: plain text

In an almost forgotten past, applications were not connected to the internet, databases were never attacked and passwords could be stored in plain text. A few rows in a user table might look like this:

ID Username Password
1 [email protected] super_Secret1
2 [email protected] can’t_gues5_This


To log in to this system, the user provides their username and password to the application. The application checks if the username/password combination exists in the database. If the combination is found, the user is allowed in.

As systems got more connected and accessible, database hacks and dumps also became more common. When an attacker would gain access to a database with passwords stored in plain text, they could use this information to log in as any user. To make matters worse, users commonly reuse passwords. This means that an attacker could use the passwords found at insecure site A to try a log in on another site B, even if this site did securely store their passwords.

Replacing plain text passwords with hashes

The first development to improve password storage security was hashing. A hashing algorithm like MD5 or SHA takes an input (in our case, the password) and generates a fixed-length string for this input. The algorithm is a mathematical operation that will always generate the same output for any given input. For example:

MD5(super_Secret1) -> bd2bf17a10bc97a50bfb551aa2de9e76
MD5(can't_gues5_This) -> fda5a58b6772c04ea8e0aa6d0767d880

The essence of a hash function is that it is one-directional. In other words, you can generate a hash from a password, but not a password from its hash. So if someone would gain access to the hash bd2bf17a10bc97a50bfb551aa2de9e76, they wouldn’t know that the source password was super_Secret1.

An application built around this technology would no longer store plain text passwords, but store the hashed version instead:

ID Username PasswordHash
1 [email protected] bd2bf17a10bc97a50bfb551aa2de9e76
2 [email protected] fda5a58b6772c04ea8e0aa6d0767d880


When a user logs in to this application they provide their username and plain text password. The application hashes the password at every login attempt and compares the result with the hash stored in the database. If they match, the user is allowed in.

With this solution the password is no longer stored in plain text. However, there are still significant weaknesses. The first is that some algorithms, like MD5 and SHA1, have been ‘broken’. When an algorithm has been broken, an attacker can independently generate an input string that results in the given hash. They can then use this input string as the password. Another problem is that the hash value will always be the same for a given password. An attacker can use this fact to build dictionaries or rainbow tables with the hash of common inputs and patterns. When they gain access to a database of hashes they only need to look them up in their dictionary to determine the original password.

Adding salt to hashes

The next generation of password security solves the problems of hashing by adding a salt. Why is this called a salt? Because salt goes well with hash. With salt, we still use a hashing algorithm, but instead of just hashing the plain password, we add a random string to the input. A few quick examples:

MD5(super_Secret1-123455) -> 8b7478c3ba06d3c5ed56754094ba5cd3
MD5(super_Secret1-669911) -> c9037f7af3e52966f4766074b17c0b8c
MD5(can't_gues5_This-998765) -> 337ef055ed6d3efd9d8d31a58d55bfab

This salt is stored with the user information in the database. Please note that a salt would in reality be a longer string with a larger character set.

ID Username PasswordHash PasswordSalt
1 [email protected] bd2bf17a10bc97a50bfb551aa2de9e76 123455
2 [email protected] fda5a58b6772c04ea8e0aa6d0767d880 998765


With a salt added to the password, two identical passwords (super_Secret1) become unique (super_Secret1-123455 and super_Secret1-669911), which results in different hashes.

When logging in to this system, the user still provides their plain text password. The server then retrieves the salt from the database and performs the hash function: MD5([password]-[salt]). When the resulting hash matches the stored password hash, the user is allowed to log in.

With salt added to hashes, dictionaries and rainbow tables have become useless. Unfortunately, even this solution is not strong enough in a number of use cases.

Adding a shared secret pepper

In the solution above all information is stored in a single source - the database. If the attacker has access to the database and a known entry (for example their own username and password), they can attempt to retrieve the original values through brute force. This might take a while, but processing power becomes cheaper every year, and governments have access to supercomputers that might make breaking passwords easier than you might expect.

To be fair, a storage mechanism with properly salted passwords will be more than secure enough for most applications. But in healthcare, finance or government use cases, it might not be sufficient. This is where adding a server based pepper comes into play. Why is it called a pepper…? Well you get the point.

A shared secret pepper builds on the principle of the salt, but instead of storing the value in the database, it is stored on the server file system or another non-database system like AWS Secrets Manager. To be able to decrypt the passwords in the database the attacker needs access to both the database and secondary storage system, which significantly improves security. A simple SQL injection attack or access to a database backup is now no longer sufficient to attempt a brute force attack.

This time when a user attempts to log in, the application will hash the password + salt + pepper: MD5([password]-[salt]-[pepper]). When the result matches the password hash stored in the database, the user is allowed in.

The case for a unique pepper per user

The Wikipedia page for Pepper (cryptography) states: “In the case of a shared-secret pepper, a single compromised password (via password reuse or other attack) along with a user’s salt can lead to an attack to discover the pepper, rendering it ineffective”. Additionally, a user, engineer or hacker with access to a web server will likely have access to the database credentials and the shared secret pepper. If this person could exfiltrate the database and the shared secret pepper, the passwords can be brute-forced again.

The solution is to generate a pepper unique to every user. Of course, the salt is also unique to each user. The difference between the user salt and the unique user pepper is where they are stored. The salt is stored semi-publicly with the user’s hashed password, while the unique user pepper is stored on a separate medium, such as a Hardware Security Module (HSM). The downside is that this separate medium needs to be able to store peppers for every single user. In large scale environments this means the security module also needs a lot of storage and processing capacity. But with the introduction of Nitro Enclaves, a new solution has become available.

Section 2: using Nitro Enclaves to manage unique user peppers

With EC2 Nitro Enclaves we can encrypt the unique user pepper with KMS and store the encrypted data with the user information in the database. The Nitro Enclave will be the only component that can decrypt the pepper. Because the Enclave runs in full isolation, the user pepper will never be exposed. Even when someone has unrestricted access to both the web server and the database, they will never be able to decrypt the user’s passwords. The Enclave application that delivers these features is called NitroPepper.

NitroPepper architecture

In the chapter below we will describe a web application with user credentials stored in DynamoDB. When a user signs up, their password is sent to NitroPepper. NitroPepper communicates with the Nitro Security Module and KMS to securely hash the password. Then NitroPepper returns the hashed password and the encrypted pepper. The hashed password and the encrypted pepper are stored together in DynamoDB.

When an existing user tries to log in, the hashed password and encrypted pepper are fetched from DynamoDB by the application. These are then forwarded to NitroPepper, together with the password they are trying to log in with. NitroPepper will decrypt the pepper from DynamoDB, and use the decrypted value to hash the password the user is trying to log in with. If the result matches the hash stored in the database, the user is allowed to log in.

Login Flow

Memory for Nitro Enclaves

The NitroPepper Enclave needs 3 GB of memory to run. This is a lot, and can surely be optimized, but it’s what it currently is. To run a 3 GB Enclave, you issue the following command: sudo nitro-cli run-enclave --cpu-count 2 --memory 3072 --eif-path nitropepper.eif --enclave-cid 6. The first time I ran this, I encountered the following error:

(env) [[email protected] nitropepper]$ sudo nitro-cli run-enclave --cpu-count 2 --memory 3072 --eif-path nitropepper.eif --enclave-cid 6
Start allocating memory...
[ E27 ] Insufficient memory available. User provided `memory` is 3072 MB, which is more than the available hugepage memory.

To fix this, we need to increase the amount of hugepages by running echo "vm.nr_hugepages=1536" | sudo tee /etc/sysctl.d/99-nitro.conf; sudo sysctl -p /etc/sysctl.d/99-nitro.conf.

At another point during development my docker container ran out of memory. It was not immediately obvious that memory was the problem, because the only output provided by Nitro Enclaves was Could not open /env file: No such file or directory. Once I changed the Nitro Enclaves memory from 2GB to 3GB the error was gone and never returned.

Vsock communication

The frontend application communicates with NitroPepper over a vsock (short for VM Socket). This is pretty straightforward in Python. NitroPepper binds to the vsock as follows (ENCLAVE_PORT is set to 5000):

    vsock = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
    vsock.bind((socket.VMADDR_CID_ANY, ENCLAVE_PORT))
    vsock.listen()

    while True:
        conn, _addr = vsock.accept()
        print('Received new connection')
        payload = conn.recv(4096)

The frontend application connects to this socket like below. ENCLAVE_CID is hardcoded to 6, ENCLAVE_PORT is 5000.

    vsock = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
    vsock.connect((ENCLAVE_CID, ENCLAVE_PORT))
    vsock.send(str.encode(json.dumps(dictionary)))
    return_data = vsock.recv(1024).decode()
    return json.loads(return_data)

Easy peasy, lemon squeezy.

KMS proxy and traffic_forwarder.py

NitroPepper executes three types of KMS operations: GenerateRandom, Encrypt and Decrypt. The first two operations are supposed to be fairly straightforward; we send a simple POST request to https://kms.eu-central-1.amazonaws.com with the credentials obtained from the parent instance, and KMS returns the contents we requested.

However, the Nitro Enclaves have no network connection available. To communicate with KMS, the Enclave needs to connect to a KMS proxy running on the parent instance. To run the KMS proxy, you simply execute sudo vsock-proxy 8000 kms.eu-central-1.amazonaws.com 443 or start vsock-proxy as a service. In the Enclave, you connect to the vsock with CID 3. This traffic is received by vsock-proxy, which forwards it to KMS.

In NitroPepper, I wanted to use the standard requests library for HTTP calls, but requests does not have native support for vsocks. Luckily, this problem was solved using a traffic forwarder written by Richard Fan (GitHub), detailed in his blog post Running Python App on AWS Nitro Enclaves. The forwarder receives traffic on a standard port and forwards it to the KMS vsock. Problem solved.

Random number generation and /dev/nsm

For Nitro Enclaves attestation, the Enclave needs to generate an RSA key pair and send the public key to the KMS service. Generating RSA key pairs requires random number generation, which is usually provided by /dev/random and /dev/urandom. However, in Nitro Enclaves, these are not available.

Instead, we have /dev/nsm, which is used for both random number generation and creating attestation documents. Using ioctl, we can communicate with /dev/nsm. This process is quite complex, but luckily we don’t have to reinvent the wheel. On GitHub, AWS provides the aws-nitro-enclaves-nsm-api, which is a library (written in Rust) that interfaces with /dev/nsm. But there is one issue: it only provides interfaces for C.

Writing Python interfaces for the NSM API

I forked the aws-nitro-enclaves-nsm-api to my own Git repository, then I wrote additional interfaces for Python. This only required about 80 lines of code, which you can find in this commit. You can clone the repository and run cargo build to build your own library, or download the precompiled library from the NitroPepper repository.

The libnsm libary provides interfaces for the nsm_get_random() and nsm_get_attestation_doc() functions, which is all we need for NitroPepper.

Generating RSA key pairs and monkey patching pycrytodome

The pycryptodome library provides the RSA.generate() function to generate RSA key pairs. By default, this uses the standard Linux random number generator (/dev/random), but this can be overwritten with RSA.generate(2048, randfunc=nsm_randfunc)… Or so I thought. In practice, this overwrites some, but not all the usages of /dev/random. To overcome this, I monkey patched pycryptodome as follows:

    @classmethod
    def _monkey_patch_crypto(cls, nsm_rand_func):
        """Monkeypatch Crypto to use the NSM rand function."""
        Crypto.Random.get_random_bytes = nsm_rand_func
        def new_random_read(self, n_bytes):
            return nsm_rand_func(n_bytes)
        Crypto.Random._UrandomRNG.read = new_random_read

With this, pycryptodome runs without issues in Nitro Enclaves, and we can use it to generate RSA key pairs.

Attestation

The most difficult part by far was to get Attestation to run. Attestation is the process where Nitro Enclaves identify themselves to KMS through a method that guarantees a request is coming from a specific Enclave. This process is only used for the Decrypt operation.

Let’s start with an overview. The KMS proxy has been left out of this diagram for readability.

Attestation

As you can see, the process requires eight steps of encryption and decryption. First the RSA key pair is generated (3). The public key is sent to the NSM API, which generates an attestation document in which this public key is embedded (4). The attestation document is attached to the KMS Decrypt call (5). KMS encrypts the requested data with the public key provided by NitroPepper. Then it sends the encrypted data back to NitroPepper (6) in the form of a Cryptographic Message Syntax (CMS) envelope, which is defined in Public Key Cryptography Standard #7, or PKCS#7. Back in the NitroPepper application, the envelope is parsed (7), which yields three values: the key used to encrypt the KMS response, which itself is encrypted with the RSA key, an initialization vector (IV) and the encrypted KMS response. NitroPepper then uses the RSA private key to decrypt the symmetric key (8). Finally, the symmetric key is used to decrypt the KMS response (9).

The KMS Decrypt call requires some undocumented parameters for the Attestation Document. The Python code below (source) shows the request body used by NitroPepper:

    request_parameters = json.dumps({
        'CiphertextBlob': ciphertext_blob,
        'Recipient': {
            'KeyEncryptionAlgorithm': 'RSAES_OAEP_SHA_1',
            'AttestationDocument': self._get_attestation_doc_b64()
        }
    })

The code for the CMS decryption process can be found in kms.py. A snippet of the core functionality:

def _cms_parse_enveloped_data(self, ciphertext_for_recipient):
    """Return symmetric key, IV, Block Size and ciphertext for serialized CMS content."""

    ...

    block_size = encrypted_content_info['content_encryption_algorithm'].encryption_block_size
    init_vector = encrypted_content_info['content_encryption_algorithm'].encryption_iv
    ciphertext = encrypted_content_info['encrypted_content'].native

    return cipherkey, init_vector, block_size, ciphertext

def _rsa_decrypt(self, private_key, encrypted_symm_key):
    """Decrypt the encrypted symmetric key with the RSA private key."""
    cipher_rsa = PKCS1_OAEP.new(private_key)
    return cipher_rsa.decrypt(encrypted_symm_key)

def _aws_cms_cipher_decrypt(self, ciphertext, key, block_size, init_vector):
    """Decrypt the plain text data with the dycrypted key from CMS."""
    cipher = AES.new(key, AES.MODE_CBC, iv=init_vector)
    return unpad(cipher.decrypt(ciphertext), block_size)

The process of sending a public RSA key to KMS, and KMS encrypting the response before returning it, guarantees that even if the data would be intercepted (on the parent instance, on the network, or by other tenants on the physical host), the interceptor still wouldn’t be able to access your data. The only way the data can be decrypted is with the private RSA key, which remains inaccessible to anyone but the enclave.

Hashing the password with bcrypt.

The function that uses the password and salt to generate a password hash is bcrypt.hashpw(). You can find the code here. The documentation of Python bcrypt can be found here. In NitroPepper, bcrypt.hashpw() is used as follows.

    derived_key = bcrypt.hashpw(
        password=password.encode('utf-8'),
        salt=bcrypt_salt_bytes
    )

The salt bytes are generated by the bcrypt.gensalt() function. By default, this function uses os.urandom():

    salt = os.urandom(16)
    output = _bcrypt.ffi.new("char[]", 30)
    _bcrypt.lib.encode_base64(output, salt, len(salt))

In NitroPepper this has been replaced with the NSM random number generator:

    salt = nitro_kms.nsm_rand_func(16)
    output = _bcrypt.ffi.new("char[]", 30)
    _bcrypt.lib.encode_base64(output, salt, len(salt))

Bcrypt intentionally CPU intensive. This makes the hashing process relatively slow, which renders a brute force attack infeasible. This might lead to a DDOS attack vector, so the memory-bound scrypt variant can be considered as well.

Section 3: getting it to run

To run the NitroPepper reference architecture, four AWS resources are required:

  • An EC2 instance with Nitro Enclaves support (I used an m5a.xlarge)
  • An IAM role and profile for this instance
  • A DynamoDB table to store users
  • A KMS key to encrypt the users’ passwords

You can create the first three resources with the following AWS CLI commands. The AMI is the latest Amazon Linux 2 image at the time of writing.

aws ec2 run-instances --region eu-central-1 --image-id ami-00a205cb8e06c3c4e --count 1 --instance-type m5a.xlarge --enclave-options 'Enabled=true' --key-name [your key name]

aws dynamodb create-table --region eu-central-1 --table-name nitropepper-users --billing-mode PAY_PER_REQUEST --attribute-definitions AttributeName=username,AttributeType=S --key-schema AttributeName=username,KeyType=HASH 

POLICY='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam create-role --role-name nitropepper-role --assume-role-policy-document $POLICY

ALLOW_DDB='{"Version": "2012-10-17","Statement":[{"Effect":"Allow","Action":["dynamodb:PutItem","dynamodb:GetItem"],"Resource":"arn:aws:dynamodb:*:*:table/nitropepper-users"}]}'
aws iam put-role-policy --role-name nitropepper-role --policy-name ddb-access --policy-document $ALLOW_DDB

ALLOW_KMS='{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "kms:GenerateRandom",
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt",
                "kms:Encrypt"
            ],
            "Resource": "arn:aws:kms:eu-central-1:*:alias/nitropepper-cmk"
        }
    ]
}'
aws iam put-role-policy --role-name nitropepper-role --policy-name kms-access --policy-document $ALLOW_KMS

aws iam create-instance-profile --instance-profile-name nitropepper-instance-profile
aws iam add-role-to-instance-profile --instance-profile-name nitropepper-instance-profile --role-name nitropepper-role

aws ec2 --region eu-central-1 associate-iam-instance-profile --instance-id [instance_id] --iam-instance-profile Name=nitropepper-instance-profile

Next, create an KMS key with a policy that allows only the enclave to use it. Replace 111122223333 with your account ID and run the following code.

KMS_POLICY='{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Allow access for Key Administrators",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:root"
            },
            "Action": [
                "kms:Create*",
                "kms:Describe*",
                "kms:Enable*",
                "kms:List*",
                "kms:Put*",
                "kms:Update*",
                "kms:Revoke*",
                "kms:Disable*",
                "kms:Get*",
                "kms:Delete*",
                "kms:TagResource",
                "kms:UntagResource",
                "kms:ScheduleKeyDeletion",
                "kms:CancelKeyDeletion"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Enable enclave data decryption",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:role/nitropepper-role"
            },
            "Action": "kms:Decrypt",
            "Resource": "*",
            "Condition": {
                "StringEqualsIgnoreCase": {
                    "kms:RecipientAttestation:PCR0": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
                    "kms:RecipientAttestation:PCR1": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
                    "kms:RecipientAttestation:PCR2": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
                }
            }
        },
        {
            "Sid": "Enable enclave data encryption",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:role/nitropepper-role"
            },
            "Action": "kms:Encrypt",
            "Resource": "*"
        }
    ]
}'
aws kms create-key --region eu-central-1 --policy $KMS_POLICY

We will replace those zeroes later. Copy the Key ID and create an alias: aws kms create-alias --region eu-central-1 --alias-name alias/nitropepper-cmk --target-key-id [your key id]. This alias will be used by the application.

Now open an SSH connection to your instance. First we will download, compile and start the enclave application. To do this, run the following commands (from Installing the Nitro CLI):

sudo amazon-linux-extras install aws-nitro-enclaves-cli -y
sudo yum install aws-nitro-enclaves-cli-devel -y
sudo usermod -aG ne ec2-user
sudo usermod -aG docker ec2-user
sudo systemctl start nitro-enclaves-allocator.service && sudo systemctl enable nitro-enclaves-allocator.service
sudo systemctl start docker && sudo systemctl enable docker

Log out and back in to the instance (I ran into some docker issues when I didn’t), then run:

sudo yum install git -y
git clone https://github.com/donkersgoed/nitropepper-enclave-app.git nitropepper
cd nitropepper

docker build . -t nitropepper
nitro-cli build-enclave --docker-uri nitropepper:latest --output-file nitropepper.eif

# Increase allowed docker memory to 3GB
echo "vm.nr_hugepages=1536" | sudo tee /etc/sysctl.d/99-nitro.conf
sudo sysctl -p /etc/sysctl.d/99-nitro.conf

sudo nitro-cli run-enclave --cpu-count 2 --memory 3072 --eif-path nitropepper.eif --enclave-cid 6

The Nitro Enclave is now running. Copy the values for PCR0, PCR1 and PCR2 to your KMS policy:

KMS_POLICY='{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Allow access for Key Administrators",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:root"
            },
            "Action": [
                "kms:Create*",
                "kms:Describe*",
                "kms:Enable*",
                "kms:List*",
                "kms:Put*",
                "kms:Update*",
                "kms:Revoke*",
                "kms:Disable*",
                "kms:Get*",
                "kms:Delete*",
                "kms:TagResource",
                "kms:UntagResource",
                "kms:ScheduleKeyDeletion",
                "kms:CancelKeyDeletion"
            ],
            "Resource": "*"
        },
        {
            "Sid": "Enable enclave data decryption",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:role/nitropepper-role"
            },
            "Action": "kms:Decrypt",
            "Resource": "*",
            "Condition": {
                "StringEqualsIgnoreCase": {
                    "kms:RecipientAttestation:PCR0": "2248ddcce2355532c4f805f90d118b7162febadbcad65386558474c4aa232c36dd9c5537b5485f14779f30fbf41d3bc1",
                    "kms:RecipientAttestation:PCR1": "ef5b4f1f63c3fe666bdf4a096bae53439d28a9fa70c33241c0e1479a1b6f17cff6d100accbe01d766a19e8116b0a2c70",
                    "kms:RecipientAttestation:PCR2": "5771ae7ca2db0f22569ad4fa30e0d17ea1ab5b1d2cf378bd9188c9d46dc0da9c57cc5462a61256ae79421f83ae7ca446"
                }
            }
        },
        {
            "Sid": "Enable enclave data encryption",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::111122223333:role/nitropepper-role"
            },
            "Action": "kms:Encrypt",
            "Resource": "*"
        }
    ]
}'
aws kms put-key-policy --region eu-central-1 --key-id [your key id] --policy-name default --policy $KMS_POLICY

The next step is to run the web application. Execute the following commands:

cd

sudo yum install python3 -y
git clone https://github.com/donkersgoed/nitropepper-demo-webapp.git webapp
cd webapp

python3 -mvenv env
source env/bin/activate
pip install -r requirements.txt

FLASK_ENV=development FLASK_APP=main.py python -m flask run

Open a second SSH session to the EC2 instance, and run sudo vsock-proxy 8000 kms.eu-central-1.amazonaws.com 443 &. This will start the KMS proxy, allowing the Enclave to reach KMS.

Now all components are running. You can test NitroPepper by calling: curl -XPOST http://127.0.0.1:5000/new_user -F '[email protected]' -F 'password=test'. This will create a new user. Then log in as this user with curl -XPOST http://127.0.0.1:5000/login -F '[email protected]' -F 'password=test'.

Looking at the database, you will find the encrypted pepper stored in the table:

Encrypted Pepper

However, trying to decrypt this key from the EC2 instance will fail, because we’re not providing the Nitro Enclaves Attestation Document:

aws kms decrypt --region eu-central-1 --ciphertext-blob fileb://<(echo 'AQICAHgKZxEd4PKEkag/DJmTAHuErNJ322vbAb41iC0u+b7X7AESZ+7h3fDTHZqFNwIZ2i55AAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQM9b5VKuQTpeyfx/fXAgEQgDtu3MsRLqDvrgmur7528oQjBF71om1fAzVsss9HKVw/2/gJORRGqW0+DYULvCY9rJTM04IsO/5pYCSxbg==' | base64 -d)

An error occurred (AccessDeniedException) when calling the Decrypt operation: The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access.

Conclusion

NitroPepper is a complete Nitro Enclaves application, written in Python. It can be used to encrypt a plaintext password with a salt that can only be decrypted in the Enclave itself. This acts as a one-way door: an application can send a password into the enclave, but there is no way to ever get the unencrypted password out again. An application can, however, send the password and the previously stored encrypted salt to NitroPepper a second time. This second round will encrypt the password exactly the same way as the first time, and then return a true if the hash of the first and second encryption rounds are the same.

NitroPepper is deliberately slow, making it infeasible to brute force passwords. On the other hand, the lag of a single successful run is low enough to not be noticeable for human users.

With this practical use case we’ve demonstrated how Nitro Enclaves work in a real-world scenario. Additionally, we have built Python interfaces with can be reused in other Nitro applications, or can be used to learn about the inner workings of NSM.

If you would like to read more about Nitro Enclaves, check out my other posts:

I share posts like these and smaller news articles on Twitter, follow me there for regular updates! If you have questions or remarks, or would just like to get in touch, you can also find me on LinkedIn.

Luc van Donkersgoed
Luc van Donkersgoed