seal

package
v0.0.12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 14, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

View Source
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

func DeriveDBKey(masterKEK, salt []byte) ([]byte, error)

DeriveDBKey derives the DB-instance KEK from the master + a per-DB salt using HKDF-SHA256. Pure function; both inputs are required.

func LoadMasterKEKFromFile

func LoadMasterKEKFromFile(path string) ([]byte, error)

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.

func NewSalt

func NewSalt() ([]byte, error)

NewSalt returns SaltBytes of cryptographically-random data, suitable for persisting in the kek_metadata table.

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 New

func New(dbKEK []byte) (*Sealer, error)

New constructs a Sealer from a 32-byte DB-instance KEK.

func (*Sealer) Open

func (s *Sealer) Open(data, wrappedDEK []byte) ([]byte, error)

Open is the inverse of Seal. Returns the original plaintext. Authentication failure (wrong KEK, tampering) surfaces as a non-nil error.

func (*Sealer) Seal

func (s *Sealer) Seal(plaintext []byte) (data, wrappedDEK []byte, err error)

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)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL