Skip to main content
The Credential Vault stores per-user secrets using AWS KMS envelope encryption.

How it works

  • A per-entry data key (DEK) is generated by KMS GenerateDataKey.
  • The value is encrypted locally with AES-256-GCM (Node crypto, never hand-rolled). Blob layout: nonce(12) || tag(16) || ciphertext. No AAD.
  • KMS returns the DEK already wrapped by the shared customer-managed key (CMK). We store ciphertext, wrapped_dek, and kek_id (per row, so rotation knows what wrapped what). The plaintext DEK is zeroized immediately and never persisted.

Per-tenant isolation via EncryptionContext

Every KMS call passes EncryptionContext = { company_id, tenant_user_id }. KMS verifies it on Decrypt, so a wrapped_dek stolen from one tenant’s row cannot decrypt another tenant’s data — even under the same CMK. key (the vault entry name) is deliberately not in the context, so vault keys stay renameable via a plain UPDATE. And there is no AES-GCM AAD — KMS context is the sole crypto binding, avoiding a JSON.stringify canonicalization footgun that would otherwise brick every row on a refactor.

Rotation — two distinct operations

  • Key rotation (POST .../rotate): KMS ReEncrypt re-wraps the DEK under the current CMK version. The value ciphertext is unchanged. Cheap.
  • Value rotation: a normal PUT with a new value (fresh DEK + ciphertext).
  • ?regenerate_dek=true does a full DEK regeneration + value re-encryption.
AWS automatic key rotation is enabled on the CMK.

IAM — least privilege

Action   = ["kms:GenerateDataKey", "kms:Decrypt", "kms:ReEncrypt*"]
Resource = aws_kms_key.naive_vault.arn   # this one key only
Condition = {
  "ForAnyValue:StringEquals" = {
    "kms:EncryptionContextKeys" = ["company_id", "tenant_user_id"]
  }
}
No kms:*, no Resource: "*". A request that omits the tenant context is rejected by IAM before it reaches KMS.

Configuration

Env varPurpose
NAIVE_VAULT_KEK_IDThe CMK ARN that wraps every DEK.
NAIVE_KMS_REGIONAWS region of the CMK (default us-east-1).
NAIVE_KMS_ACCESS_KEY_IDAccess key for the dedicated KMS IAM user.
NAIVE_KMS_SECRET_ACCESS_KEYSecret for that IAM user.
KMS uses its own credentials — not the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION env vars. On Fly those are Tigris (S3-compatible object storage) credentials with region auto; reusing them would route KMS calls to Tigris and fail every Vault operation. The dedicated NAIVE_KMS_* trio keeps the two credential sets isolated. When NAIVE_KMS_ACCESS_KEY_ID/NAIVE_KMS_SECRET_ACCESS_KEY are unset (e.g. local dev with a real AWS profile), the client falls back to the default AWS credential chain.

Secrets never travel in URLs

Reveal is a POST (/vault/:key/reveal) — the value comes back in the body, never a query string. List masks values.