AshSandbox.EncryptedSecret (AshSandbox v0.1.0)

Copy Markdown View Source

A string that is encrypted before it reaches the data layer and decrypted on the way back (003 T036, data-model.md §SandboxCredential).

Why this exists alongside sensitive? true

The two are routinely conflated and protect against different readers. sensitive? true governs what Ash prints — inspect output, error messages, changeset dumps — and leaves the stored bytes untouched. This type governs what someone reading the table sees: a database backup, a replica, an operator with SELECT, or a support engineer running an ad-hoc query.

A credential needs both, because neither implies the other. The resource therefore keeps sensitive? true on the attribute and uses this as its type.

AES-256-GCM, from OTP's :crypto

GCM is authenticated: tampering with stored ciphertext produces a decrypt failure rather than a silently different plaintext. That matters here because the value is fed into CREATE ROLE ... PASSWORD and a corrupted secret that decrypts to something would produce a sandbox nobody can connect to, with no indication why.

A fresh 12-byte IV per write is generated for every encryption, so two sandboxes that happen to share a secret do not share ciphertext. Without it the scheme is deterministic and leaks equality across rows — an operator could see which sandboxes share a credential without decrypting anything.

Stored as iv <> tag <> ciphertext, Base64-encoded so the column stays text and needs no migration.

Key management

The key comes from configuration and is not generated at runtime: a generated key would change on restart and strand every credential already written. Configure it as 32 raw bytes, Base64-encoded:

config :ash_sandbox, AshSandbox.EncryptedSecret,
  key: System.fetch_env!("SANDBOX_CREDENTIAL_KEY")

Why not cloak

cloak/cloak_ecto is the conventional Elixir answer and was considered and rejected -- recorded in 003 data-model.md §Decision. The short form: this is a published library (012-FR-015), so every dependency it declares is inherited by every consumer, and imposing Cloak's vault configuration on consumers who may hold no credentials at all is not worth 136 lines. The accepted cost is the next paragraph.

Revisit if a requirement appears for key rotation without downtime, or if a second encrypted attribute appears in these packages.

This is deliberately not a key rotation mechanism. Rotating the encryption key means re-encrypting every row, which is a migration rather than a type concern — and distinct from FR-020's credential rotation, which changes the secret itself and is handled by the resource's :rotate action.

Summary

Functions

Derives the key now, so no later read has to.

Functions

handle_change?()

prepare_change?()

warm()

@spec warm() :: :ok

Derives the key now, so no later read has to.

Called from AshSandbox.Application.start/2. A consumer that holds no credentials configures no key, so an unconfigured application is a no-op here rather than a boot failure — the raise belongs on the first use of a credential, where it names what the caller was trying to do.