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

KMS envelope encryption for Rust

Library provides envelope encryption with a key encryption key held by a KMS, and AEAD encryption without a KMS:

  • KMS providers for:
    • Google Cloud KMS;
    • AWS KMS;
    • Azure Key Vault and Managed HSM;
    • HashiCorp Vault Transit;
    • A local KMS with a key the application holds, for development, tests and single-host installs;
  • AEAD algorithms ChaCha20-Poly1305, AES-128-GCM, AES-256-GCM and AES-256-GCM-SIV;
  • Tink’s KMS envelope format for every provider, so Tink reads the values of GCP and AWS keys and the library reads Tink’s, see Formats;
  • Crypto backends ring, aws-lc-rs (including its FIPS module) and the pure Rust RustCrypto crates. A value sealed by one backend opens with any other;
  • Per-call envelopes, store-less DEK reuse, and an in-process cache of decrypted data encryption keys;
  • Deterministic encryption with AES-SIV, for values that are looked up by equality;
  • Typed fields, Encrypted<T, F>, stored with serde, tokio-postgres or sqlx;
  • Optional KMS-side random generators for GCP and AWS;
  • Tracing spans for every operation and every KMS call, with no plaintext, key material or AAD in them;
  • No unsafe code.

To start using the library, see Getting started.

Envelope encryption

A value is sealed with an AEAD algorithm under a data encryption key (DEK). The DEK is then encrypted by a key encryption key (KEK) that never leaves the KMS, and the encrypted DEK is stored next to the cipher text. KMS documentation, and Azure Key Vault’s wrapKey and unwrapKey operations, call this wrapping the DEK. To read the value, the KMS decrypts the DEK, and the DEK opens the value locally.

So the KMS sees only DEKs, never the data, and a KMS call is needed only to encrypt or decrypt a DEK. Access to the data is controlled by access to the KEK: revoke the KMS permission, or destroy the key, and the stored values cannot be opened.

The additional authenticated data (AAD) is authenticated but not encrypted: a value opens only with the AAD it was sealed with. Use it to tie a value to where it is stored, such as a table and a row id, so that a value copied to another row fails to open.

Choosing a mode

The ways to use a KMS differ in how many DEKs exist, how often the KMS is called and what a stored value holds:

  • Per-call envelope, the default of KmsAeadEnvelope: a new DEK for every value, encrypted with one KMS call, and stored in the value itself. Every decryption decrypts its DEK with one more KMS call, unless the envelope has a DEK cache. Use it when values are written rarely, such as secrets and configuration, or when one DEK per value is a requirement. See Envelope encryption;
  • Store-less DEK reuse, a KmsAeadEnvelope with a DekReusePolicy: one DEK seals many values and is renewed by age or by count, so a write calls the KMS only to renew it. Values still carry their encrypted DEK, so nothing is stored apart from them, and reads decrypt through the DEK cache. Use it for high write rates where a KMS call per value costs too much. See DEK caching and reuse;
  • One fixed key, encrypt of AeadCipher or DeterministicCipher with one key that the KMS encrypts once and the DEK cache decrypts: values carry a 4-byte key id in Tink’s wire format instead of an encrypted DEK, and BigQuery and Tink read them with a keyset of that key. Use it for a column that BigQuery reads, with no key rotation. See One fixed key;
  • Keyring, the kms-aead-keyring crate: a keyring record per scope holds the encrypted DEK versions, and values carry a 4-byte key id in Tink’s wire format instead of an encrypted DEK. The record is the place for central rotation and retirement of keys, and for values that BigQuery and Tink read across rotations. See kms-aead-keyring.

Both envelope modes seal a value of a Rust type, such as an application newtype, into an Encrypted<T, Envelope> that carries its encrypted DEK, and serde, tokio-postgres and sqlx store it as bytes, so a column of secrets needs no store of its own. See Typed fields.

AeadCipher and DeterministicCipher work without a KMS at all, with keys the application holds.

Licence

Apache Software License (ASL)

Getting started

Cargo.toml:

[dependencies]
kms-aead = { version = "0.30", features = ["gcp-kms-encryption", "aws-lc"] }

The crate has no default features. A build makes three choices, and they do not constrain each other:

  1. the KMS providers, any number of them, or none for AEAD without a KMS;
  2. the TLS root store and the TLS crypto provider of each provider that has a choice;
  3. the crypto backends that run the AEAD algorithms, one or more.

Features

KMS providers:

  • gcp-kms-encryption: Google Cloud KMS, with the native TLS roots of the system. Be aware it also turns on native-tls in reqwest, so in the same build the Azure provider uses native-tls whatever its TLS feature;
  • gcp-kms-encryption-webpki: Google Cloud KMS with the bundled WebPKI roots;
  • gcp-kms-encryption-base: Google Cloud KMS without a TLS root store or crypto provider. The application enables them with its own gcloud-sdk features, such as tls-roots or tls-webpki-roots, and the crate does not build without one;
  • aws-kms-encryption: AWS KMS;
  • azure-kms-encryption: Azure Key Vault and Managed HSM, with rustls and the aws-lc-rs crypto provider. Other TLS variants, choose one:
    • azure-kms-encryption-tls-aws-lc-rs, the same as azure-kms-encryption;
    • azure-kms-encryption-tls-no-provider, rustls with the crypto provider the application installs. The application must install a process-default rustls CryptoProvider before creating the provider, otherwise rustls panics;
    • azure-kms-encryption-native-tls, the platform TLS library;
  • hashicorp-vault-transit-kms-encryption: HashiCorp Vault Transit, with the system root certificates and the aws-lc-rs crypto provider. To choose them, enable one root store and one crypto provider instead:
    • hashicorp-vault-transit-kms-encryption-tls-roots or hashicorp-vault-transit-kms-encryption-tls-webpki;
    • hashicorp-vault-transit-kms-encryption-tls-aws-lc-rs or hashicorp-vault-transit-kms-encryption-tls-ring.

The local KMS provider, LocalKmsProvider, is in every build and needs no feature.

Crypto backends, see Crypto backends:

  • ring: the ring library;
  • aws-lc: the aws-lc-rs library;
  • aws-lc-fips: the aws-lc-rs library on the AWS-LC FIPS module. Needs CMake and Go to build;
  • rustcrypto: the pure Rust RustCrypto crates, no C compiler needed.

Other features:

  • deterministic: deterministic encryption with AES-SIV, see Deterministic encryption;
  • legacy-formats: reads the layouts written by 0.29 and earlier. Deprecated, see Formats;
  • serde: Serialize and Deserialize for Encrypted, as bytes, for KmsAeadErrorPublicGenericDetails and for SecretValue;
  • serde-json: Json, a typed field’s plaintext of any serde type, stored as its JSON;
  • postgres: ToSql and FromSql of postgres-types for Encrypted, as BYTEA, for tokio-postgres;
  • sqlx: sqlx’s PostgreSQL traits for Encrypted, as BYTEA, without a sqlx runtime or TLS stack.

See Typed fields.

Choosing a backend

The default features of the four cloud providers already build aws-lc-rs for TLS, so aws-lc is the backend that adds no second crypto library to a provider build. ring and rustcrypto work with every provider too, and a build may enable several backends at once. Each feature adds a type to kms_aead::backends, Ring, AwsLc or RustCrypto, and every type that seals or encrypts takes one as its backend type parameter:

fn example() -> kms_aead::KmsAeadResult<()> {
use kms_aead::backends::AwsLc;
use kms_aead::{AeadAlgorithm, AeadCipher};

let cipher = AeadCipher::<AwsLc>::new(AeadAlgorithm::Aes256Gcm)?;
let _ = cipher;
Ok(())
}

There is no default backend. Code generic over the backend bounds it with B: AeadBackend and nothing else, see Crypto backends.

Feature rules

The crate refuses some feature combinations at compile time with compile_error!:

  • a provider feature without a backend feature fails with “kms-aead needs a crypto backend for its KMS providers: enable “ring”, “aws-lc”, “aws-lc-fips” or “rustcrypto”“;
  • HashiCorp Vault Transit needs exactly one of -tls-roots and -tls-webpki, and exactly one of -tls-aws-lc-rs and -tls-ring. hashicorp-vault-transit-kms-encryption already enables -tls-roots and -tls-aws-lc-rs, so combine the other variants with hashicorp-vault-transit-kms-encryption-base instead;
  • Azure needs exactly one of azure-kms-encryption-tls-aws-lc-rs (or azure-kms-encryption), azure-kms-encryption-tls-no-provider and azure-kms-encryption-native-tls.

The aws-lc-rs feature that aws-lc and aws-lc-fips enable is not a backend choice: enabled alone, the aws-lc-rs build fails. Use aws-lc or aws-lc-fips.

A build without any backend feature compiles, and LocalKmsProvider is in it, but nothing that seals or encrypts can be constructed, since no type implements AeadBackend.

First example

The local KMS needs no cloud account, so it is the quickest way to try the library. An ephemeral KEK lives as long as the process:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, 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("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;

let aad = b"orders/1042";
let secret = SecretValue::from("4111 1111 1111 1111");

let encrypted: Encrypted<SecretValue, Envelope> = envelope.encrypt(aad, &secret).await?;
let decrypted: SecretValue = envelope.decrypt(aad, &encrypted).await?;

assert_eq!(decrypted, secret);
Ok(())
}

To move to a cloud KMS, replace the provider, see KMS providers. The rest of the code stays the same.

MSRV

The minimum supported Rust version is 1.99, for every feature.

KMS providers

A provider encrypts and decrypts data encryption keys with one key encryption key (KEK). Each one is a type in kms_aead::providers, enabled by its feature:

KMSTypeFeature
Google Cloud KMSGcpKmsProvidergcp-kms-encryption
AWS KMSAwsKmsProvideraws-kms-encryption
Azure Key Vault and Managed HSMAzureKmsProviderazure-kms-encryption
HashiCorp Vault TransitHcVaultTransitProviderhashicorp-vault-transit-kms-encryption
A KEK the process holdsLocalKmsProvidernone, in every build

Every provider implements KmsAeadEncryptionProvider, and KmsAeadEnvelope takes any of them:

use kms_aead::backends::AwsLc;
use kms_aead::providers::{GcpKmsKeyRef, GcpKmsProvider};
use kms_aead::KmsAeadEnvelope;

async fn example() -> kms_aead::KmsAeadResult<()> {
let kms_ref = GcpKmsKeyRef::new(
    "my-project".to_string(),
    "europe-north1".to_string(),
    "my-key-ring".to_string(),
    "my-key".to_string(),
);
let provider = GcpKmsProvider::new(&kms_ref).await?;
let envelope = KmsAeadEnvelope::<GcpKmsProvider, AwsLc>::new(provider)?;
let _ = envelope;
Ok(())
}

Each provider names its configured KEK on every decryption, so a DEK encrypted under another KEK fails at the KMS and never opens a value. provider.kek() returns that KEK as a KekRef: the KMS and the key name. It is also the provider and key field of the KMS spans and the key of the DEK cache. Nothing else is checked: an encrypted DEK carries no context that the KMS authenticates, see Security considerations.

KMS random generators

By default a DEK comes from the CSPRNG of the crypto backend. The GCP and AWS providers can take it from the KMS instead, at the cost of one more KMS request per new DEK:

  • GCP: GcpKmsProviderOptions::new().with_use_kms_random_gen(true), Cloud KMS GenerateRandomBytes at the HSM protection level;
  • AWS: AwsKmsProviderOptions::new().with_use_kms_random_gen(true), AWS KMS GenerateRandom.

Azure, Vault Transit and the local KMS always use the backend’s CSPRNG. Nonces always come from the backend’s CSPRNG.

Your own provider

An application can implement KmsAeadEncryptionProvider for a KMS of its own, or as a wrapper around another provider. The implementation:

  • returns from kek() a KekRef with KekProvider::Other and a key name that identifies the key across every KMS instance it may address. A wrapper returns the inner provider’s kek();
  • names that KEK to the KMS on every decryption;
  • keeps key material, plaintext or encrypted, out of its errors’ Display and Debug.

generate_key_material has a default implementation that returns Ok(None), so the backend generates the key.

Google Cloud KMS

Feature gcp-kms-encryption, or gcp-kms-encryption-webpki for the bundled WebPKI roots.

GcpKmsKeyRef names a CryptoKey by its project, location, key ring and key. The provider encrypts under the primary version of the key, and each encrypted DEK records the version that encrypted it, so DEKs encrypted before a rotation still decrypt afterwards.

use kms_aead::backends::AwsLc;
use kms_aead::providers::{GcpKmsKeyRef, GcpKmsProvider, GcpKmsProviderOptions};
use kms_aead::KmsAeadEnvelope;
use secret_vault_value::SecretValue;

async fn example() -> kms_aead::KmsAeadResult<()> {
let kms_ref = GcpKmsKeyRef::new(
    "my-project".to_string(),
    "europe-north1".to_string(),
    "my-key-ring".to_string(),
    "my-key".to_string(),
);

let provider = GcpKmsProvider::new(&kms_ref).await?;
let envelope = KmsAeadEnvelope::<GcpKmsProvider, AwsLc>::new(provider)?;

// Or with DEKs from the Cloud KMS generator at the HSM protection level
let envelope = KmsAeadEnvelope::<GcpKmsProvider, AwsLc>::new(
    GcpKmsProvider::with_options(
        &kms_ref,
        GcpKmsProviderOptions::new().with_use_kms_random_gen(true),
    )
    .await?,
)?;

let cipher_text = envelope
    .encrypt(b"orders/1042", &SecretValue::from("secret"))
    .await?;
let _ = cipher_text;
Ok(())
}

Full example available here.

Authentication

GcpKmsProvider::new and with_options build a Cloud KMS client with the application default credentials. They look for credentials in the following places, preferring the first location found:

  • A JSON file whose path is specified by the GOOGLE_APPLICATION_CREDENTIALS environment variable;
  • A JSON file in a location known to the gcloud command-line tool using gcloud auth application-default login;
  • On Google Compute Engine, Cloud Run, GKE, etc., the metadata server.

For local development don’t confuse gcloud auth login with gcloud auth application-default login, since the first one authorises only the gcloud tool to access the Cloud Platform.

GcpKmsProvider::with_client takes a client the application built itself, for other credentials.

The principal needs cloudkms.cryptoKeyVersions.useToEncrypt and useToDecrypt on the key, as in the roles/cloudkms.cryptoKeyEncrypterDecrypter role, and cloudkms.locations.generateRandomBytes on the location when the KMS generator is on.

Errors

A failed KMS call is a NetworkError whose code is the gRPC status code, such as PermissionDenied or NotFound.

AWS KMS

Feature aws-kms-encryption.

AwsKmsKeyRef names the key by the AWS account and the key id, which is anything AWS KMS accepts: a key id, a key ARN, an alias name such as alias/orders, or an alias ARN.

use kms_aead::backends::AwsLc;
use kms_aead::providers::{AwsKmsKeyRef, AwsKmsProvider, AwsKmsProviderOptions};
use kms_aead::KmsAeadEnvelope;
use secret_vault_value::SecretValue;

async fn example() -> kms_aead::KmsAeadResult<()> {
let kms_ref = AwsKmsKeyRef::new(
    "123456789012".to_string(),
    "arn:aws:kms:eu-west-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab".to_string(),
);

let provider = AwsKmsProvider::new(&kms_ref).await?;
let envelope = KmsAeadEnvelope::<AwsKmsProvider, AwsLc>::new(provider)?;

// Or with DEKs from AWS KMS GenerateRandom
let envelope = KmsAeadEnvelope::<AwsKmsProvider, AwsLc>::new(
    AwsKmsProvider::with_options(
        &kms_ref,
        AwsKmsProviderOptions::new().with_use_kms_random_gen(true),
    )
    .await?,
)?;

let cipher_text = envelope
    .encrypt(b"orders/1042", &SecretValue::from("secret"))
    .await?;
let _ = cipher_text;
Ok(())
}

Full example available here.

Authentication and region

AwsKmsProvider::new loads the AWS configuration from the environment, as the AWS SDK does: environment variables, the shared config and credentials files, the instance or container role, etc. It sends no request, so missing credentials or an unreachable endpoint fail the first encryption or decryption.

Without aws_region in the key reference, the provider takes the region of the AWS configuration it loads. Set one with AwsKmsKeyRef::new(..).with_aws_region(region).

The principal needs kms:Encrypt and kms:Decrypt on the key, and kms:GenerateRandom when the KMS generator is on.

Key ARN and aliases

A decryption names the configured key to AWS KMS, so a DEK encrypted under another key fails with IncorrectKeyException, even when the principal may decrypt with that key.

Configure the key ARN rather than an alias: a DEK encrypted under an alias that later moves to another key fails to decrypt with IncorrectKeyException.

The provider turns the key id into an ARN with the region and the account: an alias name into an alias ARN, a key id into a key ARN, and an ARN stays as it is. That ARN always starts with arn:aws:, so outside the aws partition, such as in GovCloud or China, give the full ARN.

Errors

Every error is an EncryptionError whose code is the AWS error code, such as IncorrectKeyException or NotFoundException, and whose message is the one AWS KMS returned. Its root cause is the SDK error.

Azure Key Vault

Feature azure-kms-encryption, or one of the TLS variants in Getting started.

AzureKmsKeyRef names the key by the vault URL and the key name. Without a key version, the latest version encrypts new DEKs. Each encrypted DEK records the version that encrypted it, and the decryption uses that version, so DEKs encrypted before a rotation still decrypt afterwards.

The provider takes any Azure credential, for example one from azure_identity:

use kms_aead::backends::AwsLc;
use kms_aead::providers::{AzureKmsKeyRef, AzureKmsProvider};
use kms_aead::KmsAeadEnvelope;
use secret_vault_value::SecretValue;

async fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let kms_ref = AzureKmsKeyRef::new(
    "https://my-vault.vault.azure.net".to_string(),
    "my-key".to_string(),
);

// Azure CLI or Azure Developer CLI credentials for local runs;
// use `azure_identity::ManagedIdentityCredential` in Azure deployments
let credential = azure_identity::DeveloperToolsCredential::new(None)?;

let provider = AzureKmsProvider::new(&kms_ref, credential)?;
let envelope = KmsAeadEnvelope::<AzureKmsProvider, AwsLc>::new(provider)?;

let cipher_text = envelope
    .encrypt(b"orders/1042", &SecretValue::from("secret"))
    .await?;
let _ = cipher_text;
Ok(())
}

Full example available here.

The provider sends no request until the first encryption or decryption. The principal needs the wrapKey and unwrapKey key operations, as in the “Key Vault Crypto User” role.

Options

AzureKmsProviderOptions has:

  • algorithm: the wrap algorithm, RsaOaep256 by default for RSA keys, A256Kw for Managed HSM AES keys. RsaOaep (SHA-1) is accepted for existing keys; Rsa1_5 and unknown algorithms are refused. Encrypted DEKs do not record the algorithm, so it stays fixed for the life of the key;
  • client_options: settings passed to the Azure SDK, such as a custom HTTP transport or retry policy.

Encrypted DEKs

Key Vault decrypts only with the key version that encrypted, so the provider’s encrypted DEK carries it before Key Vault’s encrypted DEK, in the library’s own layout, see Formats. No Tink client exists for Azure, so Tink does not read the values of this provider, and the plaintext Key Vault encrypts is the raw DEK of 16, 32 or 64 bytes, as in 0.29, rather than the DEK as Tink serialises it. Every wrap algorithm takes it, A256Kw too, which takes only multiples of 8 bytes. Store the encrypted DEK as it is, see Envelope encryption.

Errors

A failed request carries Key Vault’s error code, such as Forbidden or KeyNotFound, or AZURE_STATUS_<status> when the response has none. A server error or a rate limit is a NetworkError, any other status an EncryptionError. A decryption of an encrypted DEK in another layout fails with AZURE_ENCRYPTED_DEK_FORMAT, before any request.

HashiCorp Vault Transit

Feature hashicorp-vault-transit-kms-encryption, or one root store and one crypto provider variant, see Getting started.

The provider encrypts DEKs with a key of the Vault Transit secrets engine. HcVaultTransitKeyRef names the key, and the mount is transit unless set with with_mount.

use kms_aead::backends::AwsLc;
use kms_aead::providers::{HcVaultTransitKeyRef, HcVaultTransitProvider};
use kms_aead::KmsAeadEnvelope;
use secret_vault_value::SecretValue;

async fn example() -> kms_aead::KmsAeadResult<()> {
// VAULT_ADDR, VAULT_TOKEN, VAULT_NAMESPACE, VAULT_CACERT and VAULT_CLIENT_TIMEOUT are read by
// the provider
let key_ref = HcVaultTransitKeyRef::new("orders".to_string());

let provider = HcVaultTransitProvider::new(&key_ref)?;
let envelope = KmsAeadEnvelope::<HcVaultTransitProvider, AwsLc>::new(provider)?;

let cipher_text = envelope
    .encrypt(b"orders/1042", &SecretValue::from("secret"))
    .await?;
let _ = cipher_text;
Ok(())
}

Full example available here.

Options

HcVaultTransitProvider::new reads its options from the variables the Vault CLI reads, with HcVaultTransitProviderOptions::from_env: VAULT_ADDR (required), VAULT_TOKEN, VAULT_NAMESPACE, VAULT_CACERT and VAULT_CLIENT_TIMEOUT. An empty variable counts as unset. VAULT_CLIENT_TIMEOUT takes the formats the Vault CLI takes: whole seconds (30), whole days (2d) or a Go duration (30s, 1h30m). Zero, which the Vault CLI reads as no time limit, is refused, as is a value in any other format. HcVaultTransitProvider::with_options takes them from the application:

use kms_aead::providers::{
    HcVaultTransitKeyRef, HcVaultTransitProvider, HcVaultTransitProviderOptions,
    HcVaultTransitToken,
};
use secret_vault_value::SecretValue;
use std::time::Duration;

fn example() -> kms_aead::KmsAeadResult<()> {
let options = HcVaultTransitProviderOptions::new("https://vault.example.com:8200".to_string())
    .with_token(HcVaultTransitToken::try_from(SecretValue::from("hvs.example"))?)
    .with_namespace("team-a".to_string())
    .with_timeout(Duration::from_secs(10));

let provider = HcVaultTransitProvider::with_options(
    &HcVaultTransitKeyRef::new("orders".to_string()).with_mount("transit-eu".to_string()),
    options,
)?;
let _ = provider;
Ok(())
}

The options are:

  • address: the Vault server, such as https://vault.example.com:8200;
  • token: sent with every request. Without one the requests carry no token, which is what a Vault Agent or Vault Proxy with use_auto_auth_token expects;
  • namespace: the Vault Enterprise namespace of the mount;
  • ca_certificate: a PEM file with CA certificates for a server whose certificate comes from a private CA. The file adds to the root store and does not replace it, unlike VAULT_CACERT in the Vault CLI, so a server with a certificate from any public CA in the root store is trusted as well;
  • timeout: the time limit for one request, from opening the connection to the last byte of the response, 60 seconds by default.

The provider sends no request when it is created, so a wrong token or an unreachable server fail the first encryption or decryption. An http address to a host other than localhost or a loopback address logs a WARN event at creation, since the token and the DEKs then cross the network unencrypted.

The token needs update on <mount>/encrypt/<key> and <mount>/decrypt/<key>.

Key versions and rotation

The encrypted DEK is the Transit ciphertext, such as vault:v1:.... It names the key version, so it still decrypts after the key is rotated. The KMS spans record that version as key_version.

The decrypt names the configured key in its URL, so Vault refuses a ciphertext of another key.

Errors

A response with an error status is VAULT_STATUS_<status>: a NetworkError for a server error or a rate limit, an EncryptionError otherwise. VAULT_HTTP and VAULT_TIMEOUT are network errors for a request that got no complete response, and VAULT_RESPONSE, VAULT_RESPONSE_TOO_LARGE and VAULT_CIPHERTEXT are encryption errors for a response or a encrypted DEK that cannot be read. Invalid options fail at creation with VAULT_INVALID_OPTIONS, or VAULT_TLS when the TLS configuration cannot be built.

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.

Envelope encryption

KmsAeadEnvelope seals values under data encryption keys (DEKs) on the crypto backend it is built with, and encrypts the DEKs with the KEK of its provider. A call that makes no KMS call is not async, and every call opens one DEBUG span, see Observability.

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

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

// Or another algorithm
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::with_options(
    provider,
    KmsAeadEnvelopeOptions::new().with_algorithm(AeadAlgorithm::ChaCha20Poly1305),
)?;
let _ = envelope;
Ok(())
}

The value does not record the algorithm, so the writer and the reader of a value must be configured with the same one. The backend can differ, see Crypto backends. The default, AES-256-GCM, is read by every Tink implementation too, see Formats. Values that 0.29 and earlier sealed under their default open only with ChaCha20-Poly1305 configured, see Migrating from 0.29.

Encrypting values

encrypt seals a value under a new DEK, encrypts the DEK with one KMS call, and returns both in one Encrypted<T, Envelope>, where T is the type of the value. decrypt decrypts the DEK with one KMS call, opens the value and returns a T. A value with no type of its own is a SecretValue:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, 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("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;

let aad = b"orders/1042";
let encrypted: Encrypted<SecretValue, Envelope> = envelope
    .encrypt(aad, &SecretValue::from("4111 1111 1111 1111"))
    .await?;

// The bytes to store, and the value read back from them
let stored: Vec<u8> = encrypted.into_value();
let read_back = Encrypted::<SecretValue, Envelope>::try_from(stored)?;

let decrypted: SecretValue = envelope.decrypt(aad, &read_back).await?;
assert_eq!(decrypted, SecretValue::from("4111 1111 1111 1111"));
Ok(())
}

value() and into_value() return the bytes to store, and to_hex_string() returns them as lowercase hex. Stored bytes become an Encrypted again only through Encrypted::try_from, and stored hex through Encrypted::try_from_hex_string, which check them against the layout of the format.

The value is in Tink’s KMS envelope format for every provider, so Tink’s KmsEnvelopeAead reads it with the same KMS key and algorithm, see Formats.

A new DEK comes from the backend’s CSPRNG, or from the KMS generator when the provider is configured to use one, see KMS random generators.

Typed fields

encrypt and decrypt take a value of any type that implements Plaintext, so a field can have a type of its own. The value is encoded by its Plaintext implementation, and the result is an Encrypted<T, Envelope> that names the type:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, KmsAeadEnvelope, KmsAeadResult, Plaintext};
use secret_vault_value::SecretValue;

#[derive(Debug, Clone, PartialEq)]
struct Email(String);

// Sealed as the same UTF-8 bytes as a String
impl Plaintext for Email {
    fn to_plaintext(&self) -> KmsAeadResult<SecretValue> {
        self.0.to_plaintext()
    }

    fn from_plaintext(plaintext: SecretValue) -> KmsAeadResult<Self> {
        String::from_plaintext(plaintext).map(Email)
    }
}

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

let aad = b"users/1042/email";
let email = Email("alice@example.com".to_string());
let encrypted: Encrypted<Email, Envelope> = envelope.encrypt(aad, &email).await?;

// The bytes to store, and the field read back from them
let stored: Vec<u8> = encrypted.into_value();
let read_back = Encrypted::<Email, Envelope>::try_from(stored)?;

let decrypted: Email = envelope.decrypt(aad, &read_back).await?;
assert_eq!(decrypted, email);
Ok(())
}
  • Plaintext is implemented for String, Vec<u8>, SecretValue, bool, i32, i64, u32 and u64, and with the serde-json feature for Json<T>, any serde type stored as its compact JSON. An application type maps to one of them, as Email does;
  • the calls run in the envelope’s mode, a DEK per value or store-less DEK reuse, and decrypt through the envelope’s DEK cache, whatever the type;
  • Encrypted::try_from checks the stored bytes against Tink’s KMS envelope layout, so a truncated or foreign value fails when it is read, before any KMS call, with UNKNOWN_CIPHER_TEXT_LAYOUT, INVALID_CIPHER_TEXT_FORMAT or, without legacy-formats, LEGACY_FORMAT. It checks the framing only: a changed value fails to decrypt with DECRYPT;
  • decrypt fails with INVALID_PLAINTEXT when the plaintext is not an encoding of T.

Encrypted<T, Envelope> has no ==: two values of the same plaintext differ, so compare value() to tell whether two values are the same stored bytes. Debug shows the length only. See Formats for its layout.

Storing typed fields

Three features store Encrypted as bytes, and each one reads it back through TryFrom, so a malformed stored value fails in the decode:

  • serde: Serialize and Deserialize, a bytes value in Firestore and an array of numbers in serde_json;
  • postgres: ToSql and FromSql of postgres-types, for tokio-postgres, as BYTEA, and a slice as BYTEA[] for = ANY($1);
  • sqlx: sqlx’s Type, Encode, Decode and PgHasArrayType for PostgreSQL, as BYTEA. It depends on sqlx-core and sqlx-postgres only, so the application chooses sqlx’s runtime and TLS stack.

With sqlx:

use kms_aead::formats::Envelope;
use kms_aead::Encrypted;
use sqlx::PgPool;

#[derive(Clone)]
struct Email(String);
async fn store(pool: &PgPool, id: i64, email: &Encrypted<Email, Envelope>) -> sqlx::Result<()> {
    sqlx::query("INSERT INTO users (id, email) VALUES ($1, $2)")
        .bind(id)
        .bind(email)
        .execute(pool)
        .await?;
    Ok(())
}

async fn load(pool: &PgPool, id: i64) -> sqlx::Result<Encrypted<Email, Envelope>> {
    sqlx::query_scalar("SELECT email FROM users WHERE id = $1")
        .bind(id)
        .fetch_one(pool)
        .await
}

One fixed key

AeadCipher::encrypt seals a field under a key the application holds, in Tink’s layout of a key whose output prefix is TINK, [0x01][key id: u32 BE] before the AEAD value: an Encrypted<T, Randomized>. DeterministicCipher::encrypt does the same for an Encrypted<T, Deterministic>. The key id is a KeyId, the application’s constant, and Tink and BigQuery read the values with a keyset of the one key under that id:

use kms_aead::backends::Ring;
use kms_aead::cache::{DekCache, DekCacheOptions};
use kms_aead::formats::{Randomized, TinkKeyType, TinkKeyset};
use kms_aead::providers::{GcpKmsKeyRef, GcpKmsProvider};
use kms_aead::{
    AeadAlgorithm, AeadCipher, DataEncryptionKey, Encrypted, EncryptedDataEncryptionKey, KeyId,
    KmsAeadEncryptionProvider, KmsAeadResult, Plaintext,
};
use secret_vault_value::SecretValue;

#[derive(Debug, Clone, PartialEq)]
struct Email(String);

impl Plaintext for Email {
    fn to_plaintext(&self) -> KmsAeadResult<SecretValue> {
        self.0.to_plaintext()
    }

    fn from_plaintext(plaintext: SecretValue) -> KmsAeadResult<Self> {
        String::from_plaintext(plaintext).map(Email)
    }
}

// The id of the one key, in every value and in the keyset
const EMAIL_KEY: KeyId = KeyId::new(1);

async fn example() -> KmsAeadResult<()> {
let key_ref = GcpKmsKeyRef::new(
    "my-project".to_string(),
    "europe-north1".to_string(),
    "my-key-ring".to_string(),
    "my-key".to_string(),
);
let provider = GcpKmsProvider::new(&key_ref).await?;
let cipher = AeadCipher::<Ring>::new(AeadAlgorithm::Aes256Gcm)?;
let cache = DekCache::new(DekCacheOptions::new())?;

// Once, by an administrative job: the key encrypted by Cloud KMS, stored in the settings
let (_, encrypted_dek): (DataEncryptionKey, EncryptedDataEncryptionKey) =
    cipher.generate_encrypted_dek(&provider).await?;

// On every write and read: the key decrypted through the cache, no KMS call while it is cached
let email = Email("alice@example.com".to_string());
let (encrypted, _) = cache
    .with_dek(&provider, &encrypted_dek, |dek| {
        cipher.encrypt(EMAIL_KEY, b"users/1042/email", &email, dek)
    })
    .await?;
let encrypted: Encrypted<Email, Randomized> = encrypted?;

let (decrypted, _) = cache
    .with_dek(&provider, &encrypted_dek, |dek| {
        cipher.decrypt(b"users/1042/email", &encrypted, dek)
    })
    .await?;
let decrypted: Email = decrypted?;
assert_eq!(decrypted, email);

// Once, for BigQuery: the keyset of the one key, encrypted by the same Cloud KMS key
let mut keyset = TinkKeyset::new(EMAIL_KEY);
let (added, _) = cache
    .with_dek(&provider, &encrypted_dek, |dek| {
        keyset.add_key(EMAIL_KEY, TinkKeyType::from(cipher.algorithm()), dek)
    })
    .await?;
added?;
let first_level_keyset: EncryptedDataEncryptionKey = provider
    .encrypt_data_encryption_key(&DataEncryptionKey::from(keyset.to_bytes()))
    .await?;
let _ = first_level_keyset;
Ok(())
}

BigQuery reads the column with KEYS.KEYSET_CHAIN over the encrypted keyset, passed as a BYTES query parameter:

SELECT AEAD.DECRYPT_STRING(
  KEYS.KEYSET_CHAIN(
    'gcp-kms://projects/my-project/locations/europe-north1/keyRings/my-key-ring/cryptoKeys/my-key',
    @first_level_keyset),
  u.email,
  CONCAT('users/', CAST(u.id AS STRING), '/email'))
FROM users u;
  • BigQuery reads AES-256-GCM values with AEAD.DECRYPT_STRING and AEAD.DECRYPT_BYTES, and the AES-SIV values of DeterministicCipher with DETERMINISTIC_DECRYPT_STRING and DETERMINISTIC_DECRYPT_BYTES. KEYS.KEYSET_CHAIN decrypts with Cloud KMS only;
  • AeadCipher::decrypt reads the values Tink and BigQuery write under the same key and id. The caller picks the key by key_id(), which the prefix names without authenticating it;
  • a rotation needs a second key id, values of both ids in one column and a keyset of both keys. kms-aead-keyring keeps those versions.

Beacons

A beacon, or blind index, finds rows by a field’s value without decrypting the column: an HMAC-SHA256 of the value under a beacon key, truncated, stored beside the encrypted field. A query calculates the beacon of the value it looks for and matches it by equality. Beacon<T> holds the bytes to store, and the beacon key is a 32-byte key encrypted by the KMS, as a DEK is:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{
    Beacon, BeaconAlgorithm, BeaconBits, BeaconMac, Encrypted, KeyId, KmsAeadEnvelope,
    KmsAeadResult, Plaintext,
};
use secret_vault_value::SecretValue;

#[derive(Debug, Clone, PartialEq)]
struct Email(String);

// The application's id of its beacon key, which every beacon names
const EMAIL_BEACON_KEY: KeyId = KeyId::new(1);

impl Email {
    // The beacon of an email matches regardless of case
    fn new(email: &str) -> Self {
        Email(email.to_lowercase())
    }
}

impl Plaintext for Email {
    fn to_plaintext(&self) -> KmsAeadResult<SecretValue> {
        self.0.to_plaintext()
    }

    fn from_plaintext(plaintext: SecretValue) -> KmsAeadResult<Self> {
        String::from_plaintext(plaintext).map(Email)
    }
}

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

// Once, by an administrative job: the beacon key, stored beside the application's settings
let (_, encrypted_dek) = BeaconMac::<Ring>::new(BeaconAlgorithm::HmacSha256)
    .generate_encrypted_dek(envelope.provider())
    .await?;

// On every write: the field and its beacon
let email = Email::new("Alice@Example.com");
let encrypted: Encrypted<Email, Envelope> =
    envelope.encrypt(b"users/1042/email", &email).await?;
let beacon: Beacon<Email> = envelope
    .beacon(EMAIL_BEACON_KEY, b"users/email", BeaconBits::DEFAULT, &email, &encrypted_dek)
    .await?;

// A lookup: the beacon of the value looked for, then a decryption of each matching row
let probe: Beacon<Email> = envelope
    .beacon(
        EMAIL_BEACON_KEY,
        b"users/email",
        BeaconBits::DEFAULT,
        &Email::new("alice@example.com"),
        &encrypted_dek,
    )
    .await?;
assert_eq!(probe, beacon);
let decrypted: Email = envelope.decrypt(b"users/1042/email", &encrypted).await?;
assert_eq!(decrypted, email);
Ok(())
}
  • the context, users/email here, names the field and never a row, so that equal values in one field give equal beacons. A beacon of one field never matches a beacon of another;
  • BeaconBits sets how much of the HMAC a beacon keeps, 1 to 256 bits, 16 by default. With n distinct values and b bits, about n / 2^b values share each beacon: a lookup returns that many rows, which the application decrypts and filters, and an equal beacon never proves an equal value. More bits leak more; fewer bits return more rows to filter;
  • normalisation is the application’s: the beacon of Alice@Example.com and of alice@example.com differ unless the type normalises the value before it is sealed, as Email::new does;
  • the key id, a KeyId, is the application’s number for the beacon key, written into every beacon so that a column can hold beacons of two keys at once;
  • the beacon key is decrypted through the envelope’s DEK cache, as a DEK is.

A beacon column leaks which rows are likely to hold equal values, and with them how often each value occurs, to anyone who reads the column. A leaked beacon key lets its holder test guesses against the whole column offline. Beacons fit fields that are looked up and have many distinct values, such as emails and external ids, and never low-cardinality fields such as a status or a country. BigQuery’s SQL has no HMAC-SHA256 with a KMS-encrypted DEK, so a query in BigQuery cannot calculate a beacon; it matches beacons the application calculated.

Rotating a beacon key in store-less mode is manual. Generate the new key and keep both, write beacons under the new one, query with a beacon under each key until a re-index has rewritten every stored beacon, then drop the old key. Beacon::key_id tells which key wrote a stored beacon. kms-aead-keyring does this with key versions.

Beacon<T> is stored as Encrypted is, with the serde, postgres and sqlx features, and read back through TryFrom, which checks its layout. See Formats.

Beacon or deterministic field

A deterministic field also finds rows by equality, by its cipher text:

BeaconDeterministic field
Stored valueRandomised, so its AAD can name the row, and a value copied into another row fails to openEqual for equal values, so its AAD names the field and never the row, and a value copied into another row opens there
MatchThe truncated HMAC, with deliberate collisionsAlways the exact value
KeyA beacon key, rotated separately from the data keysThe field’s own key
CostAn extra column, and a decryption and filter of the candidates on every lookupNothing beyond the field
BigQuerySQL cannot calculate a beacon; it matches beacons the application calculatedSQL decrypts the field and searches it by equality

With the default 16 bits, there are 65,536 possible beacons. A column of 1,000,000 distinct emails gives about 1,000,000 / 65,536, or 15, emails per beacon, so a lookup of one email returns about 15 rows. The application decrypts them and keeps the one equal to the email it looks for.

AAD

The AAD is authenticated but not encrypted: a value opens only with the AAD it was sealed with, and a wrong AAD fails with DECRYPT. Use it to bind a value to its place, such as <table>/<row id>/<column>, so that a value copied into another row or column fails to open.

The AAD is not stored in the value, so the reader rebuilds it from the same data. It is never logged or put in an error, but it is not secret either: keep secrets in the value itself.

Working with DEKs yourself

The *_with_dek and *_with_encrypted_dek calls let the application keep one DEK for many values and store the encrypted DEK apart from them, such as one DEK per tenant or per file. The values are Encrypted<T, Bare>, with no encrypted DEK in them.

use kms_aead::backends::Ring;
use kms_aead::formats::Bare;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, 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("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;

// One KMS call: a new DEK and the same DEK encrypted
let (dek, encrypted_dek) = envelope.generate_encrypted_dek().await?;

// No KMS call: seal and open with the plaintext DEK
let first: Encrypted<SecretValue, Bare> =
    envelope.encrypt_with_dek(b"files/1/chunk/0", &SecretValue::from("first"), &dek)?;
let second: Encrypted<SecretValue, Bare> =
    envelope.encrypt_with_dek(b"files/1/chunk/1", &SecretValue::from("second"), &dek)?;
let decrypted: SecretValue = envelope.decrypt_with_dek(b"files/1/chunk/0", &first, &dek)?;
assert_eq!(decrypted, SecretValue::from("first"));

// Later, with only the encrypted DEK: the envelope decrypts it with the KMS
let decrypted: SecretValue = envelope
    .decrypt_with_encrypted_dek(b"files/1/chunk/1", &second, &encrypted_dek)
    .await?;
assert_eq!(decrypted, SecretValue::from("second"));
Ok(())
}

The calls are:

  • generate_encrypted_dek: a new DEK and the same DEK encrypted, one KMS call;
  • encrypt_with_dek and decrypt_with_dek: with a plaintext DEK, no KMS call;
  • encrypt_with_encrypted_dek and decrypt_with_encrypted_dek: with an encrypted DEK, decrypted through the envelope’s DEK cache, so one KMS call per call without a cache.

Be aware not to seal more than about 2^32 values under one DEK: every value takes a random 96-bit nonce, see Security considerations.

Storing encrypted DEKs

EncryptedDataEncryptionKey holds the bytes the provider returned, which also name the KMS key version where the KMS needs one to decrypt after a rotation. Store value() or to_hex_string(), and read them back with EncryptedDataEncryptionKey::from or try_from_hex_string():

use kms_aead::backends::Ring;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{EncryptedDataEncryptionKey, KmsAeadEnvelope, ValueStruct};

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

let stored: Vec<u8> = encrypted_dek.value().clone();
let read_back_encrypted_dek = EncryptedDataEncryptionKey::from(stored);

let stored_hex: String = encrypted_dek.to_hex_string();
let read_back_hex_encrypted_dek = EncryptedDataEncryptionKey::try_from_hex_string(&stored_hex)?;

assert_eq!(read_back_encrypted_dek, read_back_hex_encrypted_dek);
Ok(())
}

The encrypted DEK is the one Tink stores in its KMS envelope, so Encrypted::join of a bare value and its encrypted DEK is an Encrypted<T, Envelope> that Tink reads. See Formats for each provider’s bytes.

DEK caching and reuse

Without a cache, every read of a value decrypts its DEK with one KMS call, and every encrypt encrypts a new DEK with another. The library has two ways to make fewer KMS calls:

  • DekCache, a cache of decrypted DEKs, which serves repeated reads of values under the same DEK without the KMS;
  • store-less DEK reuse, with a DekReusePolicy, where one DEK seals many values, so a write calls the KMS only when the DEK is renewed.

DEK cache

kms_aead::cache::DekCache holds plaintext DEKs a provider decrypted, keyed by the provider’s KEK and the encrypted DEK’s bytes. A per-call envelope has no cache unless the options give it one:

use kms_aead::backends::Ring;
use kms_aead::cache::{DekCache, DekCacheOptions};
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{KmsAeadEnvelope, KmsAeadEnvelopeOptions};
use std::num::NonZeroUsize;
use std::time::Duration;

fn main() -> kms_aead::KmsAeadResult<()> {
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
let cache = DekCache::new(
    DekCacheOptions::new()
        .with_ttl(Duration::from_secs(5 * 60))
        .with_max_entries(NonZeroUsize::new(10_000).expect("not zero")),
)?;

let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::with_options(
    provider,
    KmsAeadEnvelopeOptions::new().with_dek_cache(cache.clone()),
)?;
let _ = envelope;
Ok(())
}

decrypt, the *_with_encrypted_dek calls and a store-less encrypt decrypt through the envelope’s cache. Clone shares the same cache, so one cache may serve several envelopes and several KEKs: an entry serves only a lookup that names the same KEK, and a key decrypted under one KEK never reaches a caller with the provider of another.

The cache:

  • serves an entry for its TTL after it was inserted, however often it is used, 15 minutes by default;
  • holds at most max_entries entries, 1024 by default, and evicts the oldest entry past it;
  • makes one provider call for concurrent misses of the same key, and every caller receives its result, the key or the error;
  • keeps no entry for a failed decryption, so during a KMS outage every call fails as fast as the KMS does;
  • zeroises a key when its entry leaves the cache: once it is evicted, invalidated, cleared, or purged after it expired.

DekCache::disabled() holds nothing, and every lookup calls the provider. It is what a per-call envelope has by default.

Sizing

Size max_entries above the number of distinct DEKs read per TTL. With per-call envelopes every value has its own DEK, so a cache helps only values read more than once within the TTL. With store-less writers the number of DEKs is the number of writers times the renewals per TTL, which is small.

A longer TTL saves more KMS calls, and keeps DEKs in memory for longer, see Key lifetime.

Invalidation

A disabled KMS key or a revoked IAM grant reaches the cached keys only when their entries expire. To drop one at once, invalidate it with the envelope’s own KEK:

use kms_aead::backends::Ring;
use kms_aead::cache::{DekCache, DekCacheOptions};
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{KmsAeadEnvelope, KmsAeadEnvelopeOptions};
use kms_aead::KmsAeadEncryptionProvider;

#[tokio::main(flavor = "current_thread")]
async fn main() -> kms_aead::KmsAeadResult<()> {
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::with_options(provider,
    KmsAeadEnvelopeOptions::new().with_dek_cache(DekCache::new(DekCacheOptions::new())?))?;
let (_dek, encrypted_dek) = envelope.generate_encrypted_dek().await?;

// One encrypted DEK
envelope
    .dek_cache()
    .invalidate(envelope.provider().kek(), &encrypted_dek);

// Every DEK
envelope.dek_cache().clear();
Ok(())
}

Pass envelope.provider().kek() to invalidate, with the KmsAeadEncryptionProvider trait in scope: a KekRef built by hand that differs from it matches no entry.

Key lifetime

The cache runs no thread and no task of its own. Expired entries are dropped at the next call on the cache, at purge_expired, or when the last clone of the cache is dropped. With no calls, the bytes of an expired key stay in memory. An application whose process can sit idle while the cache holds keys, and that needs the TTL to bound their lifetime in memory, calls purge_expired on a timer of its own:

use kms_aead::cache::{DekCache, DekCacheOptions};
fn example() -> kms_aead::KmsAeadResult<()> {
let cache = DekCache::new(DekCacheOptions::new())?;

let purged = cache.clone();
tokio::spawn(async move {
    let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
    loop {
        interval.tick().await;
        purged.purge_expired();
    }
});
Ok(())
}

A key reaches the caller only inside the closure of DekCache::with_dek, and stays intact until the closure returns, whatever leaves the cache meanwhile. The closure must not keep a copy of the key: a copy taken out of it outlives the cache’s control of its lifetime.

Store-less DEK reuse

A KmsAeadEnvelope with a DekReusePolicy encrypts many values under one DEK and renews it at the first encryption past max_age, past max_uses or past max_bytes, whichever comes first. A renewal makes one KMS encrypt call. Each value still carries its encrypted DEK in Tink’s KMS envelope, so nothing is stored apart from the values, and values under earlier DEKs stay readable.

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{DekReusePolicy, Encrypted, KmsAeadEnvelope, KmsAeadEnvelopeOptions};
use secret_vault_value::SecretValue;
use std::num::NonZeroU64;
use std::time::Duration;

#[tokio::main(flavor = "current_thread")]
async fn main() -> kms_aead::KmsAeadResult<()> {
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::with_options(
    provider,
    KmsAeadEnvelopeOptions::new().with_dek_reuse(
        DekReusePolicy::new()
            .with_max_age(Duration::from_secs(60 * 60))
            .with_max_uses(NonZeroU64::new(1_000_000).expect("not zero")),
    ),
)?;

// One KMS encryption for both values
let first: Encrypted<SecretValue, Envelope> =
    envelope.encrypt(b"events/1", &SecretValue::from("first")).await?;
let second: Encrypted<SecretValue, Envelope> =
    envelope.encrypt(b"events/2", &SecretValue::from("second")).await?;

// Read with any envelope over the same KEK and algorithm
let decrypted_first: SecretValue = envelope.decrypt(b"events/1", &first).await?;
let decrypted_second: SecretValue = envelope.decrypt(b"events/2", &second).await?;
assert_eq!(decrypted_first, SecretValue::from("first"));
assert_eq!(decrypted_second, SecretValue::from("second"));
Ok(())
}

The defaults are a max_age of 24 hours and a max_uses of 2^24. max_age must be longer than zero and max_uses at most 2^32, the bound for random 96-bit nonces under one key, otherwise the envelope fails to build with INVALID_DEK_REUSE_POLICY.

Volume limit

max_bytes counts the plaintext bytes sealed under the DEK. Its default depends on the algorithm:

AlgorithmDefault max_bytesMost allowed
AES-128-GCM, AES-256-GCM2^38 bytes (256 GiB)2^52 bytes
AES-256-GCM-SIVno limit beyond max_usesany
ChaCha20-Poly1305no limit beyond max_usesany

The figures come from these bounds:

  • AES-GCM: RFC 8446, section 5.5, allows “up to 2^24.5 full-size records (about 24 million)” of 2^14 bytes, 2^38.5 bytes, “while keeping a safety margin of approximately 2^-57 for Authenticated Encryption (AE) security”. Luykx and Paterson, “Limits on Authenticated Encryption Use in TLS”, table 1, give the same 2^24.5 records for a confidentiality advantage of 2^-60. The default of 2^38 bytes stays below that. The most allowed, 2^52 bytes, is the 2^48 blocks RFC 8452, section 9, allows under one AES-GCM key for an advantage of 2^-32;
  • AES-GCM-SIV: RFC 8452, section 9, allows 2^32 values of up to 8 GiB each under one key with random nonces for an advantage of 2^-32, so max_uses bounds it already;
  • ChaCha20-Poly1305: draft-irtf-cfrg-aead-limits, section 6.3, gives it no confidentiality limit beyond the security of ChaCha20 itself, and its integrity bound depends on the number of forgery attempts, which no writer can count.

A max_bytes set in the policy replaces the default, under any algorithm. Above the most allowed, the envelope fails to build with INVALID_DEK_REUSE_POLICY.

A value that would take the count past max_bytes renews the DEK before it is sealed. A value longer than max_bytes alone is sealed under a new DEK of its own, as the per-call envelope does, and the current DEK stays in use for the next values.

The bounds count blocks of plaintext and AAD, and one more block per value, and max_bytes counts plaintext bytes only. At the default max_uses and an AAD of 16 bytes or less, the difference is at most 3 × 2^24 blocks beside the 2^34 blocks of the default.

The DEK lives in the cache

The envelope keeps only the encrypted DEK. Its plaintext lives in the envelope’s DekCache, which a store-less envelope always has: DekCache::new(DekCacheOptions::new()) unless the options give it another, and INVALID_DEK_REUSE_POLICY beside DekCache::disabled().

The first encryption after the DEK has left the cache renews it. So the DEK is renewed:

  • at the cache’s TTL, whatever max_age;
  • when the cache evicts it for capacity;
  • after invalidate with envelope.provider().kek(), or after clear.

In a cache shared with readers, size max_entries above the number of distinct DEKs read per TTL, or give the writer a cache of its own, otherwise reads evict the writer’s DEK and it renews more often than the policy says.

A writer that falls quiet leaves its DEK in memory until the cache’s next call or purge_expired, see Key lifetime. The values sealed while the cache holds the DEK are readable by whoever reads the process’s memory in that window.

Crypto backends

The AEAD algorithms run on a crypto library that a feature compiles in. Each backend feature adds a type to kms_aead::backends, and AeadCipher, DeterministicCipher, KmsAeadEnvelope and LocalKmsProvider take that type as their backend type parameter B. A build may enable any number of backends, and there is no default one.

FeatureTypeLibrary
ringRingring
aws-lcAwsLcaws-lc-rs
aws-lc-fipsAwsLc, on the FIPS moduleaws-lc-rs
rustcryptoRustCryptothe RustCrypto AEADs

There is no default because Cargo unifies features: a default chosen by features would change for every crate in a build when one crate enables another backend.

Code generic over the backend

The backend types implement AeadBackend, a sealed trait in kms_aead::backends. Its items are the constant NAME, the backend’s name in spans and logs: ring, aws-lc or rustcrypto, and generate_random_bytes. Code that works with any backend bounds its type parameter with AeadBackend and nothing else:

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

struct Orders<B> {
    envelope: KmsAeadEnvelope<LocalKmsProvider<B>, B>,
}

impl<B: AeadBackend> Orders<B> {
    fn new() -> KmsAeadResult<Self> {
        let provider = LocalKmsProvider::ephemeral(LocalKekId::try_from("orders".to_string())?)?;
        Ok(Self {
            envelope: KmsAeadEnvelope::new(provider)?,
        })
    }

    async fn round_trip(&self, card: &SecretValue) -> KmsAeadResult<SecretValue> {
        let sealed = self.envelope.encrypt(b"orders/1042", card).await?;
        self.envelope.decrypt(b"orders/1042", &sealed).await
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> KmsAeadResult<()> {
let orders = Orders::<Ring>::new()?;
let card = SecretValue::from("4111 1111 1111 1111");
assert_eq!(orders.round_trip(&card).await?, card);
Ok(())
}

B::generate_random_bytes(len) returns len bytes from the CSPRNG of the backend B, for secrets the application generates itself.

Algorithms

AlgorithmKeyRingAwsLcRustCrypto
ChaCha20Poly1305 (RFC 8439)32 bytesyesyesyes
Aes128Gcm16 bytesyesyesyes
Aes256Gcm, the envelope’s default32 bytesyesyesyes
Aes256GcmSiv (RFC 8452)32 bytesnoyesyes

Every algorithm writes [nonce: 12][ciphertext][tag: 16], with a random nonce from the backend’s CSPRNG. A backend that does not implement the algorithm fails at construction, never later, with KmsAeadError::UnsupportedAlgorithm and the code UNSUPPORTED_ALGORITHM:

use kms_aead::backends::Ring;
use kms_aead::{errors::KmsAeadError, AeadAlgorithm, AeadCipher};

let result = AeadCipher::<Ring>::new(AeadAlgorithm::Aes256GcmSiv);
assert!(matches!(result, Err(KmsAeadError::UnsupportedAlgorithm(_))));

AeadAlgorithm reads and writes its names with FromStr and Display: chacha20-poly1305, aes-128-gcm, aes-256-gcm and aes-256-gcm-siv.

AEAD without a KMS

AeadCipher seals and opens values with a key the application holds. encrypt_bare writes the AEAD output alone, an Encrypted<T, Bare>:

use kms_aead::backends::Ring;
use kms_aead::formats::Bare;
use kms_aead::{AeadAlgorithm, AeadCipher, Encrypted};
use secret_vault_value::SecretValue;

fn main() -> kms_aead::KmsAeadResult<()> {
let cipher = AeadCipher::<Ring>::new(AeadAlgorithm::ChaCha20Poly1305)?;
let dek = cipher.generate_dek()?;

let aad = b"orders/1042";
let cipher_text: Encrypted<SecretValue, Bare> =
    cipher.encrypt_bare(aad, &SecretValue::from("secret"), &dek)?;
let decrypted: SecretValue = cipher.decrypt_bare(aad, &cipher_text, &dek)?;

assert_eq!(decrypted, SecretValue::from("secret"));
Ok(())
}

Full example available here.

encrypt writes an Encrypted<T, Randomized>: the same output after Tink’s TINK prefix, which names the key by a KeyId, see One fixed key.

generate_dek makes a DEK without a KMS. generate_encrypted_dek makes a DEK and encrypts it with a provider in one call, for an application that stores the encrypted DEK itself. The other calls of AeadCipher make no KMS call and open no span.

Cross-backend compatibility

Every backend writes the same bytes for the same algorithm, so a value sealed by one backend opens with any other that implements its algorithm. A writer on Ring and a reader on AwsLc work together, and moving an application from one backend to another needs no migration of its data:

use kms_aead::backends::{Ring, RustCrypto};
use kms_aead::formats::Bare;
use kms_aead::{AeadAlgorithm, AeadCipher, Encrypted};
use secret_vault_value::SecretValue;

fn main() -> kms_aead::KmsAeadResult<()> {
let writer = AeadCipher::<Ring>::new(AeadAlgorithm::Aes256Gcm)?;
let reader = AeadCipher::<RustCrypto>::new(AeadAlgorithm::Aes256Gcm)?;

let dek = writer.generate_dek()?;
let cipher_text: Encrypted<SecretValue, Bare> =
    writer.encrypt_bare(b"aad", &SecretValue::from("secret"), &dek)?;

let decrypted: SecretValue = reader.decrypt_bare(b"aad", &cipher_text, &dek)?;
assert_eq!(decrypted, SecretValue::from("secret"));
Ok(())
}

ring

ChaCha20-Poly1305, AES-128-GCM and AES-256-GCM. It has no AES-256-GCM-SIV. ring does not zeroise its expanded key schedules, so they stay in freed memory after each call.

aws-lc-rs and FIPS

aws-lc builds aws-lc-rs with every algorithm. It is the backend that adds no second crypto library to a build with a cloud provider, since the providers already build aws-lc-rs for TLS.

aws-lc-fips runs the same backend on the AWS-LC FIPS module, and builds only the FIPS module, so it does not need aws-lc. It needs CMake and Go to build. Be aware it moves every user of aws-lc-rs in the build to the FIPS module, the rustls aws-lc-rs provider included. A build with both features compiles both C libraries, and aws-lc-rs runs on the FIPS one. AES-256-GCM is the algorithm for FIPS deployments. AES-SIV, the deterministic algorithm, is not FIPS-approved, see Deterministic encryption.

aws-lc-rs copies the key into an AEAD context that AWS-LC allocates with its own allocator, which overwrites every allocation with zeros before freeing it. The expanded key schedule does not stay in freed memory after a call, as long as the application leaves AWS-LC’s allocator in place. The record keys AES-256-GCM-SIV derives for each call live on the stack, and AWS-LC does not clear them.

RustCrypto

Every algorithm, in pure Rust, with getrandom as the CSPRNG. It needs no C compiler. The feature turns on the crates’ zeroize features, so their key objects, and the Poly1305 and POLYVAL keys each call derives, are zeroised when dropped.

Deterministic encryption

Feature deterministic.

Randomised encryption gives a different cipher text every time, even for the same value. A deterministic algorithm gives equal cipher texts for equal values under one key and one AAD, so a sealed column can be compared, joined or looked up by equality without opening it.

The library provides AES-SIV (RFC 5297) with a 64-byte key, DeterministicAlgorithm::Aes256Siv. Its name for FromStr and Display is aes-256-siv. DeterministicCipher takes a backend, as AeadCipher does, and draws its keys from the backend’s CSPRNG. Every backend seals and opens with the RustCrypto aes-siv crate, so a value sealed under one backend opens under any other.

AES-SIV is not a FIPS-approved mode. A FIPS deployment keeps deterministic fields outside its validated boundary, also on aws-lc-fips, where the keys come from the FIPS module’s DRBG.

What it leaks

Be aware what equal cipher texts tell a reader of the stored data:

  • Equality: which rows hold the same value;
  • Frequency: how often each value occurs. On a column with few distinct values, such as a country, a status or a yes/no answer, the most frequent cipher text is the most frequent value, and the values can be read back from public statistics.

So do not use deterministic encryption:

  • for a column with few distinct values or a skewed distribution;
  • when equality itself is sensitive;
  • when the value is never looked up by equality: randomised encryption leaks nothing of the kind.

The AAD narrows what leaks: values are equal only under the same AAD, so a per-tenant AAD keeps equal values of two tenants apart.

A beacon finds rows by equality as well and keeps the value randomised, see Beacon or deterministic field.

Usage

DeterministicCipher seals and opens with a DataEncryptionKey of DeterministicAlgorithm::key_len() bytes, 64 for AES-SIV, and refuses a key of another length with INVALID_KEY_LENGTH, such as the 16 or 32 bytes of a randomised algorithm’s key. The application keeps track of which key serves which algorithm, as kms-aead-keyring does with its key ids.

use kms_aead::backends::Ring;
use kms_aead::formats::Deterministic;
use kms_aead::{DeterministicAlgorithm, DeterministicCipher, Encrypted, KeyId};
use secret_vault_value::SecretValue;

fn main() -> kms_aead::KmsAeadResult<()> {
let cipher = DeterministicCipher::<Ring>::new(DeterministicAlgorithm::Aes256Siv)?;
let dek = cipher.generate_dek()?;

let aad = b"customers/email";
let first: Encrypted<SecretValue, Deterministic> =
    cipher.encrypt(KeyId::new(1), aad, &SecretValue::from("user@example.com"), &dek)?;
let second: Encrypted<SecretValue, Deterministic> =
    cipher.encrypt(KeyId::new(1), aad, &SecretValue::from("user@example.com"), &dek)?;

// Equal values give equal cipher texts, to look up by
assert_eq!(first, second);
let decrypted: SecretValue = cipher.decrypt(aad, &first, &dek)?;
assert_eq!(decrypted, SecretValue::from("user@example.com"));
Ok(())
}

An Encrypted<T, Deterministic> is [0x01][key id: u32 BE][synthetic IV: 16][ciphertext]: Tink’s 5-byte TINK prefix that names the key by its KeyId, then the output of RFC 5297, so Tink and BigQuery read it with a keyset of that key, see One fixed key. Its equality compares in constant time, and its Debug prints only the length, since the bytes tell which values are equal.

Keys encrypted by a KMS

The envelope generates and decrypts deterministic keys with its provider:

use kms_aead::backends::Ring;
use kms_aead::formats::Deterministic;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{DeterministicAlgorithm, DeterministicCipher, Encrypted, KeyId, 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("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;
let algorithm = DeterministicAlgorithm::Aes256Siv;

// A new key, and the same key encrypted, to store
let (dek, encrypted_dek) = envelope.generate_encrypted_deterministic_dek(algorithm).await?;

// Later: decrypt the stored key
let decrypted_dek = envelope.decrypt_deterministic_dek(algorithm, &encrypted_dek).await?;

let cipher = DeterministicCipher::<Ring>::new(algorithm)?;
let sealed: Encrypted<SecretValue, Deterministic> =
    cipher.encrypt(KeyId::new(1), b"aad", &SecretValue::from("value"), &dek)?;
let decrypted: SecretValue = cipher.decrypt(b"aad", &sealed, &decrypted_dek)?;
assert_eq!(decrypted, SecretValue::from("value"));
Ok(())
}

decrypt_deterministic_dek calls the provider every time and does not go through the envelope’s DEK cache. To cache deterministic keys, use DekCache::with_dek, which holds the keys of both kinds of algorithm.

DeterministicCipher::generate_encrypted_dek does the same with any provider.

Formats

Every byte layout the library reads or writes is on this page.

The library writes one envelope format for every provider, Tink’s KMS envelope. A value encrypted by KmsAeadEnvelope::encrypt opens with Tink’s KmsEnvelopeAead on the same KMS key and algorithm, and a value Tink encrypts opens with KmsAeadEnvelope::decrypt.

LayoutTypeBytesRead byWritten by
Tink KMS envelopeEncrypted<T, Envelope>[w: u32 BE][encrypted DEK: w][nonce: 12][ciphertext][tag: 16]kms-aead, Tink0.30.0 and later
Bare valueEncrypted<T, Bare>[nonce: 12][ciphertext][tag: 16]kms-aead, Tinkevery release
Encrypted DEKEncryptedDataEncryptionKeythe provider’s bytes, see Encrypted DEKskms-aead, Tink for GCP and AWS keys0.30.0 and later
Serialised DEKthe plaintext of an encrypted DEK, but Azure’sTink’s key message, see Serialised DEKskms-aead, Tink0.30.0 and later
TINK-prefixed valueEncrypted<T, Randomized>, Encrypted<T, Deterministic>, named by a KeyId[0x01][key id: u32 BE][value]kms-aead, Tink, BigQuery0.30.0 and later, and kms-aead-keyring through it
Tink keysetTinkKeysetgoogle.crypto.tink.KeysetTink, BigQuery0.30.0 and later, and kms-aead-keyring through it
Tink encrypted keysetTinkKeyset::into_encrypted_bytesgoogle.crypto.tink.EncryptedKeyset, [0x12][w: varint][encrypted keyset: w]Tink with a GCP, AWS or Vault Transit key0.30.0 and later
Envelope layout v1Encrypted<T, Envelope> with legacy-formats[0x01][k: u8][key version: k][w: u16 BE][encrypted DEK: w][cipher text]kms-aead with legacy-formats0.29
Encrypted DEK layout v1stored EncryptedDataEncryptionKey[0xFF][0x01][k: u8][key version: k][encrypted DEK]kms-aead with legacy-formats0.29
Length-prefixed envelopeEncrypted<T, Envelope> with legacy-formats[w: u64 BE][encrypted DEK: w][cipher text], and [w: u32 BE] from 32-bit targets before 0.24.0kms-aead with legacy-formats0.8.0 to 0.28.1
Raw encrypted DEKEncryptedDataEncryptionKeythe KMS ciphertext of the DEK’s 16 or 32 raw byteskms-aead with legacy-formatsup to 0.29

k = 0 records no key version. A key version is 1 to 255 ASCII alphanumeric characters. The Azure provider encrypts the raw DEK in every release, see Encrypted DEKs.

Tink’s KMS envelope

KmsAeadEnvelope::encrypt writes Tink’s KMS envelope, and Encrypted::join writes it from a bare value and an encrypted DEK. separate reads it back:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, 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("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;
let encrypted: Encrypted<SecretValue, Envelope> =
    envelope.encrypt(b"aad", &SecretValue::from("secret")).await?;

let (bare, encrypted_dek) = encrypted.separate()?;
let joined = Encrypted::<SecretValue, Envelope>::join(&bare, &encrypted_dek)?;
assert_eq!(joined.value(), encrypted.value());
Ok(())
}
  • w is 1 to 4,096 bytes, the limit of every Tink implementation, on write and on read;
  • the encrypted DEK is the provider’s EncryptedDataEncryptionKey, the KMS ciphertext of the serialised DEK, or of the raw DEK for Azure;
  • the AAD goes to the DEK’s AEAD only. The KMS encryption of the DEK has no AAD, as in Tink, so Tink’s AWS client sends no encryption context and its GCP client no additional authenticated data;
  • the rest is a Bare value.

The envelope records neither the algorithm nor the KMS key version, so the writer and the reader, kms-aead or Tink, are configured with the same algorithm. Tink calls it the DEK template:

AlgorithmTink key typeTink for Java and GoTink for C++ and Python
ChaCha20-Poly1305ChaCha20Poly1305Keyreads and writesno
AES-128-GCM, AES-256-GCMAesGcmKeyreads and writesreads and writes
AES-256-GCM-SIVAesGcmSivKeyreads and writesreads and writes

AES-256-GCM is the default of KmsAeadEnvelope::new, so every Tink implementation reads the values of an envelope that names no algorithm. The default of 0.29 and earlier was ChaCha20-Poly1305, see Migrating from 0.29.

Tink for Python reads a kms-aead value with the key URI of the provider’s key:

from tink import aead
from tink.integration import gcpkms

aead.register()
key_uri = 'gcp-kms://projects/p/locations/l/keyRings/r/cryptoKeys/k'
remote = gcpkms.GcpKmsClient(key_uri).get_aead(key_uri)
envelope = aead.KmsEnvelopeAead(aead.aead_key_templates.AES256_GCM, remote)
plain_text = envelope.decrypt(value, aad)

Checked on a Cloud KMS key and an AWS KMS key, both ways: AES-128-GCM, AES-256-GCM and AES-256-GCM-SIV with Tink 1.16.1 for Python, and ChaCha20-Poly1305 with Tink 1.24.0 for Java on the Cloud KMS key. tests/tink_interop.py and examples/tink_interop.rs repeat the Python check.

Encrypted DEKs

EncryptedDataEncryptionKey is the bytes the provider returned from an encryption, which are also the bytes to store and the bytes it decrypts. Each provider’s bytes name the KMS key version that encrypted the DEK where the KMS needs one to decrypt:

ProviderBytesKey version
GCPthe Cloud KMS ciphertext, starting with 0x0ainside the ciphertext
AWSthe AWS KMS ciphertext blob, starting with 0x01not exposed
Azure[0x01][k: u8][key version: k][Key Vault encrypted DEK]in the layout
Vault Transitthe Transit ciphertext, vault:vN:…the N
local[0x01][i: u8][KEK id: i][KEK version: u32 BE][nonce: 12][sealed DEK][tag: 16]in the layout
an application’s ownwhatever it returns

The Azure layout is the library’s own: Key Vault decrypts only with the key version that encrypted, and no Tink client exists for Azure. For the same reason, the Key Vault encrypted DEK is the KMS ciphertext of the raw DEK of 16, 32 or 64 bytes, as in 0.29, not of the serialised DEK: AES key wrap, A128KW to A256KW, takes only multiples of 8 bytes, which no serialised DEK is. The local KMS seals the DEK with AES-256-GCM under the KEK version, with its header up to the version as the AAD, so neither the id nor the version can be changed without the decryption failing.

Store value() or to_hex_string(), and read them back with EncryptedDataEncryptionKey::from or try_from_hex_string, see Storing encrypted DEKs.

Serialised DEKs

The plaintext a provider encrypts is the DEK serialised as Tink serialises a key, the message of its Tink key type with version 0, which protobuf leaves out. The Azure provider encrypts the raw DEK instead, see Encrypted DEKs:

AlgorithmMessageBytes
ChaCha20-Poly1305ChaCha20Poly1305Key[0x12][0x20][key: 32]
AES-128-GCMAesGcmKey[0x1a][0x10][key: 16]
AES-256-GCMAesGcmKey[0x1a][0x20][key: 32]
AES-256-GCM-SIVAesGcmSivKey[0x1a][0x20][key: 32]
AES-256-SIV, deterministicAesSivKey[0x12][0x40][key: 64]

A beacon key of HMAC-SHA256 is serialised as [0x1a][0x20][key: 32], its 32 bytes in key_value, field 3, as in Tink’s HmacPrfKey without its parameters: the bytes of a 32-byte AesGcmKey. It is encrypted as a DEK is, raw under an Azure key.

KmsAeadEnvelope::generate_encrypted_dek, generate_encrypted_deterministic_dek and the generate_encrypted_dek of both ciphers and of BeaconMac encrypt their keys the same way. The message does not name its type, and AES-256-GCM and AES-256-GCM-SIV keys are written alike, so the algorithm of the reader decides how the key is used, as in Tink.

A decrypted DEK that is no serialised Tink key, or under an Azure key no raw DEK, fails with INVALID_KEY_FORMAT. An encrypted Tink keyset decrypts under the same KEK too, and this is what keeps it out of the DEK cache.

Tink keysets and the TINK prefix

Two items describe the TINK-prefixed values:

  • kms_aead::KeyId: the key id of Tink’s output prefix of type TINK, [0x01][key id: u32 BE], the start of every Randomized and Deterministic value and of every beacon. It picks the key that opens the value, and Tink and BigQuery pick the key of a keyset by it. It is not authenticated;
  • kms_aead::formats::TinkKeyset: a serialised google.crypto.tink.Keyset of enabled, symmetric, TINK-prefixed keys, the bytes tinkey writes for the same keys. Encrypted by a Cloud KMS key, it is the keyset of BigQuery’s KEYS.KEYSET_CHAIN: an application writes it for its one key, see One fixed key, and a keyring exports it for its versions.

TinkKeyset::into_encrypted_bytes writes the keyset encrypted by a KMS key, as Tink writes an encrypted keyset: a google.crypto.tink.EncryptedKeyset whose encrypted_keyset, field 2, is the KMS ciphertext of the serialised keyset, encrypted with no associated data. Tink’s binary keyset writers leave out keyset_info, field 3, and so does the library. Tink’s KeysetHandle.read reads it with the KMS client of KekRef::tink_key_uri, which Tink has for GCP and AWS keys, and Tink for Go for Vault Transit keys too.

Formats of Encrypted

Every value the library encrypts is an Encrypted<T, F>: the bytes of one layout, named by its format F from kms_aead::formats. Encrypted::try_from and Encrypted::try_from_hex_string check stored bytes against it:

FormatLayoutShortest valuekey_id()Written by
EnvelopeTink’s KMS envelopean encrypted DEK of 1 to 4,096 bytes, then 28 bytesnoneKmsAeadEnvelope::encrypt, Encrypted::join
Bare[nonce: 12][ciphertext][tag: 16]28 bytesnoneAeadCipher::encrypt_bare, KmsAeadEnvelope::encrypt_with_dek and encrypt_with_encrypted_dek, Encrypted::separate
Randomized[0x01][key id: u32 BE][nonce: 12][ciphertext][tag: 16]33 bytesKeyIdAeadCipher::encrypt, and kms-aead-keyring through it
Deterministic[0x01][key id: u32 BE][synthetic IV: 16][ciphertext]21 bytesKeyIdDeterministicCipher::encrypt, and kms-aead-keyring through it

A Bare value is Tink’s AEAD output under a key with the output prefix RAW, and the part of every Envelope value after the encrypted DEK. It names no key: the application keeps track of the key that opens it.

The check reads the framing only, and whether the bytes are authentic is known when they are decrypted. With legacy-formats, an Envelope value also holds the envelope layout v1 of 0.29 and the length-prefixed envelope, checked by their lengths, so a column decodes its old rows into the same type until they are upgraded, see Upgrading stored values. Without the feature, Encrypted::try_from refuses them with LEGACY_FORMAT. separate refuses them in every build. The layout of 32-bit targets before 0.24.0 has Tink’s framing, so it reads as an Envelope value in every build, and KmsAeadEnvelope::decrypt opens it with legacy-formats.

The plaintext inside is the encoding of the value’s Plaintext type: UTF-8 for String, the bytes themselves for Vec<u8> and SecretValue, true or false for bool, decimal ASCII for the integers, and compact JSON for Json<T>. See Typed fields.

Beacons

A Beacon<T> is a prefix and a truncated HMAC-SHA256:

[0x01][key id: u32 BE][HMAC-SHA256: the first ceil(bits / 8) bytes]
  • 0x01 is the version of the beacon layout. A later layout takes another first byte, so that beacons of both layouts can share a column during a re-index;
  • the key id names the beacon key that calculated the beacon;
  • the HMAC keeps its first bits bits, and the unused low bits of the last byte are zero. A beacon is 6 to 37 bytes.

The HMAC covers the bits, the context and the plaintext, as the Plaintext type encodes it:

HMAC-SHA256(key, [bits: u16 BE][context length: u32 BE][context][plaintext])

so that beacons of two fields, or of two bit counts, never match. Beacon::try_from checks the prefix and that 1 to 32 bytes of HMAC follow, and fails with INVALID_BEACON_FORMAT otherwise.

Telling layouts apart

The first bytes of a stored value:

First bytesLayout
00 00 00 00the length-prefixed envelope of 0.28.1 and earlier
00, then a length of 1 to 4,096Tink’s KMS envelope, or the 4-byte length prefix of 32-bit targets before 0.24.0
01envelope layout v1 of 0.29, or a TINK-prefixed value
FF 01encrypted DEK layout v1 of 0.29

Only the decrypted DEK tells a Tink envelope from the 32-bit layout before 0.24.0: a raw key of 16 or 32 bytes against a serialised Tink key, whose lengths differ. An encrypted DEK of AWS, Azure or the local KMS also starts with 0x01, and Tink’s LEGACY and CRUNCHY output prefixes with 0x00.

A column that holds two layouts:

  • Tink envelopes and TINK-prefixed values: the first byte, 0x00 or 0x01;
  • Tink envelopes and 0.29 envelopes: the first byte, 0x00 or 0x01;
  • Tink envelopes and 0.28.1 envelopes: the first four bytes;
  • 0.29 envelopes and TINK-prefixed values: no byte tells them apart, so the layout is recorded outside the value, such as in another column;
  • bare values and any other layout: a bare value has no framing, and its random nonce can start with any byte, so a column of bare values holds no other layout;
  • encrypted DEKs of 0.29 and of 0.30: 0xFF 0x01 starts no encrypted DEK of a built-in provider, so the library tells them apart for those. It never inspects the bytes of an application’s own provider: strip the header [0xFF][0x01][k][key version] of its 0.29 keys before reading them.

Values written by 0.29 and earlier

The layouts of 0.29 and earlier are read only with the deprecated legacy-formats feature:

  • KmsAeadEnvelope::decrypt reads the three envelope layouts, which Encrypted<T, Envelope> holds with the feature;
  • the *_with_encrypted_dek calls and the DEK cache read the encrypted DEK layout v1 of 0.29 and raw encrypted DEKs.

Encrypted::separate never reads the older envelope layouts: for Azure, the encrypted DEK they hold needs the provider to become its bytes.

Without the feature, reading them fails with KmsAeadError::LegacyFormat, the code LEGACY_FORMAT, and one of:

  • KmsAeadLegacyFormat::EnvelopeV1;
  • KmsAeadLegacyFormat::LengthPrefixedEnvelope;
  • KmsAeadLegacyFormat::EncryptedDekV1;
  • KmsAeadLegacyFormat::RawEncryptedDek, for an encrypted DEK that decrypts to the raw key. The values of 32-bit targets before 0.24.0 fail with it, since their framing is Tink’s.

The error never echoes the bytes read. With the feature, the first read of each layout in a process logs one WARN event that names the upgrade methods. Without it, the KMS AEAD decrypt spans of such values record error_code=LEGACY_FORMAT, see Observability.

Upgrading stored values

With legacy-formats, two methods of KmsAeadEnvelope rewrite a stored value in Tink’s format, and return None when it already is in it, so a sweep writes only the rows that change:

  • upgrade_legacy_value, for envelope values;
  • upgrade_legacy_encrypted_dek, for encrypted DEKs stored apart, such as the bytes of 0.29’s to_bytes.

Each decrypts the DEK with the provider, without the DEK cache, and encrypts it again as a serialised Tink key when it was in an older layout: one KMS call for a current value, two for an old one. A value in Tink’s framing needs the decryption to be told from the 32-bit layout before 0.24.0. The AEAD bytes stay as they are.

The envelope’s algorithm is the one the values were sealed with: ChaCha20-Poly1305 for values that 0.29 and earlier sealed under their default. A raw DEK does not record its algorithm, and the encrypted DEK of the upgrade records it as a Tink key type, so upgrade_legacy_value takes the value’s AAD and opens the value before it writes anything. Under another algorithm or another AAD it fails with DECRYPT. upgrade_legacy_encrypted_dek has no value to open, so it records the envelope’s algorithm for any key of its length. Be aware not to upgrade a DEK stored apart under another algorithm than its values were sealed with: use upgrade_legacy_value wherever the values are at hand.

A sweep over stored rows:

use kms_aead::backends::AeadBackend;
use kms_aead::formats::Envelope;
use kms_aead::{Encrypted, KmsAeadEncryptionProvider, KmsAeadEnvelope, KmsAeadResult};
use secret_vault_value::SecretValue;

/// A stored envelope value and the AAD it was sealed under.
struct Row {
    aad: Vec<u8>,
    value: Vec<u8>,
}

/// Rewrites the envelope values of `rows` in Tink's format, and returns how many changed.
#[allow(deprecated)]
async fn upgrade_values<P, B>(
    envelope: &KmsAeadEnvelope<P, B>,
    rows: &mut [Row],
) -> KmsAeadResult<usize>
where
    P: KmsAeadEncryptionProvider + Send + Sync,
    B: AeadBackend,
{
    let mut upgraded = 0;
    for row in rows.iter_mut() {
        let stored = Encrypted::<SecretValue, Envelope>::try_from(row.value.clone())?;
        if let Some(value) = envelope.upgrade_legacy_value(&row.aad, &stored).await? {
            row.value = value.into_value();
            upgraded += 1;
        }
    }
    Ok(upgraded)
}
use kms_aead::backends::Ring;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{AeadAlgorithm, AeadCipher, KmsAeadEnvelopeOptions, ValueStruct};
#[tokio::main(flavor = "current_thread")]
async fn main() -> KmsAeadResult<()> {
let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
// A value as 0.28.1 wrote it: [w: u64 BE][encrypted raw key][cipher text]
let cipher = AeadCipher::<Ring>::new(AeadAlgorithm::ChaCha20Poly1305)?;
let dek = cipher.generate_dek()?;
let bare = cipher.encrypt_bare(b"aad", &SecretValue::from("secret"), &dek)?;
let encrypted_dek = provider.encrypt_data_encryption_key(&dek).await?;
let mut legacy = u64::try_from(encrypted_dek.value().len()).unwrap().to_be_bytes().to_vec();
legacy.extend_from_slice(encrypted_dek.value());
legacy.extend_from_slice(bare.value());
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::with_options(
    provider,
    KmsAeadEnvelopeOptions::new().with_algorithm(AeadAlgorithm::ChaCha20Poly1305),
)?;
let mut rows = vec![Row { aad: b"aad".to_vec(), value: legacy }];
assert_eq!(upgrade_values(&envelope, &mut rows).await?, 1);
assert_eq!(upgrade_values(&envelope, &mut rows).await?, 0);
let upgraded = Encrypted::<SecretValue, Envelope>::try_from(rows[0].value.clone())?;
let decrypted: SecretValue = envelope.decrypt(b"aad", &upgraded).await?;
assert_eq!(decrypted, SecretValue::from("secret"));
Ok(())
}

Upgrade every reader to 0.30 before the sweep writes: 0.29 and earlier cannot read Tink’s format.

Deprecation

Reading the layouts of 0.29 and earlier is deprecated since 0.30.0 and will be removed in a later breaking release, together with the legacy-formats feature and the upgrade methods, which carry #[deprecated] from their first release, so every call site warns. Without the feature, the older layouts keep failing with LEGACY_FORMAT.

Error handling

Every fallible call returns KmsAeadResult<T>, whose error is kms_aead::errors::KmsAeadError:

  • SystemError: the configuration or the environment, such as invalid provider options or a KMS client that cannot be built;
  • NetworkError: a KMS that could not be reached or did not answer in time, a server error or a rate limit from Azure Key Vault or Vault, and every gRPC status from Google Cloud KMS;
  • EncryptionError: encrypting or decrypting a value or a DEK failed, or a stored value could not be read. Includes every AWS KMS error, and the requests Azure Key Vault and Vault refused;
  • UnsupportedAlgorithm: the backend does not implement the algorithm, from constructors only;
  • LegacyFormat: a stored value in a layout of 0.29 or earlier, which only the legacy-formats feature reads, see Formats.

The enum is #[non_exhaustive], so a match on it needs a _ arm.

Codes

code() returns a code to match on: one of the library’s own codes, or the code the KMS returned, such as AWS’s IncorrectKeyException. It is also the error_code field of the spans.

use kms_aead::backends::Ring;
use kms_aead::errors::KmsAeadError;
use kms_aead::formats::Bare;
use kms_aead::{AeadAlgorithm, AeadCipher, Encrypted};
use secret_vault_value::SecretValue;

fn main() -> kms_aead::KmsAeadResult<()> {
let cipher = AeadCipher::<Ring>::new(AeadAlgorithm::Aes256Gcm)?;
let dek = cipher.generate_dek()?;
let cipher_text: Encrypted<SecretValue, Bare> =
    cipher.encrypt_bare(b"orders/1042", &SecretValue::from("secret"), &dek)?;

match cipher.decrypt_bare(b"orders/1043", &cipher_text, &dek) {
    Err(KmsAeadError::EncryptionError(error)) if error.public.code == "DECRYPT" => {
        // Not authentic under this key and AAD
    }
    other => panic!("{other:?}"),
}

let too_short = Encrypted::<SecretValue, Bare>::try_from(vec![0; 8]);
assert!(matches!(&too_short, Err(e) if e.code() == "INVALID_CIPHER_TEXT_FORMAT"));
Ok(())
}

The library’s codes:

CodeVariantMeaning
ENCRYPT, DECRYPTencryptionsealing failed, or a value is not authentic under the key and the AAD
ENCRYPT_KEY, DECRYPT_KEYencryptionthe backend refused a key, or a KMS request could not be built
RANDOM_GENERATORencryptionthe CSPRNG failed, for a nonce or a key
INVALID_KEY_LENGTHencryptiona key that does not fit the algorithm, or a KMS generator that returned another length
UNSUPPORTED_ALGORITHMunsupported algorithmthe backend does not implement the algorithm
UNKNOWN_ALGORITHMencryptionFromStr of an algorithm name it does not know
INVALID_CIPHER_TEXT_FORMATencryptiona value too short, a length past its end, or a Randomized or Deterministic value without Tink’s output prefix
UNKNOWN_CIPHER_TEXT_LAYOUTencryptionan empty envelope value, or a first byte that starts no layout
INVALID_PLAINTEXTencryptiona decrypted value that is not an encoding of its type, or a Json value that does not serialise
INVALID_KEY_FORMATencryptiona decrypted DEK that is not a serialised Tink key, such as an encrypted keyset
INVALID_ENCRYPTED_KEY_FORMATencryptionthe encrypted DEK layout v1 of 0.29 that cannot be read, with legacy-formats
INVALID_KEY_VERSIONencryptiona key version that is not ASCII alphanumeric, in an Azure encrypted DEK or a layout of 0.29
INVALID_HEXencryptiontry_from_hex_string of text that is not hex
INVALID_BEACON_FORMATencryptionstored bytes that are not [0x01][key id: u32 BE] and 1 to 32 bytes of HMAC, at Beacon::try_from
INVALID_BEACON_CONTEXTencryptiona beacon context longer than 2^32 - 1 bytes
BEACONencryptionthe backend failed to calculate a beacon’s HMAC
LEGACY_FORMATlegacy formata layout of 0.29 or earlier without legacy-formats, at Encrypted::try_from or at decryption, and the envelope layouts of 0.29 and earlier in separate
INVALID_DEK_CACHE_TTLsystema DEK cache TTL of zero
INVALID_DEK_REUSE_POLICYsystema reuse policy out of range, or beside a disabled cache
INVALID_LOCAL_KEK_ID, LOCAL_*system, encryptionthe local KMS
AZURE_*system, encryptionthe Azure provider
VAULT_*system, network, encryptionthe Vault Transit provider

The KMS codes are the gRPC status codes of Google Cloud KMS, such as PermissionDenied, the AWS error codes, such as NotFoundException, and Key Vault’s error codes, such as KeyNotFound.

Retries

The library retries nothing itself. The AWS and Azure SDKs apply their own retry policies, which the Azure provider takes in client_options.

A NetworkError is the class to consider for a retry: a KMS that was not reached, a timeout, a server error or a rate limit. For Google Cloud KMS every failed call is a NetworkError, so check its code as well: Unavailable, DeadlineExceeded or ResourceExhausted are worth a retry, PermissionDenied or InvalidArgument are not. An EncryptionError from a decrypt does not change on a retry.

What errors never carry

No error carries plaintext, key material in any form, plaintext or encrypted, AAD content or cipher text bytes, in Display or in Debug. An application can log errors with {} and {:?}.

message is meant for a person reading logs. public holds the code, the part safe to return to the application’s own callers, and with the serde feature it is Serialize and Deserialize. std::error::Error::source() returns the root cause, the error of the SDK or library that failed, when there is one.

Observability

The library uses tracing. Every envelope call and every KMS call opens one span at the DEBUG level, and the spans carry what a call used besides how long it took: the mode, the algorithm and backend, whether the DEK came from the cache, the KMS key and its version, and the error code.

The spans are:

  • KMS AEAD encrypt: the envelope’s encrypt, encrypt_with_dek and encrypt_with_encrypted_dek;
  • KMS AEAD decrypt: the envelope’s decrypt, decrypt_with_dek and decrypt_with_encrypted_dek;
  • KMS AEAD generate DEK: generate_encrypted_dek and generate_encrypted_deterministic_dek;
  • KMS AEAD decrypt DEK: decrypt_deterministic_dek;
  • KMS AEAD beacon: the envelope’s beacon;
  • KMS encrypt DEK: each provider’s encryption of a DEK;
  • KMS decrypt DEK: each provider’s decryption of a DEK;
  • KMS random: a call to the GCP or AWS KMS generator, when the provider uses it.

The KMS spans nest inside the envelope span, so a DEK the envelope had to decrypt shows its KMS call as a child, and a DEK served by the cache shows none. AeadCipher and DeterministicCipher open no spans: they run once per value inside the caller’s span.

Showing the spans

Any tracing subscriber works. With tracing-subscriber, an EnvFilter that enables DEBUG for the library and span close events, every span is printed with its fields when it ends:

use kms_aead::backends::Ring;
use kms_aead::formats::Envelope;
use kms_aead::providers::{LocalKekId, LocalKmsProvider};
use kms_aead::{Encrypted, KmsAeadEnvelope};
use secret_vault_value::SecretValue;
use tracing_subscriber::fmt::format::FmtSpan;

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let subscriber = tracing_subscriber::fmt()
    .with_env_filter("kms_aead=debug")
    .with_span_events(FmtSpan::CLOSE)
    .finish();
tracing::subscriber::set_global_default(subscriber)?;

let provider = LocalKmsProvider::<Ring>::ephemeral(LocalKekId::try_from("example".to_string())?)?;
let envelope = KmsAeadEnvelope::<LocalKmsProvider<Ring>, Ring>::new(provider)?;

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

The decrypt prints, without timestamps:

DEBUG KMS AEAD decrypt{/kms_aead/mode="per_call" /kms_aead/algorithm=aes-256-gcm /kms_aead/backend=ring /kms_aead/dek_cache=disabled}:KMS decrypt DEK{/kms_aead/provider=local /kms_aead/key="example" /kms_aead/key_version="1" /kms_aead/response_time=0}: kms_aead::spans::kms: close time.busy=16.4µs time.idle=6.95µs
DEBUG KMS AEAD decrypt{/kms_aead/mode="per_call" /kms_aead/algorithm=aes-256-gcm /kms_aead/backend=ring /kms_aead/dek_cache=disabled}: kms_aead::spans: close time.busy=62.0µs time.idle=12.3µs

Full example available here, run it with cargo run --example tracing --features ring.

OpenTelemetry

With tracing-opentelemetry every span field becomes a span attribute with the same name, such as /kms_aead/key_version, so the KMS calls and cache hits show up next to the latency of a request in Cloud Trace, Jaeger, etc.

The fields are declared empty when the span opens and recorded once their value is known, which tracing-opentelemetry exports the same way as fields given at the start. Add its layer to the subscriber as usual:

use opentelemetry::trace::TracerProvider;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
    .with_batch_exporter(exporter) // any exporter, e.g. opentelemetry-otlp
    .build();

tracing_subscriber::registry()
    .with(tracing_subscriber::EnvFilter::new("info,kms_aead=debug"))
    .with(tracing_opentelemetry::layer().with_tracer(provider.tracer("my-service")))
    .init();

Be aware the library spans are DEBUG, so the filter has to enable that level for kms_aead.

Fields

Every field name has the /kms_aead/ prefix, such as /kms_aead/mode. A value nobody reported is left unrecorded, never recorded as 0 or an empty string.

KMS AEAD encrypt and KMS AEAD decrypt

FieldValue
modeper_call for encrypt and decrypt, store_less for both on a store-less envelope, caller_dek for the *_with_dek and *_with_encrypted_dek calls
algorithmchacha20-poly1305, aes-128-gcm, aes-256-gcm or aes-256-gcm-siv
backendring, aws-lc or rustcrypto
dek_cachedisabled for an envelope without a cache; hit or miss for a call that decrypts through an enabled cache, a failed decryption recording miss; unrecorded for a call that looks nothing up
error_codethe code of the error the call returned

KMS AEAD generate DEK and KMS AEAD decrypt DEK

FieldValue
purposerandomized or deterministic
algorithmthe algorithm of the key, aes-256-siv for deterministic keys
backendgenerate DEK only: the envelope’s backend, ring, aws-lc or rustcrypto
dek_cachedecrypt DEK only: bypassed beside an enabled cache, since the call never goes through it, disabled otherwise
error_codethe code of the error the call returned

KMS encrypt DEK, KMS decrypt DEK and KMS random

FieldValue
providergcp, aws, azure, hc_vault_transit, local, or the name of an application’s own provider
keythe KEK, see below; for KMS random the GCP location, and unrecorded on AWS
key_versionKMS encrypt DEK and KMS decrypt DEK: from the KMS response on encryption, from the encrypted DEK’s bytes on decryption for Azure, Vault Transit and the local KMS; unrecorded when neither has one, as for AWS and a GCP decryption
lenrandom only: the bytes requested from the KMS generator
response_timemilliseconds the KMS call took
error_codethe code of the error the call returned

The key of each provider:

  • GCP: the CryptoKey name, projects/<project>/locations/<location>/keyRings/<ring>/cryptoKeys/<key>;
  • AWS: the key or alias ARN, or the configured key id when no region is known;
  • Azure: <vault URL>/keys/<name>;
  • Vault Transit: <vault address>/<namespace>/<mount>/keys/<name>, without the namespace segment when none is set;
  • local: the KEK id.

Events

Besides the spans, the library logs:

  • WARN, inside the KMS span: a GCP or Vault Transit response without a key version;
  • WARN, once per process and layout: the first read of a layout of 0.29 or earlier with legacy-formats;
  • WARN, when a Vault Transit provider is created: an http address to another host, or a system CA certificate that cannot be loaded;
  • INFO, when a local KMS provider is created: its KEK id and versions, each with where it came from, in the key and key_versions fields.

The library retries nothing itself, so there is no retry field.

What is never logged

No span field or event carries:

  • plaintext, DEKs in any form, plaintext or encrypted, or KEK material;
  • AAD content, which can hold entity ids and so personal data;
  • cipher text bytes;
  • the length of a value or of its AAD. For AES-256-GCM the plaintext length is the value’s length minus a fixed overhead, and a length tells short values such as yes, no or a country code apart.

Key identifiers are recorded: KEK names, key versions and local KEK ids. A KEK name identifies the project, the location and the key, and a per-tenant KEK name carries the tenant id, so treat the key field accordingly in a trace pipeline.

Errors follow the same rules, see Error handling.

Security considerations

Threat model

Envelope encryption protects stored values from whoever can read the store but cannot use the KEK: a database dump, a backup, a replica, a data export or an operator with read access to the tables. Opening a value needs a KMS decryption, and the KMS checks the caller’s permission and, for the cloud KMSs, writes an audit log entry.

It does not protect values from:

  • a process, or whoever controls it, that may decrypt with the KEK: it can read every value it can fetch;
  • whoever can read the memory of a process while it holds plaintext DEKs, in the DEK cache or as a store-less writer’s DEK;
  • a reader of the store learning which values are equal, for values sealed with deterministic encryption.

The AAD protects a value from being moved: a value copied into another row or column, with an AAD that names its place, fails to open there. The AAD itself is not encrypted, and neither is the length of the encrypted DEK at the start of Tink’s KMS envelope, which is not authenticated either.

An encrypted DEK carries no context that the KMS authenticates. Whoever can write where encrypted DEKs are stored, such as a database table or a keyring store, can copy an encrypted DEK to another record or relabel it, and the KMS decrypts it for any principal allowed to decrypt with that KEK. The controls for this are in the deployment:

  • write access to the stored keys is kept apart from the right to decrypt with the KEK;
  • each isolation boundary, such as a tenant or an environment, has a KEK of its own, so a DEK copied across it fails to decrypt;
  • the KMS audit log of decrypt calls, turned on where the KMS leaves it off by default, shows which principal decrypted which key.

The local KMS has no IAM, no audit log and no HSM, see its limits.

Nonces

Every AEAD algorithm takes a random 96-bit nonce from the backend’s CSPRNG for each value, and writes it in front of the cipher text. NIST SP 800-38D allows 2^32 encryptions under one key with random 96-bit nonces, and the library applies the same bound to every algorithm:

  • the per-call envelope seals one value per DEK, so the bound does not apply to it;
  • a store-less envelope renews its DEK at max_uses, 2^24 by default and at most 2^32;
  • with encrypt_with_dek and AeadCipher, the application counts the values it seals under one key, and moves to a new key before 2^32;
  • the local KMS seals each DEK it encrypts with AES-256-GCM, so a KEK version is good for about 2^32 encryptions. Add a new version before that.

AES-256-GCM-SIV (RFC 8452) resists nonce misuse: a repeated nonce reveals only whether the two values sealed with it are equal, and does not break integrity.

Deterministic encryption with AES-SIV takes no random nonce.

Data volume

AES-GCM also has a bound on the data under one key, whatever the nonces: RFC 8446, section 5.5, allows 2^24.5 records of 2^14 bytes, 2^38.5 bytes, for an AE advantage of about 2^-57. The bound applies to AES-GCM keys this way:

  • a store-less envelope renews its DEK at max_bytes, 2^38 bytes by default under AES-GCM and at most 2^52, see Volume limit. AES-256-GCM-SIV and ChaCha20-Poly1305 have no default volume limit;
  • with encrypt_with_dek and AeadCipher, the application counts the bytes it seals under one AES-GCM key too, and moves to a new key before 2^38 bytes.

Key lifetime and zeroisation

DataEncryptionKey, LocalKek and the Vault token are zeroised on drop and shown as *** by Debug. The DEK cache zeroises a key when its entry leaves the cache and no with_dek closure holds it.

Some copies are out of the library’s reach:

  • the KMS SDKs and HTTP clients the providers use keep copies of each plaintext DEK in request and response buffers, which kms-aead cannot zeroise. The same holds for the Vault token in the HTTP stack;
  • ring does not zeroise its expanded key schedules, so they stay in freed memory after each call;
  • aws-lc-rs clears its key context through AWS-LC’s allocator, as long as the application leaves that allocator in place. The record keys AES-256-GCM-SIV derives for each call stay on the stack;
  • the RustCrypto backend zeroises its key objects and the per-call Poly1305 and POLYVAL keys;
  • an expired DEK stays in the cache’s memory until the next call on the cache or purge_expired;
  • a plaintext value returned to the application is a SecretValue, zeroised on drop, and what the application does with it afterwards is its own.

What rotation does not protect

Rotating the KEK in the KMS makes the KMS encrypt new DEKs under the new key version. It does not:

  • re-encrypt the DEKs already stored: they still decrypt with the old key version, which the KMS must keep enabled for as long as those values are read;
  • re-encrypt any value: a DEK that leaked keeps opening every value sealed under it, whatever the KEK version that encrypts it. To retire a leaked DEK, decrypt and encrypt the values again under new DEKs;
  • reach DEKs already in a DEK cache: they keep serving until their entries expire, or until invalidate or clear. The same holds for disabling a KMS key or revoking an IAM grant;
  • renew a store-less writer’s DEK: it is renewed by its policy, or once it leaves the cache.

With the local KMS, rotation is a new KEK version. The old version has to stay in the provider for as long as DEKs encrypted under it are read, and crypto-shredding means destroying every copy of it, backups included.

OSS

Open source code is created through voluntary collaboration of software developers. The original authors license the code so that anyone can see it, modify it, and distribute new versions of it. You should manage all OSS using the same procedures and tools that you use for commercial products. As always, train your employees on cyber security best practices that can help them securely use and manage software products. You should not solely rely on individuals, especially on the projects like this reading sensitive information.

Versioning

Please don’t use broad version dependency management not to include a new version of dependency automatically without auditing the changes.

Reporting a vulnerability

See SECURITY.md.

Migrating from 0.29

0.30 replaces the ring-only API with a choice of crypto backends, adds store-less DEK reuse, the DEK cache, deterministic encryption and the local KMS, and makes ring optional. It writes Tink’s KMS envelope format for every provider, so values written by 0.29 and earlier read only with the deprecated legacy-formats feature, which also rewrites them in the new format, see Old values. The default algorithm of the envelope is AES-256-GCM, see Default algorithm.

Features

0.290.30
ring-aead-encryptionring
a provider feature alone, which turned on ringthe provider feature plus ring, aws-lc, aws-lc-fips or rustcrypto
vault-transit-kms-encryptionhashicorp-vault-transit-kms-encryption
vault-transit-kms-encryption-basehashicorp-vault-transit-kms-encryption-base
vault-transit-kms-encryption-tls-roots, -tls-webpkihashicorp-vault-transit-kms-encryption-tls-roots, -tls-webpki
vault-transit-kms-encryption-tls-aws-lc-rs, -tls-ringhashicorp-vault-transit-kms-encryption-tls-aws-lc-rs, -tls-ring

ring is no longer turned on by the provider features. A 0.29 build that relied on it fails with “kms-aead needs a crypto backend for its KMS providers”, so add a backend feature. aws-lc adds no second crypto library to a cloud provider build, see Choosing a backend.

The crate declares rust-version = "1.99".

Names

0.290.30
KmsAeadRingEnvelopeEncryption<P>KmsAeadEnvelope<P, Ring>, with the backend as a type parameter
KmsAeadRingEnvelopeEncryption::new(provider).await?KmsAeadEnvelope::<P, Ring>::new(provider)?, not async, on AES-256-GCM rather than ChaCha20-Poly1305, see Default algorithm
KmsAeadRingEnvelopeEncryption::with_algorithm(provider, &ring::aead::AES_256_GCM)KmsAeadEnvelope::<P, Ring>::with_options(provider, KmsAeadEnvelopeOptions::new().with_algorithm(AeadAlgorithm::Aes256Gcm))
with_options, with_algorithm_optionsKmsAeadEnvelope::<P, Ring>::with_options(provider, options)
KmsAeadRingEnvelopeEncryptionOptionsKmsAeadEnvelopeOptions
KmsAeadRingEncryptionProviderKmsAeadEncryptionProvider, see Your own provider
module kms_aead::ring_encryptionremoved; AeadCipher<Ring>, with AeadCipher at the crate root and Ring in kms_aead::backends
RingAeadEncryption::new()AeadCipher::<Ring>::new(AeadAlgorithm::ChaCha20Poly1305)
RingAeadEncryption::with_algorithm(&ring::aead::AES_256_GCM, rand)AeadCipher::<Ring>::new(AeadAlgorithm::Aes256Gcm)
RingAeadEncryption::with_rand, with_options, with_algorithm_optionsremoved; the backend owns its CSPRNG, and the random nonce was the only option
RingAeadEncryptionOptions, RingAeadEncryptionNonceKindremoved
RingAeadEncryption.algoAeadCipher::algorithm()
RingAeadEncryption::generate_data_encryption_key()AeadCipher::generate_dek()
kms_aead::kms_envelope_encryption::…the same names at the crate root
CipherText(bytes)Encrypted::<SecretValue, Bare>::try_from(bytes)?, which checks the layout
CipherTextWithEncryptedKey(bytes)Encrypted::<SecretValue, Envelope>::try_from(bytes)?, which checks the layout
CipherTextWithEncryptedKey::new(&cipher_text, &encrypted_dek)Encrypted::<SecretValue, Envelope>::join(&bare, &encrypted_dek)?, in Tink’s KMS envelope format
cipher_text.to_hex_string()value.to_hex_string(), read back with Encrypted::try_from_hex_string
cipher_text == othervalue.value() == other.value()
EncryptedDataEncryptionKey::new(encrypted, key_version)EncryptedDataEncryptionKey::new(bytes): the provider’s bytes, which carry the key version where the KMS needs one
encrypted_dek.wrapped(), to_bytes()?, TryFrom<&[u8]>encrypted_dek.value() and EncryptedDataEncryptionKey::from(bytes)
encrypted_dek.to_hex_string()?encrypted_dek.to_hex_string(), infallible
encrypted_dek.key_version()removed; the key_version field of the KMS spans
kms_aead::KmsKeyVersionkms_aead::providers::KmsKeyVersion, the type of AzureKmsKeyRef::key_version
DataEncryptionKey(secret), dek.0DataEncryptionKey::from(secret), dek.value()
errors::BoxedErrorremoved

HashiCorp Vault Transit types have the Hc prefix:

0.290.30
VaultTransitProviderHcVaultTransitProvider
VaultTransitProviderOptionsHcVaultTransitProviderOptions
VaultTransitKeyRefHcVaultTransitKeyRef
VaultTransitTokenHcVaultTransitToken
examples/vault_transit_kms.rsexamples/hc_vault_transit_kms.rs

Traits replaced by inherent methods

AeadEncryption and KmsAeadEnvelopeEncryption are gone. Their methods are inherent methods of AeadCipher and KmsAeadEnvelope, so delete the trait imports. The AAD is any &(impl AsRef<[u8]> + ?Sized), like 0.29’s Aad: AsRef<[u8]>, so &aad still compiles, and the calls that make no KMS call are not async:

0.290.30
use kms_aead::KmsAeadEnvelopeEncryption; envelope.encrypt_value(&aad, &value).await?envelope.encrypt(&aad, &value).await?
envelope.encrypt_value_with_dek(&aad, &value, &dek).await?envelope.encrypt_with_dek(&aad, &value, &dek)?
envelope.decrypt_value(&aad, &cipher_text).await?envelope.decrypt(&aad, &value).await?
envelope.decrypt_value_with_dek(&aad, &cipher_text, &dek).await?envelope.decrypt_with_dek(&aad, &bare, &dek)?
encrypt_value_with_encrypted_dek, decrypt_value_with_encrypted_dekencrypt_with_encrypted_dek, decrypt_with_encrypted_dek
KmsAeadEnvelopeEncryption::<Aad>::generate_new_dek(&envelope)envelope.generate_encrypted_dek()
use kms_aead::AeadEncryption; cipher.encrypt_value(&aad, &value, &key).await?cipher.encrypt_bare(&aad, &value, &key)?
cipher.decrypt_value(&aad, &cipher_text, &key).await?cipher.decrypt_bare(&aad, &bare, &key)?

Every cipher text is an Encrypted<T, F>, see Formats. It has its own value() and into_value(). Those of EncryptedDataEncryptionKey come from ValueStruct, which the crate re-exports: use kms_aead::ValueStruct. DataEncryptionKey no longer implements ValueStruct, and has its own value().

Your own provider

An application’s own implementation of the provider trait:

  • implements KmsAeadEncryptionProvider instead of KmsAeadRingEncryptionProvider;
  • adds fn kek(&self) -> &KekRef, with KekProvider::Other and a key name that identifies the key;
  • deletes generate_encryption_key when it returned the backend’s key. The replacement, generate_key_material(&self, len), has a default implementation that lets the backend generate the key, and is implemented only for a KMS generator;
  • returns EncryptedDataEncryptionKey::new(bytes). A KMS that needs the key version to decrypt gets it from those bytes, as the Azure provider’s do.

The plaintext the provider encrypts is the DEK serialised as a Tink key, see Formats. The library never inspects the provider’s own bytes.

Errors

KmsAeadError is #[non_exhaustive], so an exhaustive match on it needs a _ arm. It has two new variants, UnsupportedAlgorithm and LegacyFormat. code() returns the code of any variant, and source() returns the root cause.

The From conversions of SDK errors into KmsAeadError are gone: gcloud_sdk::error::Error, gcloud_sdk::tonic::Status, the AWS SdkError, the hyper client error, azure_core::Error and SystemTimeError. Map an SDK error in the application.

ConstantTimeEq is no longer implemented on the public types. == still compares in constant time. The Debug of Encrypted prints only the length, and == is gone from randomised values: only an Encrypted<T, Deterministic> has it.

Error codes that changed:

0.290.30Where
INVALID_CIPHERTEXTINVALID_CIPHER_TEXT_FORMATEncrypted::try_from of a Bare value shorter than a nonce and a tag
ENCRYPTIONRANDOM_GENERATORa CSPRNG failure, for a nonce or a key
DECRYPT_KEYENCRYPT_KEYthe GCP encryption, when its request cannot be built
DECRYPT_KEYRANDOM_GENERATORthe GCP KMS generator, when its request cannot be built
SYSTEM_TIME_ERRORremoved
UNKNOWN_ENCRYPTED_KEY_LAYOUTremovedstored encrypted DEKs are the provider’s bytes
AZURE_KEY_VERSION_MISSINGAZURE_ENCRYPTED_DEK_FORMATthe Azure decryption of bytes outside its layout; the old code stays for a 0.29 value without a key version, with legacy-formats

The other 0.29 codes keep their spelling. The new codes are listed in Error handling.

AWS KMS decryption names the key

The AWS provider sends the configured key as KeyId on every decryption. In 0.29 a decryption left it out, so AWS KMS decrypted under whichever key the encrypted DEK named, for any key the principal may decrypt with. A 0.30 process refuses a DEK encrypted under another key with IncorrectKeyException. A deployment that relied on decrypting under a second key configures a provider per key.

An alias name such as alias/orders becomes an alias ARN, where 0.29 built a key ARN of it. Configure the key ARN rather than an alias, see AWS KMS.

Default algorithm

The default algorithm of KmsAeadEnvelope is AES-256-GCM, which every Tink implementation reads in a KMS envelope. The default of 0.29 was ChaCha20-Poly1305.

A value does not record its algorithm. Values sealed under the default of 0.29 open only with ChaCha20-Poly1305 configured, and an envelope on the new default fails them with DECRYPT. A 0.29 envelope that named no algorithm becomes:

use kms_aead::backends::Ring;
use kms_aead::providers::{GcpKmsKeyRef, GcpKmsProvider};
use kms_aead::{AeadAlgorithm, KmsAeadEnvelope, KmsAeadEnvelopeOptions};

async fn example(key_ref: GcpKmsKeyRef) -> kms_aead::KmsAeadResult<()> {
// 0.29: KmsAeadRingEnvelopeEncryption::new(provider).await?
let envelope = KmsAeadEnvelope::<GcpKmsProvider, Ring>::with_options(
    GcpKmsProvider::new(&key_ref).await?,
    KmsAeadEnvelopeOptions::new().with_algorithm(AeadAlgorithm::ChaCha20Poly1305),
)?;
let _ = envelope;
Ok(())
}

Configure it on every envelope that reads those values, on the one that upgrades them, see Old values, and on every envelope that writes new values beside them: a reader cannot tell the two algorithms apart. AES-256-GCM suits a column that holds no 0.29 values.

Old values

0.30 writes Tink’s KMS envelope, and encrypts every DEK but Azure’s as a serialised Tink key. 0.29 wrote envelope layout v1 and the encrypted DEK layout v1 [0xFF][0x01]…, and 0.28.1 and earlier a length-prefixed envelope, all with the raw DEK encrypted. 0.30 reads them only with the deprecated legacy-formats feature, and fails with LEGACY_FORMAT without it, see Formats.

The envelopes that read and upgrade those values keep the algorithm they were sealed with, see Default algorithm.

To move stored values to the new format:

  1. upgrade every process that reads the values to 0.30 with legacy-formats. 0.29 and earlier cannot read Tink’s format, so no process writes it before every reader has the upgrade;
  2. rewrite the stored values with upgrade_legacy_value and upgrade_legacy_encrypted_dek of KmsAeadEnvelope, one or two KMS calls per value, see Upgrading stored values. upgrade_legacy_value takes the value’s AAD, and fails with DECRYPT on a value that does not open under the envelope’s algorithm and that AAD;
  3. turn the feature off.

Encrypted::<T, Envelope>::separate reads Tink’s format only, with or without the feature, so code that separated stored 0.29 values runs after the sweep.