Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Local KMS

LocalKmsProvider encrypts DEKs with a key encryption key the process holds, with AES-256-GCM on the backend it is built with. It is in every build, with no feature, for:

  • development and tests without a cloud KMS;
  • single-host installs where the operator protects the KEK.

kms-aead builds one only when the application calls one of its constructors; no provider falls back to it. Each constructor logs one INFO event naming the KEK id, its versions and where each version came from (ephemeral, file, env or secret), never the key.

KEK ids and versions

A LocalKekId names the KEK: 1 to 255 bytes of ASCII alphanumerics, -, _ and .. Every encrypted DEK records it, and a decryption refuses a DEK encrypted under another id. The id is also the key of the DEK cache, so one id must name one set of KEK material: two providers with the same id and different keys share cache entries.

A provider holds one or more LocalKek versions of 32 bytes each. The highest version encrypts new DEKs, and every version it holds decrypts. Each encrypted DEK records its version, so adding a new version keeps the DEKs encrypted under older ones readable.

A KEK comes from one of:

  • LocalKek::from_secret: bytes the application already holds, such as a key an operator unseals with their own tool (systemd-creds, a TPM, sops, age);
  • LocalKek::from_file: the standard base64 of the 32 bytes in a file, trailing whitespace allowed. The file must be a regular file of at most 256 bytes. No permission or ownership check runs: restricting who can read or replace the file is the operator’s job;
  • LocalKek::from_env: the standard base64 of the 32 bytes in an environment variable. Be aware the variable stays in the process environment for the life of the process: the same user can read it through /proc/<pid>/environ, and every child process inherits it.

Errors name the file or the variable, never its content.

Tests and development

An ephemeral provider has one random version-1 KEK that lives as long as the provider, so the DEKs it encrypts cannot be decrypted once the process exits. It suits tests:

use kms_aead::backends::Ring;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::KmsAeadEnvelope;
use secret_vault_value::SecretValue;

#[tokio::main(flavor = "current_thread")]
async fn main() -> kms_aead::KmsAeadResult<()> {
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("tests".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;

let encrypted = envelope
    .encrypt(b"orders/1042", &SecretValue::from("secret"))
    .await?;
let decrypted: SecretValue = envelope.decrypt(b"orders/1042", &encrypted).await?;
assert_eq!(decrypted, SecretValue::from("secret"));
Ok(())
}

For a development environment whose data outlives a run, keep a KEK in a variable or a file. Generate one with:

openssl rand -base64 32
use kms_aead::providers::{LocalKek, LocalKekId, LocalKekVersion, LocalKmsProvider};
use kms_aead::backends::Ring;
use std::num::NonZeroU32;

fn example() -> kms_aead::KmsAeadResult<()> {
let version = LocalKekVersion::from(NonZeroU32::MIN);
let provider = LocalKmsProvider::<Ring>::new(
    LocalKekId::try_from("dev".to_string())?,
    vec![LocalKek::from_env(version, "APP_DEV_KEK")?],
)?;
let _ = provider;
Ok(())
}

A VM or a single host

On a host, keep the KEK out of the environment. With systemd, encrypt it with systemd-creds, bound to the host’s TPM where it has one, and let systemd pass it to the service as a credential:

openssl rand -base64 32 | systemd-creds encrypt --name=kek-v1 - /etc/credstore.encrypted/kek-v1
[Service]
LoadCredentialEncrypted=kek-v1:/etc/credstore.encrypted/kek-v1

systemd decrypts it into $CREDENTIALS_DIRECTORY, readable only by the service:

use kms_aead::providers::{LocalKek, LocalKekId, LocalKekVersion, LocalKmsProvider};
use kms_aead::backends::AwsLc;
use std::num::NonZeroU32;
use std::path::PathBuf;

fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let credentials = PathBuf::from(std::env::var("CREDENTIALS_DIRECTORY")?);
let version = LocalKekVersion::from(NonZeroU32::MIN);

let provider = LocalKmsProvider::<AwsLc>::new(
    LocalKekId::try_from("orders-host-1".to_string())?,
    vec![LocalKek::from_file(version, &credentials.join("kek-v1"))?],
)?;
let _ = provider;
Ok(())
}

To rotate, add kek-v2 the same way and list both versions. Version 2 encrypts new DEKs, and the DEKs encrypted under version 1 still decrypt. Version 1 can be dropped once nothing encrypted under it is left.

Limits

  • No IAM, no audit log and no HSM. Protecting the KEK, at rest and in backups, is the operator’s job;
  • Every DEK encryption is one AES-256-GCM encryption with a random 96-bit nonce, so a KEK version is safe for about 2^32 encryptions. With the per-call envelope every value costs one encryption: a host sealing 1,000 values per second spends 2^32 encryptions in 50 days. Use DEK reuse so that one encryption serves many values, and add a new KEK version before the budget is spent;
  • Crypto-shredding means destroying every copy of the KEK, backups included;
  • DEKs encrypted by a local KEK cannot be exported to BigQuery, which decrypts with Cloud KMS.

Errors

An encryption fails with RANDOM_GENERATOR when the backend’s CSPRNG fails. A decryption fails with LOCAL_ENCRYPTED_DEK_FORMAT when the encrypted DEK is not in the local layout, LOCAL_KEK_ID_MISMATCH when it was encrypted under another KEK id, LOCAL_KEK_UNKNOWN_VERSION when the provider does not hold its version, and DECRYPT when it is not authentic under that version.

Creating a provider fails with INVALID_LOCAL_KEK_ID for an invalid id, LOCAL_KEK_VERSIONS when the versions are empty or list one version twice, and LOCAL_KEK_SOURCE or LOCAL_KEK_LENGTH when a KEK cannot be read or is not 32 bytes.