A proof of concept demonstrating AWS KMS encryption backed by a local SoftHSM key, using the XKS Proxy API. The cryptographic master key lives on a machine I fully own, not in AWS, not in a managed service, but on my personal hardware.

Motivation

I wanted to back AWS KMS with a hardware security module under my physical control. SoftHSM on a local machine serves as a stand-in for a real HSM for this POC. The EC2 instance and SSH reverse tunnel exist only because AWS KMS requires an HTTPS endpoint with a valid TLS certificate to reach the XKS proxy. The actual key material never leaves the local machine.


Architecture

The intended model

The idea behind XKS is straightforward: you run an HSM on your premises, front it with a service layer that implements the XKS Proxy API, and AWS KMS calls that proxy whenever it needs to encrypt or decrypt. The proxy talks to any Hardware Security Module that supports PKCS#11 v2.40 – Thales Luna, Entrust nShield, or anything else that speaks the standard. AWS publishes the API spec and a reference implementation in Rust, so you can build and run your own.

S3 (SSE-KMS) → KMS → XKS Proxy → HSM (PKCS#11 v2.40)

In production, the proxy sits next to the HSM on the same network. Simple.

My workaround

I don’t have an HSM or a server with a public IP and a TLS certificate. So I stitched together a workaround to validate the concept: SoftHSM on my Mac as the HSM stand-in, an EC2 instance running the XKS proxy behind an ALB for TLS termination, and p11-kit remoting the PKCS#11 calls back to my machine over an SSH reverse tunnel.

S3 (SSE-KMS) → KMS → XKS Proxy (EC2) → p11-kit client
                                              ↓
                                     SSH reverse tunnel
                                              ↓
                                     p11-kit server (Mac) → SoftHSM

Not what you’d run in production. But it proves the point: when S3 encrypts an object, the actual cryptographic operation happens on my machine, with a key that never leaves it.

Component Location Purpose
xks-proxy EC2 (aarch64, t4g.nano) AWS reference XKS proxy (Rust)
ALB AWS TLS termination with valid certificate
p11-kit 0.26.2 EC2 + Mac PKCS#11 remoting over Unix sockets
SoftHSM v2 Mac (Homebrew) Software HSM holding the AES-256 key

Setup

Create the SoftHSM AES-256 key

softhsm2-util --init-token --slot 0 --label foo --pin 1234 --so-pin 0000
softhsm2-util --show-slots

pkcs11-tool --module /opt/homebrew/lib/softhsm/libsofthsm2.so \
  --login --pin 1234 \
  --keygen --key-type AES:32 --label foo \
  --token-label foo

Expected output:

Secret Key Object; AES length 32
  label:      foo
  Usage:      encrypt, decrypt, sign, verify, wrap, unwrap
  Access:     never extractable, local

Deploy the infrastructure

A single create.sh script handles everything: cross-compiling xks-proxy for aarch64 via cargo zigbuild, deploying CloudFormation (ALB, EC2, Route53, CloudWatch), SCPing the binary and config to EC2, and starting the systemd service.

The p11-kit version issue

This is where I spent most of my time. The AL2023 repo ships p11-kit 0.24.1, which lacks AES-GCM RPC serialization entirely, every encrypt/decrypt call fails with CKR_MECHANISM_INVALID.

Version AES-GCM RPC Object Handles Status
0.24.1 (AL2023 repo) No N/A CKR_MECHANISM_INVALID
0.25.x Yes Broken across sessions CKR_OBJECT_HANDLE_INVALID
0.26.2 Yes Works Working

Version 0.26.2 must be built from source on EC2. And it must match on both ends – a version mismatch between client (EC2) and server (Mac) causes RPC protocol errors.

# On EC2 (t4g.nano, 512MB RAM)
sudo dnf install -y meson ninja-build gcc libtasn1-devel libffi-devel
curl -sL https://github.com/p11-glue/p11-kit/releases/download/0.26.2/p11-kit-0.26.2.tar.xz | tar xJ
cd p11-kit-0.26.2
meson setup _build --prefix=/usr --libdir=/usr/lib64
ninja -C _build -j1    # -j1 required: t4g.nano OOMs on parallel builds
sudo ninja -C _build install

Start the PKCS#11 tunnel

Two terminals on the Mac:

Terminal 1 – p11-kit server:

p11-kit server --provider /opt/homebrew/lib/softhsm/libsofthsm2.so "pkcs11:"

Terminal 2 – SSH reverse tunnel:

export P11_KIT_SERVER_ADDRESS=unix:path=/var/folders/.../pkcs11-XXXX

ssh -i ~/Downloads/EC2Tutorial2.pem \
  -R /home/ec2-user/.p11-kit.sock:${P11_KIT_SERVER_ADDRESS#unix:path=} \
  ec2-user@<EC2_IP>

This forwards the EC2 Unix socket to the Mac’s p11-kit server socket. From EC2’s perspective, PKCS#11 operations on /home/ec2-user/.p11-kit.sock transparently reach SoftHSM on the Mac.

Test end-to-end

# Health check
curl https://xks.lemaire.tel/ping

# Upload with XKS encryption
echo "hello from softhsm" > /tmp/test.txt
aws s3 cp /tmp/test.txt s3://xks-proxy-poc-test/test.txt \
  --region eu-west-3 \
  --sse aws:kms \
  --sse-kms-key-id cd0608a9-0726-4187-b1b1-d0b08370d8f9

# Download (decryption goes through SoftHSM)
aws s3 cp s3://xks-proxy-poc-test/test.txt /tmp/downloaded.txt --region eu-west-3
cat /tmp/downloaded.txt
# hello from softhsm

A Rust Undefined Behavior Bug in the XKS Proxy

The interesting problem I hit wasn’t infrastructure, it was a compiler optimization bug in the AWS reference implementation.

The GetKeyMetadata handler declares stack variables as immutable (let key_type = 0;) and passes pointers to them via set_ck_ulong() for C_GetAttributeValue to write into. The C function writes the correct values (e.g., key_type=31 for CKK_AES), but the Rust compiler’s release-mode optimizer treats the immutable bindings as compile-time constants and inlines 0 wherever they’re subsequently read.

Impact: keyspec(0, 0) = "RSA_0" instead of keyspec(31, 32) = "AES_256". KMS rejected the key metadata.

This only manifests in release builds. Debug builds work fine because the optimizer doesn’t inline the constants.

To fix: Force the compiler to re-read from actual memory after the C call:

let key_type = unsafe { std::ptr::read_volatile(&key_type) };
let key_size = unsafe { std::ptr::read_volatile(&key_size) };

The root cause is Rust undefined behavior: writing through a raw pointer derived from an immutable reference. The proper long-term fix would be UnsafeCell or MaybeUninit in the rust-pkcs11 crate’s CK_ATTRIBUTE::set_ck_ulong implementation.


What XKS Does and Does Not Protect

XKS protects against

  • Future unauthorized access by AWS. Disconnect the tunnel, shut down the proxy, and AWS cannot decrypt data going forward.
  • Regulatory and sovereignty requirements. Cryptographic master keys remain under your control, with an audit trail of all key operations.
  • Cloud provider key management concerns. The master key material stays entirely outside AWS.

XKS does not protect against

  • AWS actively retaining or exfiltrating data. They could copy plaintext before encryption or retain data encryption keys.
  • Legal compulsion. If DEKs were retained in AWS systems, AWS could be compelled to produce them.
  • Retrospective decryption. If AWS legitimately decrypted an object to serve a GET request, they could have retained the plaintext.

Bottom line

External key management is about governance, operational control, and trust reduction. It is not about protecting against a malicious provider – they see plaintext anyway. If the cloud provider itself is your threat model, use client-side encryption before uploading, or don’t use cloud.


Cloud Provider Comparison

AWS – External Key Store (XKS)

DIY implementation: YES. AWS publishes the XKS Proxy API specification and a reference implementation. You can build your own proxy backed by any PKCS#11-compatible HSM or key manager.

GCP – Cloud External Key Manager (EKM)

DIY implementation: NO. No public API specification, no reference implementation. You must use certified partner solutions (Thales CipherTrust, Fortanix DSM).

Azure – No external key store equivalent

DIY implementation: N/A. Azure offers BYOK (keys end up in Azure), Managed HSM (single-tenant, still in Azure), and Dedicated HSM (Thales Luna in Azure). No equivalent to XKS or EKM where keys remain outside the cloud.


Source Code

Available on GitHub: aws-kms-xks-poc

aws-kms-xks-poc/
  cloudformation.yaml          # EC2 + ALB + Route53 + CloudWatch
  configuration/settings.toml  # xks-proxy config
  create.sh                    # One-shot deploy script

Updated: