Saurabh Singh
Journal

22 min readSaurabh Singh

Two Servers, One Secret: Envelope Encryption Inside Nitro Enclaves with KMS

A hands-on tutorial: send a message between two servers so that only a specific, measured enclave on the receiving side can ever read it, using AES-GCM, RSA keys in KMS, and an attestation document.

  • #aws
  • #nitro-enclaves
  • #kms
  • #encryption
  • #security
  • #tutorial

Picture two services. The first, call it Server A, builds a settlement file every night with account numbers in it. The second, Server B, belongs to a partner pipeline and needs that file. Both run on EC2. Both teams are careful people. TLS is on everywhere.

Then the security review asks one question that TLS cannot answer: who can read this data once it is sitting in memory on Server B?

The honest answer is "anyone with root on that box". An engineer on a debugging session, a compromised sidecar, a memory dump taken for a perfectly good reason. TLS protected the wire. It never promised anything about the machine at the other end.

This post is how I closed that gap with AWS Nitro Enclaves and KMS. The two servers and the file are invented, but the setup is real, I have run this pattern, and everything below is meant to be followed along on your own account. By the end, Server A will encrypt a message inside an enclave, Server B will decrypt it inside an enclave, and a root shell on either host will see nothing useful.

If you read my earlier post on Nitro Enclaves attestation documents, this is the sequel. There I validated the attestation document by hand in Python. Here I let KMS do the validating, which is the version you will actually want in production.

What we are building

Server B: where the key can be used Architecture of Server B: the envelope reaches the parent, the enclave asks KMS to decrypt with an attestation document, and a direct decrypt from the parent is refused at the key policy. SERVER B HOST · UNTRUSTED NITRO ENCLAVE AWS KMS · THE GATE ENVELOPE VSOCK NO DOC DENIED DECRYPT + DOC SEALED KEY PCR0 MATCHES EXTServer A enclaveRSA-OAEP wrap HOSTParent appserver-b-instance-role ENCEnclave appkmstool + AES-GCM KMSKey BRSA_2048 private half GATEKey policyImageSha384 = PCR0 LEGENDUntrusted hostEnclaveKMSExternalEnvelopeRequestReturnRefused
Server B's side of the exchange. A direct decrypt from the parent stops at the key policy; only the enclave, with its attestation document, gets through.

The diagram zooms in on Server B, the receiving side, because that is where the trust boundaries are. Server A has the same shape with the roles reversed.

Each server is an EC2 instance (the parent) that runs a Nitro Enclave. An enclave is a tiny VM carved out of the parent's CPU and memory. It has no network, no disk and no SSH. The only way in or out is a vsock, a local socket between the parent and the enclave.

The flow, in plain words:

  1. A wants to send B a message. A's enclave makes a random AES key, encrypts the message with it, then wraps that AES key with B's RSA public key. It hands back an envelope containing both.
  2. A sends the envelope to B over any transport you like. It is safe to log.
  3. B's enclave receives the envelope and asks KMS to unwrap the AES key. KMS holds B's RSA private key and will never release it. KMS only agrees to unwrap if the caller proves, with an attestation document, that it is B's exact enclave image.
  4. KMS returns the AES key encrypted to the enclave, and the enclave decrypts the message.

Both servers have their own KMS key, so the same setup works in the other direction.

The threat model, before any code

I like to write down what a design does and does not defend against before building it. For this one:

  • Defends against: an operator, a root user on the parent instance, or an attacker who lands on it. They can see the envelope and relay bytes, but cannot read plaintext or call KMS to unwrap the key.
  • Defends against: someone with plain IAM access to the KMS key. The key policy requires an attestation document, so kms:Decrypt from a laptop or from the parent fails.
  • Does not defend against: a bug in your enclave code. The enclave is a locked room, but whatever you put in it can still leak data if you tell it to.
  • Does not give you sender authentication. Anyone who has B's public key can build a valid envelope. I come back to this at the end.

Keep that list nearby. Most of the design decisions below exist to make the first two lines true.

Why envelope encryption, and why KMS

You might wonder why not just encrypt the message with B's RSA public key and be done. Two reasons.

RSA can only encrypt a small amount. With a 2048-bit key and OAEP padding using SHA-256, the limit is 190 bytes. Your settlement file is not 190 bytes.

So we use the pattern every cloud system uses: envelope encryption. A fast symmetric cipher (AES-256-GCM) encrypts the data with a one-off key, and the slow asymmetric cipher (RSA) encrypts only that small key.

Building the envelope inside the sender enclave Data flow inside Server A's enclave: a fresh AES key encrypts the plaintext, Server B's public key wraps the AES key, and both land in one envelope. SERVER A · INSIDE THE ENCLAVE CIPHERTEXT WRAPPED KEY INPlaintextyour message INB's public keypinned by SHA-256 AESAES-256-GCMnonce + aad DEKFresh AES keyrandom, per message RSARSA-OAEPSHA-256 + MGF1 OUTEnvelope (JSON)wrapped_keynonceciphertext + tagaad LEGENDInputOperationSecretOutputData flow
Envelope encryption inside the sender enclave: a one-off AES key for the message, the receiver's RSA public key for the AES key.

The second reason is where the private key lives. If B's RSA private key sat on B's disk, anyone who owns B owns every message. With a KMS key of type RSA_2048, the private half is created inside KMS and never leaves. B can only ask KMS to use it, and KMS decides who gets to ask. That decision is where the attestation document comes in.

The trick: KMS can answer to an enclave

Normally kms:Decrypt returns the plaintext to whoever called it. If an enclave called it, the plaintext would travel back through the parent's network stack, which is exactly the thing we do not trust.

KMS has a feature built for this. You pass a Recipient parameter containing the enclave's attestation document. Two things then happen:

  • KMS checks the document and evaluates condition keys in your key policy against it, for example "the image hash must equal this value".
  • KMS encrypts the result to the public key inside the document and returns it in CiphertextForRecipient. The plaintext field comes back empty. Only the enclave holds the matching private key, so only the enclave can read the answer.
Decrypting with KMS and an attestation document Sequence between the parent, the enclave, the Nitro Secure Module and KMS, ending with the data key returned sealed to the enclave. DECRYPT, CREDS EPHEMERAL KEY ATTEST(PUBKEY) SIGNED DOC VIA PROXY DECRYPT + DOC CHECK PCR0 VIA PROXY SEALED KEY AES-GCM OPEN RESULT HWNSMNitro Secure Module ENCEnclavekmstool + app HOSTParentvsock-proxy · IMDS KMSAWS KMSDecrypt + Recipient LEGENDDeviceEnclaveUntrusted hostKMSActivationCallReturnKey reply
Receiver-side decrypt. KMS only releases the data key if the attestation document matches the key policy, and it returns the key encrypted to the enclave.

The attestation document is a signed statement from the Nitro hypervisor. In short it says: "this enclave, running this exact image, asked me to include this public key". Here is what is in it and which parts KMS cares about.

What KMS checks in an attestation document Fields of the attestation document on the left, each paired with the KMS check that consumes it on the right. ATTESTATION DOCUMENT AWS KMS · WHAT IT CHECKS VERIFIES MUST MATCH ENCRYPTS TO CERTCert chainleaf + cabundle PCRPCR0hash of the image KEYEphemeral pubkeymade in the enclave CHKChains to Nitro rootsignature + freshness POLEquals pinned PCR0?key policy · ImageSha384 OUTSeals the replyCiphertextForRecipient LEGENDDocument fieldPinned valueKMS stepChecked by
What is inside an attestation document and which parts KMS actually checks.

PCRs, in plain words

PCR stands for Platform Configuration Register. The name comes from TPM chips, where a PCR is a slot that holds a running hash of what was loaded. In Nitro, the hypervisor measures your enclave when it starts and records the results as a small set of SHA-384 hashes. Those go into the attestation document, signed by Nitro, so nobody inside the enclave or on the parent can edit them.

What they mean:

  • PCR0 is a hash of the whole enclave image file. Change one byte of your code or dependencies, rebuild, and PCR0 changes.
  • PCR1 is a hash of the Linux kernel and boot ramdisk used by the enclave.
  • PCR2 is a hash of the application you put in the image, without the kernel.
  • PCR3 is a hash of the IAM role attached to the parent instance. It ties the enclave to the role that launched it.
  • PCR4 is a hash of the parent instance ID. It ties the enclave to one specific instance.
  • PCR8 is a hash of the certificate that signed the enclave image file, when you sign it.

Why they matter: a PCR is the answer to "exactly what code is running here?", and it comes from the hypervisor, not from the code's own claims. A key policy that compares PCR values turns "trust this enclave" into "trust this build". KMS can check PCR0 through 4 and PCR8 with condition keys, and kms:RecipientAttestation:ImageSha384 is the one for PCR0, which is what we use below.

In practice you pin PCR0 (the strictest, since it names one exact build), or PCR8 (any image signed by your pipeline). PCR3 and PCR4 are useful extras when you also want to restrict which role or instance may run it. In debug mode all of them are zeros, which is why debug enclaves can never satisfy a real policy.

Before you start

You need:

  • An AWS account and the AWS CLI configured.
  • Two EC2 instances (or one, if you want to run both roles on it) that support Nitro Enclaves, for example m5.xlarge on Amazon Linux 2023, launched with enclave support turned on (--enclave-options Enabled=true).
  • On each instance, an IAM instance role. Server A's role needs kms:GetPublicKey on B's key. Server B's role needs kms:Decrypt on B's key.
  • Docker, socat and the Nitro CLI on each instance.
  • Node 20+ on the parent for the TypeScript script.

I will walk through Server B in detail, since that is where the interesting part is. Server A is the same with the names swapped.

One caveat I want to be upfront about. The envelope logic below has a local test that I ran, but the enclave, kmstool and KMS parts depend on your AWS environment. Flag names and paths for kmstool_enclave_cli follow its README at the time of writing, so check them against the version you build.

Step 1: create the KMS keys

One key per server. They are asymmetric, so the key spec is set at creation and cannot change later.

aws kms create-key \
  --key-spec RSA_2048 \
  --key-usage ENCRYPT_DECRYPT \
  --description "server-b envelope key" \
  --query KeyMetadata.KeyId --output text

aws kms create-alias --alias-name alias/server-b --target-key-id <key-id-from-above>

Repeat with alias/server-a. RSA_2048 is fine for wrapping a 32-byte key. KMS also offers 3072 and 4096 if your policy requires them.

Leave the default key policy in place for now. We will tighten it in Step 6, once we know what to pin it to.

Pin the peer's public key

Here is a subtle point that is easy to miss. Server A's enclave needs B's public key to wrap the AES key. But the enclave has no network, so the parent fetches it from KMS and passes it in. And the parent is the thing we do not trust. A hostile parent could hand the enclave its own public key, and A would happily encrypt to the wrong party.

The fix is to pin. Compute the SHA-256 of B's public key once, and bake it into A's enclave image. Anything else gets refused.

aws kms get-public-key --key-id alias/server-b \
  --query PublicKey --output text | base64 -d | sha256sum | cut -d' ' -f1

Keep that hex string. We will pass it to the Docker build for Server A. Because it is baked into the image, it also becomes part of A's PCR0, so changing the pin changes A's measurement.

Step 2: the enclave application

This is the heart of it. One Python file, two operations. I will show it whole and then talk through it.

import base64, hashlib, json, os, socket, subprocess

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

PORT = 5005        # vsock port the parent talks to
KMS_PROXY = 8000   # vsock port where the parent runs vsock-proxy -> KMS
PINNED = os.environ["RECIPIENT_PUBKEY_SHA256"]  # baked into the image, so it is part of PCR0
OAEP = padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None)

b64 = lambda b: base64.b64encode(b).decode()


def encrypt(req):
    spki = base64.b64decode(req["recipient_public_key"])
    if hashlib.sha256(spki).hexdigest() != PINNED:
        raise ValueError("recipient public key does not match the pinned fingerprint")
    pub = serialization.load_der_public_key(spki)

    dek = AESGCM.generate_key(bit_length=256)
    nonce = os.urandom(12)
    aad = req["aad"].encode()
    ciphertext = AESGCM(dek).encrypt(nonce, base64.b64decode(req["plaintext"]), aad)
    return {
        "v": 1,
        "wrapped_key": b64(pub.encrypt(dek, OAEP)),
        "nonce": b64(nonce),
        "ciphertext": b64(ciphertext),  # includes the 16-byte GCM tag
        "aad": req["aad"],
    }


def unwrap_with_kms(req):
    """Ask KMS to decrypt the wrapped key. kmstool attaches our attestation document."""
    c = req["credentials"]
    out = subprocess.run(
        [
            "/app/kmstool_enclave_cli", "decrypt",
            "--region", req["region"],
            "--proxy-port", str(KMS_PROXY),
            "--aws-access-key-id", c["access_key_id"],
            "--aws-secret-access-key", c["secret_access_key"],
            "--aws-session-token", c["session_token"],
            "--key-id", req["key_id"],
            "--encryption-algorithm", "RSAES_OAEP_SHA_256",
            "--ciphertext", req["envelope"]["wrapped_key"],
        ],
        capture_output=True, text=True, check=True,
    ).stdout
    return base64.b64decode(out.split(":", 1)[1].strip())  # "PLAINTEXT: <base64>"


def decrypt(req):
    env = req["envelope"]
    dek = unwrap_with_kms(req)
    plaintext = AESGCM(dek).decrypt(
        base64.b64decode(env["nonce"]),
        base64.b64decode(env["ciphertext"]),
        env["aad"].encode(),
    )
    return {"plaintext": b64(plaintext)}


HANDLERS = {"encrypt": encrypt, "decrypt": decrypt}


def serve():
    s = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM)
    s.bind((socket.VMADDR_CID_ANY, PORT))
    s.listen()
    while True:
        conn, _ = s.accept()
        with conn, conn.makefile("rwb") as f:
            try:
                req = json.loads(f.readline())
                res = HANDLERS[req["op"]](req)
            except Exception as e:  # error text goes back to the parent, keep it free of secrets
                res = {"error": f"{type(e).__name__}: {e}"}
            f.write(json.dumps(res).encode() + b"\n")


if __name__ == "__main__":
    serve()

A few things worth pointing out.

The AES key never touches the parent. encrypt creates it, uses it and drops it. decrypt receives it from KMS already encrypted to this enclave. The parent only ever sees the envelope.

aad is the cheap way to bind context. GCM authenticates an extra string called additional authenticated data. I put server-a->server-b in it, so an envelope cannot be quietly replayed on a different route. Note this is context binding, not authentication of the sender.

Why shell out to kmstool_enclave_cli? The call that matters is Decrypt with a Recipient. To make it, you have to fetch an attestation document from the Nitro Secure Module, embed an ephemeral public key, sign the request with SigV4, talk TLS over vsock and then unwrap the CMS structure KMS returns in CiphertextForRecipient. AWS ships a C tool that does all of it. Writing that yourself is a fun weekend and a poor use of a work week. This is the one place I deliberately took the boring option.

Credentials come from the parent. The enclave has no instance metadata service, so the parent reads the role's temporary credentials and passes them in each request. They are short-lived and scoped by the instance role, and the key policy is what actually protects the key.

Randomness. Everything here rests on the AES key and the nonce being unpredictable, and an enclave is a hard place to get randomness: no disk, no network, no keyboard, none of the timing noise a normal machine gathers. A VM that boots with poor entropy and then generates keys is a classic way to lose a whole security design.

Nitro covers this with the Nitro Secure Module (NSM), which serves random bytes from the Nitro hypervisor through a GetRandom call. Newer enclave kernels also expose it as a hardware RNG device, which feeds the kernel's random pool. So os.urandom and AESGCM.generate_key, which draw from that pool, get their seed from the NSM rather than from guessable boot-time state. That is why I trust the key generation in encrypt. My older post patched Python's random source because early enclave images lacked this, so check yours: inside a debug-mode enclave, cat /sys/devices/virtual/misc/hw_random/rng_current should print nsm-hwrng, and you can patch the random source as I did before if it does not.

A precision note, because this gets repeated loosely: the NSM's output is cryptographically secure and hardware-backed, and that is the property you need. AWS does not publish the physical design of the entropy source, so I would not describe it as a "true random number generator" in the physics sense. For AES keys and GCM nonces the difference does not matter. A random 12-byte nonce is also safe here only because every message gets a brand new key, so the nonce never repeats under the same key.

A local test you can run today

You can check the envelope logic on your laptop with no AWS at all. Swap unwrap_with_kms for a function that uses a local RSA key, and make sure tampering and key swaps fail.

import base64, hashlib, os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

priv = rsa.generate_private_key(65537, 2048)  # stand-in for the KMS key
spki = priv.public_key().public_bytes(
    serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
os.environ["RECIPIENT_PUBKEY_SHA256"] = hashlib.sha256(spki).hexdigest()

import app

app.unwrap_with_kms = lambda req: priv.decrypt(
    base64.b64decode(req["envelope"]["wrapped_key"]),
    padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None))

b64 = lambda b: base64.b64encode(b).decode()
env = app.encrypt({"recipient_public_key": b64(spki), "plaintext": b64(b"hello from A"), "aad": "a->b"})
assert base64.b64decode(app.decrypt({"envelope": env})["plaintext"]) == b"hello from A"

for tweak in (dict(env, ciphertext=b64(b"x" * 40)), dict(env, aad="a->evil")):
    try:
        app.decrypt({"envelope": tweak})
        raise SystemExit("tampering went unnoticed")
    except SystemExit:
        raise
    except Exception:
        pass  # expected: GCM refuses

print("ok")

Install cryptography, put the test next to app.py and run it. It prints ok. Importing app never starts the server because serve() sits behind the __main__ guard.

Step 3: package it as an enclave image

First build kmstool_enclave_cli and its libnsm.so from the AWS SDK repo:

git clone https://github.com/aws/aws-nitro-enclaves-sdk-c.git
cd aws-nitro-enclaves-sdk-c/bin/kmstool-enclave-cli
./build.sh
# produces kmstool_enclave_cli and libnsm.so in this directory

Copy those two files next to app.py, then write the Dockerfile:

FROM public.ecr.aws/amazonlinux/amazonlinux:2023

RUN dnf install -y python3 python3-pip && pip3 install cryptography && dnf clean all

WORKDIR /app
COPY kmstool_enclave_cli libnsm.so app.py ./
ENV LD_LIBRARY_PATH=/app

# Fingerprint of the *peer's* public key. Part of the image, so part of PCR0.
ARG RECIPIENT_PUBKEY_SHA256
ENV RECIPIENT_PUBKEY_SHA256=${RECIPIENT_PUBKEY_SHA256}

CMD ["python3", "/app/app.py"]

If kmstool_enclave_cli complains about a missing shared library, run ldd on it inside the image. The tool is built on Amazon Linux 2, so a different base image might want a different set of libraries.

Build for Server B. B's enclave only decrypts in the demo, but both operations live in one file, so it still needs a pin. Use A's key fingerprint, computed the same way as above:

docker build --build-arg RECIPIENT_PUBKEY_SHA256=<fingerprint-of-A's-key> -t server-b-enclave .

Step 4: the parent script

The parent is the untrusted half, and it is deliberately boring. It fetches credentials, fetches B's public key, and shuttles JSON to the enclave.

One practical snag: Node has no built-in support for vsock. Rather than reaching for a native addon, I let socat translate a local TCP port into vsock, and Node just opens a plain socket.

// parent.ts, run with: npx tsx parent.ts <encrypt|decrypt> ...
import net from "node:net";
import { KMSClient, GetPublicKeyCommand } from "@aws-sdk/client-kms";

const REGION = process.env.AWS_REGION ?? "us-east-1";
const ENCLAVE = { host: "127.0.0.1", port: 5005 }; // socat bridges this to vsock

function callEnclave(req: object): Promise<any> {
  return new Promise((resolve, reject) => {
    const sock = net.connect(ENCLAVE, () => sock.write(JSON.stringify(req) + "\n"));
    let buf = "";
    sock.on("data", (d) => (buf += d));
    sock.on("error", reject);
    sock.on("end", () => {
      const res = JSON.parse(buf);
      res.error ? reject(new Error(res.error)) : resolve(res);
    });
  });
}

// The enclave has no IMDS, so hand it the instance role's temporary credentials.
async function roleCredentials() {
  const base = "http://169.254.169.254/latest";
  const token = await (
    await fetch(`${base}/api/token`, {
      method: "PUT",
      headers: { "X-aws-ec2-metadata-token-ttl-seconds": "300" },
    })
  ).text();
  const headers = { "X-aws-ec2-metadata-token": token };
  const role = (await (await fetch(`${base}/meta-data/iam/security-credentials/`, { headers })).text()).trim();
  const c = await (await fetch(`${base}/meta-data/iam/security-credentials/${role}`, { headers })).json();
  return { access_key_id: c.AccessKeyId, secret_access_key: c.SecretAccessKey, session_token: c.Token };
}

const [cmd, keyId, arg] = process.argv.slice(2);
const route = "server-a->server-b";

if (cmd === "encrypt") {
  // keyId = the RECEIVER's KMS key. We only need its public half.
  const { PublicKey } = await new KMSClient({ region: REGION }).send(new GetPublicKeyCommand({ KeyId: keyId }));
  const { envelope } = await callEnclave({
    op: "encrypt",
    recipient_public_key: Buffer.from(PublicKey!).toString("base64"),
    plaintext: Buffer.from(arg).toString("base64"),
    aad: route,
  });
  console.log(JSON.stringify(envelope));
} else if (cmd === "decrypt") {
  // keyId = OUR KMS key, arg = the envelope JSON we received.
  const { plaintext } = await callEnclave({
    op: "decrypt",
    region: REGION,
    key_id: keyId,
    credentials: await roleCredentials(),
    envelope: JSON.parse(arg),
  });
  console.log(Buffer.from(plaintext, "base64").toString());
}

Install the one dependency with npm i @aws-sdk/client-kms tsx.

Notice the shape of the trust. The parent calls GetPublicKey, which is public information, so nothing sensitive crosses it. For decrypt it passes credentials that are only useful together with an attestation document the parent cannot forge. In this demo the plaintext does travel back to the parent so you can see it. In a real system you would do the business logic inside the enclave, or return only the part the caller needs.

Step 5: run the enclave

On each instance, install the tooling and give the enclave some resources:

sudo dnf install -y aws-nitro-enclaves-cli aws-nitro-enclaves-cli-devel docker socat
sudo usermod -aG ne,docker $USER   # log out and back in afterwards

# /etc/nitro_enclaves/allocator.yaml -> memory_mib: 2048, cpu_count: 2
sudo systemctl enable --now docker nitro-enclaves-allocator.service

Build the enclave image file. The output you need is the PCR0 value (the hashes below are made up):

nitro-cli build-enclave --docker-uri server-b-enclave:latest --output-file server-b.eif
{
  "Measurements": {
    "HashAlgorithm": "Sha384 { ... }",
    "PCR0": "5f4e0c...e91b",
    "PCR1": "bcdf05...a2c7",
    "PCR2": "3a2f9d...07c4"
  }
}

Copy PCR0. Every build of this image has one, and changing a single byte changes it. Now start the enclave and the two small bridges:

# 1. Run the enclave (production mode, no --debug-mode)
nitro-cli run-enclave --eif-path server-b.eif --cpu-count 2 --memory 1024 --enclave-cid 16

# 2. Let the enclave reach KMS. Allow the endpoint in /etc/nitro_enclaves/vsock-proxy.yaml first.
vsock-proxy 8000 kms.us-east-1.amazonaws.com 443 --config /etc/nitro_enclaves/vsock-proxy.yaml &

# 3. Bridge a local TCP port to the enclave for the Node script
socat TCP-LISTEN:5005,bind=127.0.0.1,fork,reuseaddr VSOCK-CONNECT:16:5005 &

If the enclave will not start, raise --memory. The image is unpacked into enclave RAM and a Python runtime plus the C library needs more than you might expect.

The vsock-proxy is just a TCP forwarder. TLS to KMS is terminated inside the enclave, so the parent sees encrypted bytes and nothing else.

Step 6: lock the key to that enclave

This is the step that turns the whole thing from "an enclave that happens to work" into "a key that only this enclave can use". Put this on Server B's key, with your PCR0:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "KeyAdministration",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:role/your-key-admin-role" },
      "Action": [
        "kms:Describe*", "kms:Get*", "kms:List*", "kms:Put*", "kms:Update*",
        "kms:Enable*", "kms:Disable*", "kms:TagResource", "kms:UntagResource",
        "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion",
        "kms:CreateAlias", "kms:DeleteAlias"
      ],
      "Resource": "*"
    },
    {
      "Sid": "PeerMayFetchPublicKey",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:role/server-a-instance-role" },
      "Action": "kms:GetPublicKey",
      "Resource": "*"
    },
    {
      "Sid": "OnlyThisEnclaveMayDecrypt",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::111122223333:role/server-b-instance-role" },
      "Action": "kms:Decrypt",
      "Resource": "*",
      "Condition": {
        "StringEqualsIgnoreCase": {
          "kms:RecipientAttestation:ImageSha384": "5f4e0c...e91b"
        }
      }
    }
  ]
}
aws kms put-key-policy --key-id alias/server-b --policy-name default --policy file://server-b-key-policy.json

Two decisions in that policy matter more than the condition itself.

First, there is no arn:aws:iam::ACCOUNT:root statement. That principal lets any IAM policy in the account grant access to the key, which would let someone add an unconditional kms:Decrypt to some role and walk around your attestation condition. Every principal that can use the key is named here. Check your admin ARN twice before you put this policy. You are choosing to own the key policy outright.

Second, the admin statement deliberately has no kms:Decrypt. Key administrators can manage the key, and cannot use it. That is the separation of duties your auditors will ask for.

Step 7: run it, then try to break it

On Server A, encrypt a message to B:

npx tsx parent.ts encrypt alias/server-b "settlement-2026-10-09: acct 4417-8890, 12,450.00 USD" > envelope.json
cat envelope.json
{"v":1,"wrapped_key":"k3Jd...==","nonce":"o8h2...","ciphertext":"Qx9v...","aad":"server-a->server-b"}

Carry that to Server B by any route. Email it if you like, it is only useful to B's enclave. Then on B:

npx tsx parent.ts decrypt alias/server-b "$(cat envelope.json)"
# settlement-2026-10-09: acct 4417-8890, 12,450.00 USD

That is the happy path. The reason I trust this design is what happens on the unhappy ones, so try these before you ship it.

Root on the parent tries to unwrap the key directly. Take the wrapped_key and call KMS from the host shell with the instance role:

jq -r .wrapped_key envelope.json | base64 -d > wrapped.bin
aws kms decrypt --key-id alias/server-b \
  --encryption-algorithm RSAES_OAEP_SHA_256 \
  --ciphertext-blob fileb://wrapped.bin
# An error occurred (AccessDeniedException) when calling the Decrypt operation

No attestation document, no kms:RecipientAttestation:ImageSha384 in the request, so the Allow never applies. The role has permission on paper and none in practice.

A modified enclave. Change one line in app.py, rebuild and run it. PCR0 is now different, so KMS refuses even though it is the same instance, the same role and the same key.

Debug mode. Run the enclave with --debug-mode to get console logs, and decryption fails too. In debug mode the attestation document reports PCRs that are all zeros, so it can never match a real PCR0. It is an easy one to trip over. Use debug mode to see logs, and test the real decrypt in production mode.

What the attestation document actually looks like

You do not need to handle it, since kmstool does, but it is useful to see once. It is a CBOR-encoded COSE_Sign1 structure, which is an array of four items, with the interesting payload in the third:

import cbor2

def peek(doc: bytes) -> dict:
    """Decode (not verify) an attestation document: COSE_Sign1 = [protected, unprotected, payload, signature]."""
    sign1 = cbor2.loads(doc)
    sign1 = getattr(sign1, "value", sign1)  # some encoders wrap it in CBOR tag 18
    return cbor2.loads(sign1[2])

# peek(doc)["pcrs"][0].hex()  ->  the PCR0 your key policy compares against
# peek(doc)["public_key"]     ->  the ephemeral key KMS will encrypt its answer to

To get a real one, call the NSM from inside an enclave through libnsm or an NSM binding. This function only decodes. Verification means checking the signature against the certificate and walking the chain up to the AWS Nitro root, which is the work KMS is doing for you at decrypt time.

What I would think about before production

Here are the parts that took me from "it works" to "I would put my name on it".

PCR0 changes on every rebuild, and your key policy pins it. That is secure and also painful, because every deploy means editing a key policy. The usual way out is to sign your enclave image file and pin on PCR8, which measures the signing certificate, so any build signed by your release pipeline is accepted. The tradeoff is that your pipeline's signing key becomes a root of trust. Pin PCR0 for the highest assurance and a rare deploy cadence. Pin PCR8 when you ship often. In both cases, roll the new value into the policy before the new enclave goes live, and keep the old value in the condition list during the changeover so you can roll back.

You have no sender authentication. Anyone holding B's public key can produce an envelope B will happily decrypt. If B needs to know the message came from A, have A's enclave sign the envelope with a separate KMS key of usage SIGN_VERIFY, and have B's enclave verify it before trusting the content. I left it out to keep the post focused. In a real system I would add it before anything else.

Replay. AAD stops an envelope being replayed on a different route. It does not stop the same envelope being delivered twice. Put a message ID and timestamp inside the encrypted payload and have the receiver keep a short window of seen IDs.

Rotation. KMS does not automatically rotate asymmetric keys. Rotation means creating a new key, pointing the peer's pin at it and keeping the old key alive until old envelopes are drained. That is why the envelope has a v field. I would also add the KMS key ID, so the receiver knows which key to ask.

Latency and quota. Each decrypt is one KMS call, plus the attestation round trip. That is fine for a file or a batch and a poor fit for a hot path doing thousands of messages a second. If you need volume, batch many records under one envelope, or switch to GenerateDataKey with a Recipient and cache the data key inside the enclave for a short, bounded time.

The parent can still say no. The parent can drop messages, delay them or refuse to start the enclave. Enclaves give you confidentiality and integrity of what runs inside, and nothing about availability. Plan for that the way you plan for any dependency.

Audit. KMS calls are logged in CloudTrail, and calls made with a Recipient carry extra attestation details, so look at a real event in your account to see what you can key on. Alert on kms:Decrypt against this key that did not come from your enclave. With the policy above those will be AccessDenied, and a sudden cluster of them is a signal worth a page.

Regulated data. If the payload is policyholder data, such as non-public personal or health information, this pattern is a strong technical control for your data protection obligations. It is not a substitute for the review your compliance team will want, covering key custody, retention, who may change the key policy and how the enclave release is approved.

When it goes wrong

The failures I hit most often, and what they usually mean:

  • AccessDeniedException from the enclave. Your PCR0 in the policy is stale, you are in debug mode, or the instance role is not the principal in the Allow statement. Compare PCR0 from nitro-cli describe-eif with the policy first.
  • kmstool hangs or fails to connect. The vsock-proxy is not running, the endpoint is missing from vsock-proxy.yaml, or the proxy port in app.py and on the host do not match.
  • error while loading shared libraries: libnsm.so. LD_LIBRARY_PATH is not set or the library was not copied into the image.
  • Wrong recipient pin. recipient public key does not match the pinned fingerprint means the parent fetched a different key than the one you baked in. Either you pointed it at the wrong alias or somebody is doing exactly what the pin exists to catch.
  • InvalidCiphertextException. The envelope was wrapped for a different key, or the key was recreated after you computed the pin.

The short version

  • Envelope encryption does the work: AES-256-GCM for the data, RSA from KMS for the key.
  • KMS private keys never leave KMS. The enclave proves itself with an attestation document, and KMS returns the data key encrypted to the enclave's own ephemeral public key.
  • The key policy is the real security boundary. Name every principal, drop the root delegation, and pin PCR0 or PCR8.
  • Pin the peer's public key inside the image. The parent is untrusted and can swap a key as easily as it can relay one.
  • Test the failures. Root on the parent, a modified image and debug mode should all be refused. If any of them works, the design is wrong.
  • Add sender signatures and replay protection before calling it done.

If you remember one thing, make it this: the point of an enclave is not that the code inside is secret. It is that someone can prove exactly which code is allowed to hold the key, and a system that is not the one you named gets nothing, whoever's hands are on the keyboard.