Documentation
¶
Overview ¶
Package crypto is Rivolt's in-process key-wrapping layer. It exists so the rest of the codebase can say "seal this blob" and "open this blob" without caring whether the key-encryption key (KEK) sits in an env var or a cloud KMS.
Design ¶
Every sealed blob is **envelope-encrypted**:
- A fresh 32-byte data-encryption key (DEK) is generated per Seal call.
- The plaintext is encrypted with AES-256-GCM under the DEK.
- The DEK itself is wrapped with the KEK (also AES-256-GCM).
- The wire format carries a magic prefix, a KEK identifier, the nonce, the wrapped DEK, and the ciphertext.
This shape is chosen so that:
- **KEK rotation is cheap.** Rotating the KEK only re-wraps the per-row DEKs; the payload ciphertexts don't have to be re-encrypted.
- **KMS-backed sealing stays affordable.** A per-request kms:Encrypt/Decrypt for a ~32-byte DEK is the canonical KMS usage pattern; encrypting whole session payloads via KMS would blow through the request/s quota and add 20-50ms per call.
- **Multiple KEKs can coexist.** `kek_id` in the header lets old rows unwrap with the old key while new writes use the new one. (There is no job that re-wraps old rows yet: a row stays under its KEK until it is next written, so retired KEKs must stay loaded; see NewEnvSealerWithRotation.)
- **Cross-tenant ciphertext swaps fail.** The user ID is bound into GCM's additional-authenticated-data, so a blob stolen from user A's row and pasted into user B's row fails to open.
What this package does NOT do ¶
- It does not protect secrets in memory. A process- compromise attacker still sees plaintext in RAM. The threat model here is database-at-rest (DB dump, offsite backup, compromised replica).
- It does not store the KEK. That's delivered by the environment (env var or KMS) and swapped in via the Sealer implementation.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrSealedBlob = errors.New("crypto: sealed blob did not open")
ErrSealedBlob is returned when a supposedly sealed blob fails any validation — wrong magic, truncated header, KEK mismatch, authentication failure, or user-ID mismatch. The exact cause is intentionally not exposed (it'd help an attacker pin down which invariant they're tripping); only "the blob did not open".
Functions ¶
This section is empty.
Types ¶
type EnvSealer ¶
type EnvSealer struct {
// contains filtered or unexported fields
}
EnvSealer is the phase-1 / phase-2 Sealer. It reads its KEK from an environment variable at construction time. The KEK is 32 raw bytes, base64-standard-encoded; anything shorter is rejected — AES-128 is not offered because there's no legitimate reason to pick it over AES-256 in 2026.
Rotation: hold two EnvSealers, one under the old KEK id and one under the new, and dispatch Open by reading the blob's kek_id header. NewEnvSealerFromEnv supports that by accepting a list of env-var names; the first non-empty one wins for Seal, but all of them are tried for Open.
func NewEnvSealer ¶
NewEnvSealer builds a Sealer from an explicit kekID + raw 32- byte KEK. Most code should call NewEnvSealerFromEnv instead; this constructor is here for tests and for the rotation tooling.
func NewEnvSealerFromEnv ¶
NewEnvSealerFromEnv builds an EnvSealer by reading KEKs from the process environment.
primaryVar is the env var holding the primary KEK (used for new Seal calls). rotationVars are earlier KEKs still required to Open existing rows during a rotation window; they can be empty. Each value is "<kekID>:<base64-KEK>" — the colon splits a short operator-meaningful label (e.g. "v1", "v2") from the 32 random bytes. The label goes into the blob header; the bytes never leave the process.
Startup-hard: a missing or malformed primary is a fatal configuration error. The caller is expected to refuse to boot.
func NewEnvSealerWithRotation ¶ added in v0.37.5
NewEnvSealerWithRotation builds a sealer from the primary KEK and the retired KEKs it must still open, all given as VALUES in the "<id>:<base64>" format (what RIVOLT_KEK and RIVOLT_KEK_ROTATION hold). A malformed primary or retired entry is an error, so a bad rotation list refuses to boot instead of silently leaving rows sealed under the old key unreadable. Blank entries and a retired key repeating the primary's id are skipped; two different retired keys with the same id are an error (one would shadow the other).
func (*EnvSealer) KEKID ¶
KEKID implements Sealer. Used by the sealer caller to stamp the user_secrets row with the KEK that protects it.
func (*EnvSealer) KEKIDs ¶ added in v0.37.5
KEKIDs returns the id of the KEK new data is sealed under and the ids of the retired ones still loaded (never the keys), sorted, for the boot log.
type NoopSealer ¶
type NoopSealer struct{}
NoopSealer is an identity sealer — Seal returns plaintext unchanged, Open returns ciphertext unchanged, KEKID is "noop". It exists for tests and for the dev server's stub mode where disk encryption is explicitly not required. **Never use in production.**
type Sealer ¶
type Sealer interface {
// Seal encrypts plaintext and returns a self-describing
// ciphertext blob. The userID is bound into the AEAD
// additional-authenticated-data so a ciphertext stolen
// from one user's row cannot be opened against another.
Seal(ctx context.Context, userID uuid.UUID, plaintext []byte) ([]byte, error)
// Open reverses Seal. Any failure — bad magic, wrong KEK,
// wrong user, tampered ciphertext — returns
// ErrSealedBlob. The original cause is not surfaced.
Open(ctx context.Context, userID uuid.UUID, ciphertext []byte) ([]byte, error)
// KEKID identifies the KEK used for new writes. Stored
// alongside each ciphertext so the rotation job can find
// rows to re-wrap and so audit queries can report which
// key protects which secret. Must be stable across
// restarts for a given KEK.
KEKID() string
}
Sealer is the surface the rest of the codebase programs against. Implementations differ in where the KEK lives; everything above the interface stays the same.
Round-trip contract: for any sealer S, plaintext p, user u, and context ctx, S.Open(ctx, u, S.Seal(ctx, u, p)) must return p. A different user, a different KEK, or a single flipped ciphertext bit must return ErrSealedBlob.