Envelope encryption for the secrets Ankusa stores: a source's verify.secret
and its sinks' credentials (URLs with a password, header values, tokens, keys),
which Ankusa.SourceStore.Persistent keeps in this node's RocksDB store (and
so in every backup of it) and Ankusa.SourceStore.Redis keeps in Redis.
Value format
One encrypted value is
<<"AENC", 1::8, key_id_len::8, key_id::binary, iv::96, tag::128, ciphertext::binary>>AES-256-GCM (:crypto.crypto_one_time_aead/7) under the key-encryption key
(KEK) named key_id, with a fresh random 96-bit IV per value. The bytes up to
and including key_id are the authenticated data, so a value cannot be
re-labelled with another key's id. In a stored JSON spec each hidden string is
replaced by "AENC:" <> Base.encode64(value).
Which fields
The fields Ankusa.Admin.Redact hides from the admin API are the fields this
module encrypts (Ankusa.Admin.Redact.reduce_hidden/3): one allowlist decides
both what the API may show and what may rest in the clear. Identity and
structure (type, tenant, signature_header, topics, regions, …) stay
readable, so a stored spec can still be inspected and diffed.
Keys
Ankusa.Config's secrets section:
:kek— the current key: 32 random bytes, base64 (openssl rand -base64 32), optionally"<id>:<base64>". The id names the key in every value it encrypts; without one it is the first eight hex characters of the key's SHA-256.:kek_provider—{module, fun, args}instead of:kek, called once when a store starts, returning{:ok, [kek | previous]}: each element is a:kekstring or an{id, 32-byte key}pair, the first one current.:kek_previous— keys that may still decrypt, never encrypt, so a rotation can be rolled out node by node and rows rewritten withmix ankusa.secrets.rewrap.
With no KEK new rows are stored as plain JSON, as before; rows written earlier under a KEK are then refused at boot rather than served as ciphertext.
Summary
Functions
Is there a key to encrypt with?
The id of the key new values are encrypted under, or nil with no KEK.
Decrypt a value encrypt/2 produced, under whichever of the keyring's keys named it.
One line for the startup log: how the secrets rest. Does not call
:kek_provider, so it names the provider, not its key.
Encrypt plaintext under the current key. Raises with no KEK.
Is this binary an encrypted value (by its magic)?
Resolve a config's secrets section into a keyring. Calls :kek_provider
once, if there is one.
The spec to store when a row is moved to the current key: {:ok, sealed}
when it holds a plaintext hidden field or a value under another key, :current
when there is nothing to do.
Encrypt every hidden field of a source spec (see the module doc). With no KEK the spec is returned as it is.
Decrypt every encrypted string of a stored spec. A plain spec comes back
unchanged; an encrypted one that no key of the keyring opens is an error
(:no_kek, {:unknown_key, id}, :tampered), never half-decrypted.
Check the secrets section's shape. Never calls :kek_provider (that is
I/O, and happens once when a store starts), and never prints a key.
Types
@type keyring() :: Ankusa.Crypto.Envelope.Keyring.t()
@type reason() :: :no_kek | :not_encrypted | :malformed | :tampered | {:unknown_key, String.t()} | {:unsupported_version, non_neg_integer()} | {:bad_kek, :not_base64 | :wrong_size | :bad_id | :not_a_key | :duplicate_id} | {:kek_provider, term()}
Why a value could not be read or a keyring built. Never carries key bytes.
Functions
Is there a key to encrypt with?
The id of the key new values are encrypted under, or nil with no KEK.
Decrypt a value encrypt/2 produced, under whichever of the keyring's keys named it.
@spec describe(Ankusa.Config.t()) :: String.t()
One line for the startup log: how the secrets rest. Does not call
:kek_provider, so it names the provider, not its key.
Encrypt plaintext under the current key. Raises with no KEK.
Is this binary an encrypted value (by its magic)?
Resolve a config's secrets section into a keyring. Calls :kek_provider
once, if there is one.
The spec to store when a row is moved to the current key: {:ok, sealed}
when it holds a plaintext hidden field or a value under another key, :current
when there is nothing to do.
Encrypt every hidden field of a source spec (see the module doc). With no KEK the spec is returned as it is.
Decrypt every encrypted string of a stored spec. A plain spec comes back
unchanged; an encrypted one that no key of the keyring opens is an error
(:no_kek, {:unknown_key, id}, :tampered), never half-decrypted.
@spec validate_config!(Ankusa.Config.t()) :: :ok
Check the secrets section's shape. Never calls :kek_provider (that is
I/O, and happens once when a store starts), and never prints a key.