Ankusa.Crypto.Envelope (ankusa v0.7.0)

Copy Markdown View Source

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 :kek string 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 with mix 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

Types

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.

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

keyring()

@type keyring() :: Ankusa.Crypto.Envelope.Keyring.t()

reason()

@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

configured?(keyring)

@spec configured?(keyring()) :: boolean()

Is there a key to encrypt with?

current_key_id(keyring)

@spec current_key_id(keyring()) :: String.t() | nil

The id of the key new values are encrypted under, or nil with no KEK.

decrypt(keyring, arg2)

@spec decrypt(keyring(), binary()) :: {:ok, binary()} | {:error, reason()}

Decrypt a value encrypt/2 produced, under whichever of the keyring's keys named it.

describe(config)

@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(keyring, plaintext)

@spec encrypt(keyring(), iodata()) :: binary()

Encrypt plaintext under the current key. Raises with no KEK.

encrypted?(arg1)

@spec encrypted?(binary()) :: boolean()

Is this binary an encrypted value (by its magic)?

keyring(secrets)

@spec keyring(map()) :: {:ok, keyring()} | {:error, reason()}

Resolve a config's secrets section into a keyring. Calls :kek_provider once, if there is one.

rewrap_spec(ring, spec)

@spec rewrap_spec(keyring(), map()) :: {:ok, map()} | :current | {:error, reason()}

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.

seal(ring, spec)

@spec seal(keyring(), map()) :: map()

Encrypt every hidden field of a source spec (see the module doc). With no KEK the spec is returned as it is.

unseal(ring, spec)

@spec unseal(keyring(), map()) :: {:ok, map()} | {:error, reason()}

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.

validate_config!(config)

@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.