cek

package
v1.801.360 Latest Latest
Warning

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

Go to latest
Published: Aug 2, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package cek is cloud's ONE encryption-at-rest gate for its per-subsystem SQLite stores. Every store opens its database through cek.Open — the single seam where a plaintext file is transparently migrated to SQLCipher and a keyed *sql.DB is returned — so "encrypted at rest" is a property of the open path, not something each of the ~30 stores must remember to do.

THREAT MODEL. The DO block volume under /var/lib/cloud is provider-encrypted, so the residual exposure is a COPIED PV snapshot/backup or an in-cluster exec/PV read seeing plaintext customer PII (crm), the ledger (treasury), org wallet maps (wallets), the audit log, team/entitlements. cek removes that exposure: each file's pages are SQLCipher-encrypted under a per-database key that never leaves the process in the clear and is itself wrapped by the KMS-injected master key; the pre-migration plaintext copy is shredded once the encrypted store is proven readable. A lifted file is useless without the key.

SCOPE / NON-GOALS. cek provides CONFIDENTIALITY at rest against the read-only exposure above (no master key ⇒ no plaintext). It is NOT integrity, authenticity, or anti-rollback against a PV-WRITE (node-compromise) adversary who can modify the volume: the per-file id lives in the (unauthenticated) .dek sidecar, so such an adversary could still replay an old snapshot of a store in place. Swapping two stores ACROSS tenants no longer works — the owner is in the KEK and the AAD, so a pair carried into another org's directory fails to unwrap (Principal). Rollback of a store onto itself remains out of model; it needs an epoch, which the sidecar does not carry.

ENVELOPE (the primitives live in github.com/hanzoai/sqlite/cek.go and are reused verbatim — one crypto implementation, KAT-gated there):

  • Each database has its OWN random 256-bit DEK (the SQLCipher page key), minted once at first touch and NEVER changed, so ciphertext pages are never rewritten.

  • Each database also gets a random 128-bit FILE ID, stored in the clear at the head of its <db>.dek sidecar. The KEK derives from the OWNER and that id, never from the file path:

    KEK = HKDF-SHA256(masterKey, lp(type) || lp(owner || "/" || hex(fileID)))

    for a tenant store, and lp("global") || lp(hex(fileID)) for the platform partition, which has no owner. Because the path is absent, moving the data dir or changing CLOUD_DATA_DIR can never change a KEK or brick a store; because the owner is present, a {db,.dek} pair carried into another tenant's directory fails to unwrap rather than opening. RFC-5869 HKDF via x/crypto/hkdf — NOT luxfi/crypto/kdf (a QZMQ KeySchedule, not generic HKDF; using it would brick every store).

  • The DEK is wrapped AES-256-GCM under the KEK, bound to the same id as AAD. Sidecar = fileID(16) || wrapped-DEK. The raw DEK is never written.

  • Master-key ROTATION rewraps only the sidecar: the DEK and fileID are unchanged, so no page is rewritten and no file can be bricked.

FAIL-SECURE. On an encryption-CAPABLE build (production is CGO + libsqlcipher) a missing master key is FATAL — the data plane refuses to open unencrypted, the same posture the KMS store takes. A key set on a NON-encrypting build is likewise fatal. An encrypted file whose sidecar is missing is refused. A migration whose encrypted copy does not reproduce the source's schema AND per-table content (a rowid-independent multiset hash, not just a row count) leaves the plaintext untouched and errors — the caller (MountAll) fails closed, so cloud never serves a half-migrated data plane.

rewrap.go — the one-time migration "a store's key names its owner" needed and did not ship with.

That change (cek: a store's key names its owner) started deriving a store's KEK from (principal, fileID) instead of fileID alone. Every sidecar written before it was wrapped under Global, so every pre-existing ORG store stopped opening the moment the new derivation landed — reported as

cek: unwrap DEK (wrong master key or corrupt sidecar)

which is true and misleading: the master key is right and the sidecar is intact. Only the identity the KEK derives from moved.

Rewrap moves ONE sidecar forward: unwrap the DEK under the legacy principal, re-wrap the SAME DEK under the store's real owner, write it atomically. The DEK never changes, so the database's pages are untouched — this rewrites the wrapper and nothing else.

It is NOT a compatibility shim. There is exactly one derivation — the owner-bound one — and this walks the old world into it once. A store that already opens under its owner is left alone; a store that opens under NEITHER identity is reported and skipped, never "repaired" by minting a fresh DEK, because a new DEK would answer every future open with plausible garbage instead of an error.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotBound = fmt.Errorf("cek: store not bound to the source principal")

ErrNotBound reports that a store did not unwrap under the principal it was asked to move FROM — most often because it is already bound to the target, which makes a re-run a no-op rather than a failure.

View Source
var Global = Principal{/* contains filtered or unexported fields */}

Global is the cross-org platform partition: certs, providers, the audit trail, the gateway's own store. Its id is fixed, so a platform store's key derives from the file id alone exactly as it always has — the platform partition is not a tenant and has no owner to bind to.

Functions

func Encrypting

func Encrypting() bool

Encrypting reports whether cek will encrypt at rest (a valid master key is configured). cloud calls this once at boot for the posture log; a false result means resolveMaster errored (no or invalid key) and the first store Open will fail closed rather than write plaintext.

func EnsureDevKey added in v1.801.231

func EnsureDevKey() bool

EnsureDevKey installs a deterministic development master key when NO key is configured AND the live codec is not linked — i.e. a pure-Go dev/CI build. It lets that build run through the SAME encrypted path as production (per-db DEK, SQLCipher-format envelope) with zero configuration, instead of a divergent plaintext path. It is a NO-OP when a key is already configured (production uses it) or the live codec is linked (a production binary, which MUST supply the real KMS key and fails closed without it). Call once at boot, before the first Open. Returns true when a dev key was installed. Not safe against a concurrent Open — resolveMaster caches on first use, so this must run first.

func Exists added in v1.801.231

func Exists(path string) bool

Exists reports whether a store lives at path — the question "has this org/ subsystem been created yet?", asked by every caller that discovers stores by walking the data directory.

os.Stat on the database file alone does NOT answer it. The pure-Go codec holds the database in its envelope and materializes the file on close, so a store that is OPEN right now has only its sidecar on disk; the live codec writes the database in place and has both. A caller that stats only the database therefore sees a store on one build and not the other, and on the pure-Go build skips precisely the stores that are in use. cek mints the sidecar eagerly, before the first byte of database is written, on both codecs — so "database or sidecar" is the marker that holds on every build and at every point in a store's life.

The layout stays cek's own: callers ask this rather than knowing the suffix.

func Master added in v1.801.299

func Master() []byte

Master returns the resolved data-plane key, or nil when none is configured.

It exists for ONE caller — the credential broker, which hands the same key to every child process in the deployment. That is not a convenience: the children open the SAME encrypted files, so a child that resolved a key of its own would write a store no sibling could read. The broker therefore has to be able to state which key this deployment settled on, including the dev key a keyless build installs for itself.

It resolves (and so memoizes) exactly like Encrypting, and must be called after any SetMasterKey/EnsureDevKey — the same ordering every caller here has.

func Open

func Open(p Principal, path string) (*sql.DB, error)

Open returns a *sql.DB for the SQLite database at path, encrypted at rest and bound to p. It is the single way a cloud store opens its file.

The principal comes first because it is the question a caller must answer, not one it may forget: pass cek.Global for a platform store, cek.Org(slug) for a tenant's. Passing the wrong one is not a silent mistake — the store will not unwrap.

func Rebind added in v1.801.307

func Rebind(from, to Principal, path string) error

Rebind moves one store from one owner's key to another's. It exists because the principal became part of the derivation AFTER stores were already on disk: a tenant store written when every file keyed under the platform tag cannot be opened by its owner until its sidecar is rewrapped.

It is an OPERATION, deliberately not a fallback inside Open. A store has one key and Open derives it one way; a second derivation tried on failure would mean every open silently accepts two answers forever, which is the thing that made the old binding unenforceable in the first place. Running this once is a migration. Leaving it in the open path would be a permanent ambiguity.

Only the sidecar changes. The DEK and the fileID are read out under `from` and written straight back under `to`, so not one database page is rewritten and the file itself is never opened — which is why this is safe on a store too large to copy and why a failure cannot corrupt data. The write is atomic (temp + rename), so an interrupted rebind leaves either the old sidecar or the new one, never a torn file.

It is idempotent in the way that matters: a store already bound to `to` fails to unwrap under `from` and returns ErrNotBound, so re-running a completed migration reports "already done" rather than damaging anything.

func RebindOrgs added in v1.801.307

func RebindOrgs(dataDir, platformSlug string) (bound, skipped int, errs []error)

RebindOrgs binds every per-org store under dataDir to the org that owns it, which is the one migration the principal change requires. It walks {dataDir}/orgs/<slug>/, including the nested projects/<slug>/ level, and rebinds each store from Global to Org(slug).

The reserved platform partition is skipped: it is not a tenant and keys as Global both before and after, so there is nothing to move.

It never stops on one store's failure. A run over a live volume will meet stores that are already bound (a re-run, or a store created after the change) and those are counted as skipped, not errors — the point of the walk is to leave every store bound, and that is a state to converge on rather than a transaction.

func SetMasterKey

func SetMasterKey(k []byte)

SetMasterKey injects the 32-byte master key explicitly (cloud's boot resolves it once from cfg and hands it here), taking precedence over the environment. Call before the first Open. A wrong-length key is ignored so the env path can still apply.

Types

type Principal added in v1.801.307

type Principal struct {
	// contains filtered or unexported fields
}

Principal is WHOSE store this is. It is the first argument to Open because it is part of a store's identity, not a property of its location: the same bytes at the same path belong to exactly one org, and the key says so.

It binds the derivation. Before, every store — platform and per-org alike — derived under the single tag "global", so a store's key knew nothing about its owner and a {db,.dek} pair lifted into another org's directory opened there perfectly well. Confidentiality between orgs still held (each file has its own random DEK under its own KEK), but nothing tied a file to the tenant it belonged to. Now the owner is in the HKDF info and in the GCM AAD, so a store carried across a tenant boundary fails to unwrap instead of opening.

func Org added in v1.801.307

func Org(slug string) Principal

Org binds a store to one tenant. slug MUST be the value SanitizeOrg produced (the injective slugger OrgDB already folds every org through), so two distinct orgs can never derive the same key.

func User added in v1.801.307

func User(id string) Principal

User binds a store to one person, for the per-user partition.

func (Principal) String added in v1.801.307

func (p Principal) String() string

String renders the principal for errors and logs. It never carries key material.

type Replica added in v1.801.350

type Replica interface {
	// Has reports whether a replica exists for this store. It is what makes
	// hydrate-on-open safe to call unconditionally: a first-ever boot finds
	// nothing and proceeds to create an empty store, which is correct and is
	// what `-if-replica-exists` was approximating from outside.
	Has(path string) (bool, error)

	// Hydrate materializes the store at path from its replica. It runs only
	// when Exists(path) is false, so it can never overwrite live data — the
	// property the initContainer got right and which must not be lost.
	Hydrate(path string) error

	// Follow streams subsequent commits out. It returns once following has
	// started, not when the replica is caught up: a store that refuses to open
	// until its backup is current is a store that will not open during an S3
	// incident, which trades a durability risk for an availability one.
	Follow(path string) (stop func() error, err error)
}

replication.go — where SQLite→S3 replication belongs, and why it is not a sidecar.

DESIGN ONLY. Nothing here is wired yet; this file marks the seam and records the decision so the next change lands in the right place instead of adding a sixth object to every stateful pod.

What the sidecar costs

Today each replicated service carries four objects and a key: a `replicate` container, a generated ConfigMap, a restore initContainer, and its own age keypair. All of it exists for one reason — `replicate` is a separate binary watching a file it does not own, so the file has to be described to it.

On 2026-07-29 that arrangement produced four independent outages in one day: a misindented age stanza replicate refused outright, an age/plaintext mismatch between config and bucket, a service whose data directory was not mounted at all, and a restore path that had never once run successfully. The last is the instructive one: restore only executes `-if-db-not-exists`, so while the local file happened to exist it was never exercised. The backups were configured, not current, and not restorable — and nothing said so until a volume was lost.

Why it belongs here

Replication is a property of the STORE, not of a process watching it. Open is already "the single way a cloud store opens its file" and Exists already answers "is there a store here" — which is the entire question the initContainer was shelling out to ask.

Native, the lifecycle collapses into Open:

if !Exists(path) && replica.Has(path) { replica.Hydrate(path) }
db := open(path)
replica.Follow(db)   // every commit streams out

Restore stops being a lifecycle stage and becomes what Open does. The ConfigMap, the initContainer, the second container and the ordering between them all disappear — there is nothing left to describe to a peer, because there is no peer.

One key, not two

This is the sharpest argument and the reason the age keypair should not be migrated to KMS but DELETED. cek already holds a master key (CLOUD_KMS_MASTER_KEY_REF) and resolveMaster is explicit that there is no plaintext-at-rest mode: a store is keyed or it does not open. The age identity is a SECOND key system encrypting the SAME data — with its own per-service secret, its own failure modes, and no rotation story at all, because an age identity cannot be rotated after the fact. Lose it and every replica under it is unreadable.

A replica written by this layer is encrypted under the key the process is already holding. One store, one key, one encryption path.

Transport

S3 over ZAP on the internal plane, like every other cross-app call — see cloud/rpc.go. That also retires ghcr.io/hanzoai/replicate as a shipped image.

The interface this wants

Small on purpose: three verbs, all about bytes at a path, none about containers or lifecycle.

type RewrapResult added in v1.801.350

type RewrapResult struct {
	Path      string // the database path whose sidecar was examined
	Owner     string // the principal it now derives from
	Rewrapped bool   // true when this call moved it forward
	Already   bool   // true when it already opened under its owner
	Err       error  // non-nil when it opened under neither identity
}

RewrapResult is the honest per-store outcome.

func InspectOrgs added in v1.801.350

func InspectOrgs(root string) ([]RewrapResult, error)

InspectOrgs is RewrapOrgs' read-only twin: it reports what each store would do without writing a byte. The dry run and the real run therefore share one walk and one decision — a dry run that used different logic would be a different program telling you about this one.

func Rewrap added in v1.801.350

func Rewrap(owner Principal, dbPath string) RewrapResult

Rewrap migrates one sidecar from the legacy (Global) derivation to owner-bound.

Order matters and is deliberate: try the OWNER first. A store already migrated must not be touched, and trying the legacy identity first on an already-correct store would fail and look like corruption.

func RewrapOrgs added in v1.801.350

func RewrapOrgs(root string) ([]RewrapResult, error)

RewrapOrgs walks every per-org store under root and migrates each to its owner. The subdirectory NAME is the slug OrgDB folded through on the way in, so the owner is read from the layout rather than guessed.

Jump to

Keyboard shortcuts

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