Key management and rotation¶
Taintward uses two secret keys:
| purpose | what it does | where it is used |
|---|---|---|
fpe |
AES key for FF1; makes the reversible tokens for internal sinks | Redactor |
vault |
AES-GCM key; seals each original in the vault | SQLiteVault, PostgresVault, RedisVault |
Each purpose has a key ring: one active key that is used for new work, plus older keys that are only used to read what they made. This page shows where rings can come from, how to keep keys wrapped by a KMS, and how to rotate. How the keys are used is in Redaction crypto.
Where keys come from¶
Set TAINTWARD_KEYS:
| value | source |
|---|---|
unset or env |
TAINTWARD_FPE_KEY and TAINTWARD_VAULT_KEY, one key each (the old setup, unchanged) |
file:/etc/taintward/keys.json |
a key ring file |
vault-kv:secret/taintward |
the same ring stored in HashiCorp Vault KV v2 (<mount>/<path>) |
TAINTWARD_FPE_KEY and TAINTWARD_VAULT_KEY still win when they are set:
they become the active key, and the ring's keys stay readable. So you can
move to a ring without changing anything else first.
HashiCorp Vault is called over its HTTP API with the standard settings:
VAULT_ADDR, VAULT_TOKEN (or VAULT_TOKEN_FILE), and optionally
VAULT_NAMESPACE and VAULT_CACERT. No extra Python package is needed.
In KV, store the ring as the secret's data, or as a JSON string under the
field keyring.
A ring from a file or Vault is read once per process and cached. Restart
the process (or call taintward.sdk.keys.reload_keys()) after a rotation.
The key ring file¶
{
"kms": "aws-kms:alias/taintward",
"fpe": {
"active": "v2",
"keys": {
"v1": {"wrapped": "AQICAHh...", "kms": "aws-kms:alias/taintward"},
"v2": {"wrapped": "AQICAHi..."}
}
},
"vault": {
"active": "v1",
"keys": {"v1": {"wrapped": "AQICAHj..."}}
}
}
activenames the key for new work. If it is missing, the first key is active.- A key is either plain hex (
"v1": "<hex>"or{"hex": "<hex>"}), or wrapped by a KMS ({"wrapped": ...}). A wrapped key uses its ownkms, or the ring's top levelkms. - Key ids are 1 to 64 of
A-Z a-z 0-9 . _ -. The FPE key id is stored next to each token in the vault, and the vault key id is stored in each sealed row. - A key may not be in both the
fpeand thevaultring.
taintward-keys writes this file for you (below), with mode 0600.
Wrapped keys (envelope encryption)¶
With a wrapped key, the ring file holds only ciphertext. The KMS unwraps it once, when the ring is loaded, and the key then lives in process memory. Someone who copies the file gets nothing without KMS access.
kms value |
service | needs |
|---|---|---|
aws-kms:<key id, ARN or alias/...> |
AWS KMS Decrypt |
pip install "taintward[aws]", the normal AWS credentials and region (AWS_ENDPOINT_URL works too) |
gcp-kms:projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k> |
Google Cloud KMS | pip install "taintward[gcp]", application default credentials |
vault-transit:[<mount>/]<key> |
HashiCorp Vault Transit (mount transit by default) |
VAULT_ADDR, VAULT_TOKEN |
AWS and GCP wraps are bound to their purpose (encryption context or
additional data taintward-key=fpe or vault). A wrapped vault key put in
the fpe ring fails to unwrap.
Give the Taintward process permission to decrypt only. Wrapping happens when you rotate, from a machine or pipeline that may encrypt.
Rotation¶
Make a ring¶
taintward-keys rotate --file keys.json --purpose fpe --kms aws-kms:alias/taintward
taintward-keys rotate --file keys.json --purpose vault
taintward-keys show --keys file:keys.json
The first --kms is saved as the ring's default, so later rotations are
wrapped the same way. show lists key ids only, never key bytes:
Put the file in your secret store, or write the same JSON to Vault KV, and
point TAINTWARD_KEYS at it.
Rotate the vault key¶
taintward-keys rotate --file keys.json --purpose vault # v2 is active, v1 kept
# deploy the new ring to every replica, then:
TAINTWARD_KEYS=file:keys.json taintward-keys reseal --vault postgresql://...
taintward-keys retire --file keys.json --purpose vault --id v1
- rotate adds a new key and makes it active. Old rows still open: each row names the key that sealed it.
- Deploy the new ring everywhere. New rows are sealed with the new key.
- reseal goes through the vault and seals every row that is not on the active key again, including its subject index for erasure. It works on SQLite, Postgres and Redis, keeps TTLs, and only replaces a row that did not change while it ran. Running it again does nothing.
- retire removes the old key once nothing needs it. It refuses to remove the active key.
While old and new keys are both in the ring, forget_subject finds rows
indexed under either key, so erasure works during the whole rotation.
With the env provider, the same steps are: set the new key in
TAINTWARD_VAULT_KEY, the old one in TAINTWARD_VAULT_KEY_PREVIOUS
(comma separated for more than one), run reseal, then drop the old one.
Rotate the FPE key¶
New values get tokens under the new key. Old tokens still reverse through the vault, which keeps the original, not something derived from the key. Two things to know:
- The same value gets a different token after the rotation, so joining internal data across the rotation point does not work. Rotate the FPE key on a schedule you can plan around, or when a key may have leaked.
- The digit-only fallback (
structural_fallback, on only withMemoryVault) uses the active key, so it cannot reverse tokens made under an older key. Persistent vaults do not use it.
Keep old FPE keys in the ring as long as you want to be able to check who made a token. Retiring one makes erasure of its digit-only tokens final: no one can decrypt them without the vault any more.
Rows from before key rings¶
Vault rows written by an earlier version have no key id. They are opened by
trying each key in the ring (the AES-GCM tag tells which key is right), and
reseal moves them to the active key. Their subject index is kept as it
was, because those rows do not hold the subject, so keep the key that wrote
them in the ring if you still need to erase them by subject.
Errors¶
A ring that does not load stops the process with a KeyConfigError that
says what is wrong (missing active key, bad hex, wrong length, wrapped key
with no kms, KMS unreachable, KMS refused). Messages never contain key
bytes, and KMS errors show only the error type.
A vault row sealed with a key that is no longer in the ring raises
VaultError naming the missing key id.
Code¶
taintward/sdk/keys.py: rings, providers, KMS wrap and unwrap, thetaintward-keyscommand.taintward/sdk/vault.py:_Sealer(key id in each row, open with any key in the ring, erasure under every key,reseal).taintward/sdk/redact.py: the Redactor takes its FPE key from the ring.- Tests:
tests/test_keys.py(rings, rotation, reseal, erasure across rotation, old rows, CLI, wrapped keys with a fake GCP client),tests/test_keys_services.py(HashiCorp Vault KV and Transit, AWS KMS on LocalStack, in docker),tests/test_vault_backends.py::test_vault_key_rotation_reseal(Postgres and Redis).