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 var | Purpose |
|---|
NAIVE_VAULT_KEK_ID | The CMK ARN that wraps every DEK. |
NAIVE_KMS_REGION | AWS region of the CMK (default us-east-1). |
NAIVE_KMS_ACCESS_KEY_ID | Access key for the dedicated KMS IAM user. |
NAIVE_KMS_SECRET_ACCESS_KEY | Secret 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.