Documentation
¶
Overview ¶
Package seal provides envelope encryption for sensitive columns in the unified application database.
Threat model: protect long-lived signing keys (the OAuth2 / IDP issuer's RSA private key, fosite's HMAC GlobalSecret) from leaking when the SQLite file is exfiltrated — backups copied off the PVC, container debug shells, snapshot leaks. Once the master KEK is on a separately-mounted secret, raw access to the DB is no longer equivalent to "forge tokens for any user."
Design — envelope encryption:
- The operator provides a master Key Encryption Key (KEK) by pointing HTTP_API_KEK_FILE at a 32-byte secret. HTCondor config values are considered public, so the file path goes in config but the bytes don't.
- On first use we generate a 32-byte salt and persist it in the DB's kek_metadata table. The salt is non-secret — the master KEK is what protects everything.
- The DB-instance KEK is HKDF-SHA256(masterKEK, salt, "htcondor-api/db-kek/v1"). This binds the derived key to the specific DB file even if the master KEK is reused across deployments, and gives us a clean rotation handle (re-derive when info or salt change) without exposing the master.
- Each row gets a fresh random 32-byte Data Encryption Key (DEK). The DEK is wrapped under the DB KEK with AES-256-GCM; the data is encrypted with the DEK, also AES-256-GCM. The wrapped DEK lives in a sibling `_dek` column so an operator inspecting the DB with sqlite3 can immediately tell which rows are encrypted.
Both ciphertext blobs include a 1-byte version tag so we can migrate to a different cipher suite later without ambiguity.
Operator-managed KEK file: never auto-created ¶
This package will never create or write to the KEK file. If LoadMasterKEKFromFile is asked to load a path that doesn't exist, it returns an error (with a generation hint) and refuses to start. Auto-creating the file would be a footgun in containerised deployments: a freshly-generated KEK on an emptyDir / unmounted path would silently invent a new key on every restart, the derived DB KEK would no longer match what's in kek_metadata, and every wrapped DEK in the DB would become un-openable — losing the OAuth2 / IDP signing keys + every encrypted secret on the next container start. The operator MUST provide the file out-of-band (k8s Secret, sealed-secret, vault csi driver, etc.) and stage it as a stable mount.
Index ¶
Constants ¶
const ( // MasterKEKBytes is the required raw master KEK length. We // accept either exactly this many raw bytes, or a hex-encoded // string that decodes to this many bytes. 32 bytes ≡ AES-256. MasterKEKBytes = 32 // SaltBytes is the length of the per-DB salt threaded into HKDF. // 32 bytes is overkill for HKDF salt but uniform with the keys. SaltBytes = 32 )
Variables ¶
This section is empty.
Functions ¶
func DeriveDBKey ¶
DeriveDBKey derives the DB-instance KEK from the master + a per-DB salt using HKDF-SHA256. Pure function; both inputs are required.
func LoadMasterKEKFromFile ¶
LoadMasterKEKFromFile reads the master KEK from path. The file must contain exactly MasterKEKBytes raw bytes, or a hex-encoded string that decodes to that length. Trailing whitespace / newlines in the hex form are tolerated — `openssl rand -hex 32 > kek` is the recommended generation recipe.
Refuses to read a file with any world (other) bits set. Group bits are tolerated because kubelet applies `fsGroup` to Secret/ConfigMap volume mounts and forces group-read (turning a 0400 file into 0440); the "group" in that mount is the pod's own fsgroup, not a multi-tenant boundary. World-readable, however, is always wrong: it means anyone on the host (or in another container sharing the namespace) can read the KEK, which is equivalent to a leaked DB.
This function NEVER creates the file. If the path doesn't exist the call returns an explicit error including the openssl recipe for the operator. Auto-generating a missing KEK would be unsafe in container deployments where the file location may not be persisted across restarts; see the package doc for the full rationale.
Types ¶
type Sealer ¶
type Sealer struct {
// contains filtered or unexported fields
}
Sealer wraps the DB-instance KEK with AES-256-GCM for envelope encryption. Construct once at startup; the underlying cipher.AEAD is goroutine-safe so callers can share a Sealer across requests.
func (*Sealer) Open ¶
Open is the inverse of Seal. Returns the original plaintext. Authentication failure (wrong KEK, tampering) surfaces as a non-nil error.
func (*Sealer) Seal ¶
Seal returns the ciphertext for `plaintext` and the wrapped per-row DEK, suitable for storing in adjacent columns. Both blobs include a 1-byte version tag so we can migrate cipher suites later.
The wrapped-DEK blob layout is:
1 byte: sealVersion 12 bytes: nonce 48 bytes: AES-GCM(DBKey, dek) ← 32 plaintext + 16 tag
The data blob layout is:
1 byte: sealVersion 12 bytes: nonce N+16 bytes: AES-GCM(dek, plaintext)