Documentation
¶
Overview ¶
Package encryption implements the AES-256-GCM envelope encryption scheme described in docs/design/2026_04_29_proposed_data_at_rest_encryption.md §4.
Stage 0 (foundation) provides the primitive Encrypt/Decrypt operations, the wire format envelope encoder/decoder, and the in-memory keystore. Composition of AAD bytes for storage-layer envelopes (§4.1) and raft-layer envelopes (§4.2) is the responsibility of callers in store/ and internal/raftengine/etcd/, added in later stages.
Wire format (§4.1):
+--------+------+---------+----------+-----------+--------+ | 0x01 | flag | key_id | nonce | ciphertext| tag | | 1 byte | 1 B | 4 bytes | 12 bytes | N bytes | 16 B | +--------+------+---------+----------+-----------+--------+
Per-value overhead is 34 bytes (HeaderSize + TagSize).
Index ¶
- Constants
- Variables
- func AppendHeaderAADBytes(dst []byte, version, flag byte, keyID uint32) []byte
- func BuildRaftAAD(version byte, keyID uint32) []byte
- func HeaderAADBytes(version, flag byte, keyID uint32) []byte
- func IsNotExist(err error) bool
- func UnwrapRaftPayload(c *Cipher, encoded []byte) ([]byte, error)
- func WrapRaftPayload(c *Cipher, keyID uint32, nonce, payload []byte) ([]byte, error)
- func WriteSidecar(path string, sc *Sidecar) (retErr error)
- type ActiveKeys
- type Cipher
- type Envelope
- type Keystore
- func (k *Keystore) AEAD(keyID uint32) (cipher.AEAD, bool)
- func (k *Keystore) DEK(keyID uint32) ([KeySize]byte, bool)
- func (k *Keystore) Delete(keyID uint32)
- func (k *Keystore) Has(keyID uint32) bool
- func (k *Keystore) IDs() []uint32
- func (k *Keystore) Len() int
- func (k *Keystore) Set(keyID uint32, dek []byte) error
- type Sidecar
- type SidecarKey
Constants ¶
const ( // EnvelopeVersionV1 is the current envelope format version. §11.3 // reserves 0x02..0x0F for future authenticated formats. The current // build only understands 0x01; ANY other version byte (including the // 0x02..0x0F reserved range) causes DecodeEnvelope to return // ErrEnvelopeVersion. Future decoders that know how to handle the // reserved range will widen this check. EnvelopeVersionV1 byte = 0x01 // FlagCompressed (bit 0) is set when ciphertext encrypts a Snappy- // compressed plaintext (§6.4). The flag participates in the AAD so a // post-hoc bit-flip is rejected by GCM verification. FlagCompressed byte = 1 << 0 // KeySize is the AES-256 key length in bytes. KeySize = 32 // NonceSize is the AES-GCM standard nonce size in bytes. NonceSize = 12 // TagSize is the AES-GCM authentication tag size in bytes. TagSize = 16 // HeaderAADSize covers version + flag + key_id (the bytes that // participate in storage AAD, distinct from the full envelope header // which also carries nonce). Exposed as the input length of // HeaderAADBytes. HeaderAADSize = versionBytes + flagBytes + keyIDBytes // 6 // HeaderSize covers version + flag + key_id + nonce, in that order. HeaderSize = HeaderAADSize + NonceSize // 18 // EnvelopeOverhead is the per-value byte overhead introduced by the // envelope: HeaderSize + TagSize. EnvelopeOverhead = HeaderSize + TagSize // 34 // ReservedKeyID is the cluster-wide "not bootstrapped" sentinel // (§5.1). Implementations MUST refuse to install or look up this // key_id. ReservedKeyID uint32 = 0 )
Public constants for the §4.1 wire format.
const ( SidecarPurposeStorage = "storage" SidecarPurposeRaft = "raft" )
SidecarPurposeStorage / SidecarPurposeRaft are the only purposes the reader recognises. Stage 6 may add more.
const RaftAADPurpose byte = 'R' // 0x52
RaftAADPurpose is the literal byte 'R' (0x52) that prefixes the raft-envelope AAD per design §4.2. It distinguishes a raft envelope from a storage envelope: a storage-layer ciphertext replayed into the raft layer (or the reverse) fails GCM verification because the AAD prefix does not match.
const SidecarFilename = "keys.json"
SidecarFilename is the standard filename inside <dataDir>/encryption/.
const SidecarTmpFilename = SidecarFilename + ".tmp"
SidecarTmpFilename is the filename used for the §5.1 crash-durable write protocol's intermediate write.
const SidecarVersion = 1
SidecarVersion is the wire version of the on-disk sidecar JSON.
Version 1 carries the §5.1 layout (active, keys, raft_applied_index, storage_envelope_active, raft_envelope_cutover_index). Future versions extend the layout via additive JSON fields plus a bump here; mismatched versions are rejected at read time so an older binary cannot silently drop fields it does not understand.
Variables ¶
var ( // ErrUnknownKeyID is returned when a wrap/unwrap call references a key_id // that is not present in the Keystore. Surfaces as `unknown_key_id` on // the §9.2 elastickv_encryption_decrypt_failures_total counter. ErrUnknownKeyID = errors.New("encryption: unknown key_id") // ErrReservedKeyID is returned when a caller tries to install or use // key_id 0; that value is reserved cluster-wide as the // "not bootstrapped" sentinel per §5.1. ErrReservedKeyID = errors.New("encryption: key_id 0 is reserved as the not-bootstrapped sentinel") // ErrBadNonceSize indicates the nonce passed to Encrypt/Decrypt was not // exactly NonceSize bytes. ErrBadNonceSize = errors.New("encryption: nonce size invalid") // ErrBadKeySize indicates the DEK passed to Keystore.Set was not exactly // KeySize bytes (AES-256 requires 32). ErrBadKeySize = errors.New("encryption: DEK size invalid") // ErrIntegrity indicates a GCM tag mismatch on Decrypt — i.e., the // ciphertext was tampered with, the AAD does not match the one used at // Encrypt, or the wrong DEK is loaded. Per §4.1, callers MUST treat this // as a typed read error and never silently zero or retry. ErrIntegrity = errors.New("encryption: integrity check failed (GCM tag mismatch)") // ErrEnvelopeShort indicates DecodeEnvelope received fewer bytes than the // minimum envelope size (HeaderSize + TagSize). ErrEnvelopeShort = errors.New("encryption: envelope shorter than header+tag") // ErrEnvelopeVersion indicates DecodeEnvelope saw a version byte the // current build does not know how to parse. Reserved values per §11.3. ErrEnvelopeVersion = errors.New("encryption: unknown envelope version") // ErrNilKeystore indicates NewCipher was called with a nil Keystore. // Surfaced at construction time so a wiring mistake is caught // before the first Encrypt/Decrypt would otherwise nil-deref panic. ErrNilKeystore = errors.New("encryption: keystore is nil") // ErrKeyConflict indicates Keystore.Set was called with a keyID // already loaded under DIFFERENT key material. Replacing live key // bytes for an in-use key_id would render every envelope already // persisted under that id undecryptable, so Set fails closed // rather than silently overwriting. Set with the SAME bytes is // idempotent (returns nil) and does not raise this error. ErrKeyConflict = errors.New("encryption: key_id already loaded with different key material") // ErrUnsupportedFilesystem indicates the parent directory of the // sidecar cannot guarantee crash-durability of os.Rename via // fsync (typical on NFS, some FUSE mounts). Per §5.1 the // encryption package refuses to start in that situation rather // than silently degrading the durability guarantee. WriteSidecar // wraps any fsync-on-directory failure with this sentinel so the // Stage 5+ startup integration can errors.Is-match it. ErrUnsupportedFilesystem = errors.New("encryption: filesystem does not support durable directory sync (NFS, some FUSE mounts are unsupported)") // ErrSidecarActiveKeyMissing indicates the Sidecar has a non-zero // Active.{Storage,Raft} key_id that does not appear in the Keys // map. The two halves are written together by every rotation / // bootstrap path; an Active id without a corresponding wrapped // DEK is malformed input. ErrSidecarActiveKeyMissing = errors.New("encryption: sidecar active key_id has no entry in keys map") // ErrSidecarActivePurposeMismatch indicates the Sidecar has a // non-zero Active.{Storage,Raft} key_id pointing to a Keys entry // whose Purpose does not match the slot. e.g., active.storage=7 // but Keys["7"].purpose == "raft". Crossed pointers would route // the wrong DEK into a purpose-specific encryption path after // restart or rotation, so the reader fails closed. ErrSidecarActivePurposeMismatch = errors.New("encryption: sidecar active key_id references a key with mismatched purpose") )
var ( // ErrSidecarVersion indicates ReadSidecar saw a wire version this // build does not know how to parse. Use the message and the offending // version to decide whether to upgrade the binary or fall back. ErrSidecarVersion = errors.New("encryption: unsupported sidecar version") // ErrSidecarPurpose indicates a Sidecar.Keys entry has a "purpose" // field outside the recognised set ({"storage","raft"}). The reader // fails closed rather than silently treating an unknown purpose as a // known one — a typo'd or future-version sidecar must be the // operator's explicit upgrade decision. ErrSidecarPurpose = errors.New("encryption: unsupported sidecar key purpose") // ErrSidecarKeyIDFormat indicates a Sidecar.Keys map key was not a // decimal uint32 string per §5.1. ErrSidecarKeyIDFormat = errors.New("encryption: sidecar key_id is not a decimal uint32") // ErrSidecarReservedKeyID indicates a Sidecar.Keys map carries // key_id 0, which §5.1 reserves as the "not bootstrapped" sentinel. // On-disk presence of 0 in the keys map is malformed input. ErrSidecarReservedKeyID = errors.New("encryption: sidecar key_id 0 is reserved") )
Errors returned by sidecar I/O.
Functions ¶
func AppendHeaderAADBytes ¶
AppendHeaderAADBytes appends the same 6-byte header prefix (version, flag, key_id) onto dst and returns the extended slice. Allocation-free when dst already has HeaderAADSize spare capacity, which lets storage callers in later stages write the AAD directly into a pooled buffer alongside the per-record context (e.g., pebble_key) without an intermediate make().
func BuildRaftAAD ¶
BuildRaftAAD composes the §4.2 raft-envelope AAD: a single-byte purpose tag ('R'), the envelope version, and the 4-byte big-endian key_id. Exposed for tests; production callers go through WrapRaftPayload / UnwrapRaftPayload.
func HeaderAADBytes ¶
HeaderAADBytes returns the first 6 bytes of the envelope header (version, flag, key_id) in their on-disk order. These bytes participate in the §4.1 storage-layer AAD (storage AAD = HeaderAADBytes ‖ pebble_key) and in the §4.2 raft-layer AAD's middle slice (raft AAD = "R" ‖ version ‖ key_id, computed by raft-layer callers in a later stage).
Allocates HeaderAADSize bytes. Hot-path callers should prefer AppendHeaderAADBytes to reuse a buffer.
func IsNotExist ¶
IsNotExist reports whether err is a "sidecar file does not exist" error from ReadSidecar. Provided as a convenience so callers can branch on first-boot vs. malformed sidecar without unwrapping the fs.PathError manually.
func UnwrapRaftPayload ¶
UnwrapRaftPayload reverses WrapRaftPayload. Decodes the envelope, rebuilds the AAD identically, and calls Decrypt. The same `*Cipher` instance used at wrap time must hold the embedded keyID (or one of its rotated successors) for unwrap to succeed.
Surfaces typed errors callers can disambiguate via errors.Is:
- ErrEnvelopeShort: encoded shorter than HeaderSize+TagSize
- ErrEnvelopeVersion: unknown version byte
- ErrUnknownKeyID: DEK is not loaded (retired or sidecar missing)
- ErrIntegrity: GCM tag mismatch (tampered envelope, wrong DEK, or layer confusion with a storage envelope)
A storage envelope fed to UnwrapRaftPayload fails with ErrIntegrity because the storage AAD prefix ('envelope_version ‖ flag ‖ key_id ‖ value_header(9B) ‖ pebble_key') does not start with the raft-purpose byte 'R'.
func WrapRaftPayload ¶
WrapRaftPayload wraps payload in a §4.2 raft envelope under the DEK identified by keyID, using the supplied 12-byte nonce. The cipher must already hold the keyID under the "raft" purpose (the keystore itself does not enforce purpose — that contract is maintained by the sidecar loader).
The flag byte is fixed at 0x00; raft proposals do not carry the Snappy compression bit (the apply path is latency-sensitive and proposals are small / high-entropy).
Nonce uniqueness is the caller's responsibility: re-using a (keyID, nonce) pair under the same DEK is a catastrophic AES-GCM failure (key-recovery + plaintext XOR). The §4.2 deterministic nonce construction (`node_id ‖ local_epoch ‖ write_count`) guarantees uniqueness by construction; do not substitute a different scheme without an equivalent uniqueness proof.
func WriteSidecar ¶
WriteSidecar persists sc to path using the §5.1 crash-durable write protocol:
- Build the new contents in memory (sc.Version is set to SidecarVersion so the caller never has to remember).
- Write to <path>.tmp, then file.Sync().
- os.Rename(<path>.tmp, <path>).
- dir.Sync() on the parent directory so the rename is durable.
Skipping step 2 or 4 is a data-loss-class bug: a power loss between the rename and the directory inode flush can roll back keys.json while the rotation's Raft entry is already committed, stranding ciphertext under a wrap that has effectively disappeared. Per §5.1 this is treated as a hard precondition, not an optimisation.
The temp file is created with mode 0o600 so a stale tmp left behind after a crash is not world-readable.
Types ¶
type ActiveKeys ¶
ActiveKeys holds the active key_id per envelope purpose. A zero value (== ReservedKeyID) means "not bootstrapped" per §5.1.
type Cipher ¶
type Cipher struct {
// contains filtered or unexported fields
}
Cipher is the AES-256-GCM primitive over a Keystore.
Cipher does NOT compose AAD — callers in store/ (§4.1 AAD) and internal/raftengine/etcd/ (§4.2 AAD) supply the full AAD bytes. This keeps the cipher narrow and lets each layer choose the right AAD without baking storage/raft assumptions into the foundation package.
AES key expansion and GCM initialization happen once per DEK at Keystore.Set time; the hot path only needs an atomic.Pointer load and a Seal/Open call.
The zero value is NOT safe to use: Encrypt/Decrypt return ErrNilKeystore for a zero-value or nil Cipher rather than nil-deref panicking. Always construct via NewCipher.
func NewCipher ¶
NewCipher returns a Cipher backed by ks.
Returns ErrNilKeystore if ks is nil. Catching this at construction time turns a wiring mistake into a typed error during process startup or DEK rotation, rather than a nil-deref panic on the first Encrypt/Decrypt — important for the dynamic dependency-wiring paths where the encryption stack may be re-initialised after a sidecar resync (§5.5) or rotation (§5.2).
func (*Cipher) Decrypt ¶
Decrypt verifies and decrypts (ciphertext ‖ tag) using the DEK identified by keyID, the supplied nonce, and the same aad bytes that were passed to Encrypt.
On GCM tag mismatch, Decrypt returns an error wrapping ErrIntegrity. Per §4.1, callers MUST treat this as a typed read error and never silently zero or retry. The original Open error is attached as a secondary cause for diagnostic logging.
func (*Cipher) Encrypt ¶
Encrypt produces (ciphertext ‖ tag) for plaintext under the DEK identified by keyID and the supplied nonce. aad is treated verbatim.
Constraints:
- keyID must not be ReservedKeyID; otherwise ErrReservedKeyID.
- nonce must be NonceSize bytes; otherwise ErrBadNonceSize.
- keyID must be present in the Keystore; otherwise ErrUnknownKeyID.
CRITICAL: callers MUST NOT reuse the same (keyID, nonce) pair with any two distinct plaintexts. Nonce reuse under AES-GCM is catastrophic: it leaks the XOR of the two plaintexts and enables authentication-key recovery. The §4.1 storage-layer integration uses the nonce construction (node_id ‖ local_epoch ‖ write_count) to guarantee uniqueness by construction; do not substitute a different nonce scheme in that layer without a corresponding uniqueness proof. (For tests / benchmarks, fresh crypto/rand nonces are perfectly safe.)
The returned slice has length len(plaintext) + TagSize. It is freshly allocated; the caller may retain it indefinitely.
func (*Cipher) LoadedKeyIDs ¶
LoadedKeyIDs returns the sorted list of key_ids currently loaded in the underlying keystore. Used by the storage layer's rebadge guard to trial-decrypt a cleartext-labelled body against every candidate DEK — rotation leaves multiple DEKs active at once, and the on-disk envelope's key_id field can be rewritten by an attacker, so the guard must iterate rather than trust it.
Returns nil for a nil receiver or zero-value Cipher; callers MUST NOT treat that as "no keys" without considering the surrounding context.
type Envelope ¶
type Envelope struct {
Version byte
Flag byte
KeyID uint32
Nonce [NonceSize]byte
// Body is the concatenation of ciphertext and the GCM tag, as produced
// by AEAD.Seal. Length is plaintext_len + TagSize.
Body []byte
}
Envelope is the parsed form of the §4.1 wire format.
func DecodeEnvelope ¶
DecodeEnvelope parses an envelope. It does NOT verify the GCM tag — authentication happens at Cipher.Decrypt time once the AAD is known.
DecodeEnvelope copies Body so the returned Envelope does not alias src.
func (*Envelope) Encode ¶
Encode serialises the envelope into a single byte slice using the §4.1 wire format. The returned slice is freshly allocated.
Encode validates the envelope at build time so a programmer error (uninitialised Version, truncated Body) fails here with a clear stack trace, rather than surfacing later as a confusing DecodeEnvelope or Cipher.Decrypt failure on the read side. Returns:
- ErrEnvelopeVersion if Version is not EnvelopeVersionV1.
- ErrEnvelopeShort if Body is shorter than TagSize (every valid body must contain at least the GCM tag).
type Keystore ¶
type Keystore struct {
// contains filtered or unexported fields
}
Keystore is a copy-on-write map from key_id to (DEK, pre-init AEAD).
Reads on the hot path take a single atomic load and observe an immutable snapshot of the map. Writes (rotation, bootstrap, retire) allocate a new map and CAS it in via atomic.Pointer.Store.
Per §10 self-review lens 2: this avoids contending a mutex on the hot path while keeping rotation atomic with respect to readers.
Zero-value safety: a `var ks Keystore` (or a nil *Keystore) is degraded but does not panic — read methods (AEAD, DEK, Has, IDs, Len) treat it as the empty keystore, Delete is a no-op, and Set returns ErrNilKeystore for a nil receiver. Always prefer NewKeystore so an unwrap path that needs to install keys reports the wiring mistake immediately.
func (*Keystore) AEAD ¶
AEAD returns the pre-initialized cipher.AEAD for keyID, ready for Seal/Open. The returned value is safe for concurrent use by multiple goroutines (Go stdlib AEAD implementations are stateless after initialization).
Used by Cipher.Encrypt / Cipher.Decrypt on the hot path. Returns (nil, false) if keyID is not loaded, the receiver is nil, or the Keystore is zero-valued.
func (*Keystore) DEK ¶
DEK returns the raw 32-byte DEK for keyID. The returned array is a value copy — callers are free to mutate it without affecting the keystore. The bool reports whether keyID is loaded.
Most call sites should use AEAD instead; DEK is provided for the rotation / rewrap path that needs the raw key material to wrap it under a new KEK.
func (*Keystore) Delete ¶
Delete removes the DEK for keyID. No-op if absent, the receiver is nil, or the Keystore is zero-valued (no map ever Stored).
func (*Keystore) IDs ¶
IDs returns a sorted snapshot of all currently-loaded key_ids. Returns nil for a nil receiver or zero-value Keystore.
func (*Keystore) Len ¶
Len reports the number of currently-loaded keys. Returns 0 for a nil receiver or zero-value Keystore.
func (*Keystore) Set ¶
Set installs a DEK under keyID and pre-initializes the cipher.AEAD. dek must be exactly KeySize bytes; the reserved key_id 0 is rejected with ErrReservedKeyID. The DEK bytes are copied into the keystore so the caller is free to zero or reuse the source slice.
Set is set-once with idempotent-same semantics: re-Set under an existing keyID with byte-identical DEK is a no-op (returns nil), but Set with DIFFERENT bytes for an already-loaded keyID returns ErrKeyConflict. Replacing live key bytes for a keyID would render every envelope already persisted under that id undecryptable.
A nil receiver returns ErrNilKeystore; zero-value Keystores are rejected at the same boundary as Cipher.
type Sidecar ¶
type Sidecar struct {
Version int `json:"version"`
RaftAppliedIndex uint64 `json:"raft_applied_index"`
StorageEnvelopeActive bool `json:"storage_envelope_active"`
RaftEnvelopeCutoverIndex uint64 `json:"raft_envelope_cutover_index"`
Active ActiveKeys `json:"active"`
// Keys is keyed by the decimal string form of key_id (per §5.1's
// "JSON object keys must be strings, but the on-disk envelope and
// the in-memory keystore always work in the binary uint32 form").
Keys map[string]SidecarKey `json:"keys"`
}
Sidecar is the parsed §5.1 keys.json layout.
All fields persisted under the §5.1 illustrative JSON are represented here. Fields not yet present in the design (Stage 9 audit log, etc.) are omitted; they will be added as additive fields when the relevant stage ships.
func ReadSidecar ¶
ReadSidecar parses the keys.json file at path. It validates the wire version, the per-key purpose, and the decimal-uint32 form of every keys-map entry, and rejects malformed sidecars with typed errors.
ReadSidecar does NOT KEK-unwrap the DEK bytes — it just hands the caller a parsed struct. Wrapping is the kek.Wrapper's job at a higher layer.
type SidecarKey ¶
type SidecarKey struct {
Purpose string `json:"purpose"`
Wrapped []byte `json:"wrapped"`
Created string `json:"created"`
LocalEpoch uint16 `json:"local_epoch"`
}
SidecarKey holds the metadata for a single wrapped DEK.
Wrapped is the KEK-wrapped DEK bytes (encoding/json base64-encodes []byte automatically). Created is an ISO-8601 timestamp string; the package keeps it as a plain string rather than time.Time so a future timezone-format addition does not break older readers. LocalEpoch is consumed by the §4.1 nonce construction.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package kek implements KEK (Key Encryption Key) providers that wrap and unwrap DEKs (Data Encryption Keys) per §5.1 of the data-at-rest encryption design.
|
Package kek implements KEK (Key Encryption Key) providers that wrap and unwrap DEKs (Data Encryption Keys) per §5.1 of the data-at-rest encryption design. |