Documentation
¶
Overview ¶
Package keyescrow wraps the hub's root secret, CLOOP_SECRET_KEY, under a passphrase an operator keeps, so that losing .cloop/hub.env stops being the end of every sealed credential (Task 20410).
What it protects, and what it cannot ¶
Every key-encryption key the secret broker seals under is derived from CLOOP_SECRET_KEY, so rotating the KEKs as often as you like changes nothing about this one value: lose it and every sealed PAT, kubeconfig, App key, session refresh token and hub CA key is gone. Until this package the bootstrap said so in a comment — "no recovery path and no escrow" — and the runbook's restore was a two-part procedure whose second part assumed the first had survived.
An escrow file holds the key sealed under a passphrase. It is safe to keep where a database backup is not — with the other root credentials, off the host — because without the passphrase it is a few dozen bytes of AES-GCM ciphertext behind a memory-hard KDF. It is exactly as strong as the passphrase and no stronger, which is why one shorter than MinPassphraseLength is refused.
Why argon2id, and not the store's own KDF ¶
The broker stretches CLOOP_SECRET_KEY with 200,000 rounds of SHA-256 (pkg/secretbroker/cipher.go). That is plenty for 256 bits of crypto/rand, which nobody guesses, and far too cheap for something a person chose: a GPU tries billions of SHA-256 a second. argon2id makes every guess cost memory as well as time, which is what a GPU or an ASIC has least of. The parameters are RFC 9106's second recommended option (§4: t=3, p=4, 64 MiB), written into the file and bound into the AEAD's associated data, so a file cannot be talked into a cheaper derivation than it was made with; and they are bounded on read, so a crafted file cannot make a verifier allocate the machine.
What the hub keeps ¶
Only a Record: the escrow's id, when and by whom, and the key's fingerprint — the one `cloop hub doctor` and every audit checkpoint already show for this key, so recording it publishes nothing a checkpoint line has not. It is what lets the doctor say that the escrow on record no longer holds the key in use.
Plaintext never touches disk ¶
The key goes from the environment into memory and out as ciphertext. The escrow file is written through a temporary holding ciphertext only. WriteEnvFile, which puts a recovered key back, writes the one file the key lives in anyway, and a temporary that fails on the way there is destroyed with pkg/securewipe rather than merely unlinked.
Index ¶
- Constants
- Variables
- func CheckDestination(path string) error
- func CheckPassphrase(passphrase []byte) error
- func EnvFileHoldsKey(path string) bool
- func Fingerprint(key []byte) (string, error)
- func SaveRecord(db *statedb.DB, r Record) error
- func WriteFile(path string, f *File, replace bool) error
- func Zero(b []byte)
- type AEAD
- type EnvInstall
- type File
- type KDF
- type KeyFileInstall
- type Params
- type Record
- type WrapOptions
Constants ¶
const ( KDFArgon2id = "argon2id" AEADAESGCM = "aes-256-gcm" )
The algorithms a version 1 file names.
const EnvKey = rootkey.EnvKey
EnvKey is the variable holding the key an escrow protects: the secret broker's passphrase (secretbroker.EnvPassphraseKey), which every audit checkpoint and backup manifest seal is derived from as well. It may as well come from a key file or a systemd credential (pkg/rootkey); an escrow holds the current one, whichever source it was read from.
const Kind = "cloop.hub.key-escrow"
Kind marks an escrow file.
const MinPassphraseLength = 15
MinPassphraseLength is the shortest passphrase an escrow accepts, in characters: NIST SP 800-63B rev. 4 asks 15 of a password that is the only thing standing between an attacker and what it protects, which is this passphrase's whole job.
const Version = 1
Version is the file format this build writes and reads.
Variables ¶
var ( ErrNotEscrow = errors.New("keyescrow: not a cloop key escrow file") ErrUnsupported = errors.New("keyescrow: unsupported escrow format") ErrParams = errors.New("keyescrow: argon2id parameters out of bounds") ErrOpen = errors.New("keyescrow: wrong passphrase, or the file was altered") ErrWeakPassphrase = errors.New("keyescrow: passphrase refused") ErrNoKey = errors.New("keyescrow: " + EnvKey + " is not set") )
Sentinels. A wrong passphrase and an altered file are deliberately the same error (ErrOpen): AES-GCM cannot tell them apart, and the difference would help only whoever is doing the altering.
var DefaultParams = Params{Time: 3, MemoryKiB: 64 << 10, Threads: 4}
DefaultParams is RFC 9106's second recommended option: three passes over 64 MiB with four lanes, about a tenth of a second on a server core.
Functions ¶
func CheckDestination ¶
CheckDestination reports why path is no place for an escrow file. The file exists to survive the loss of the hub's .cloop/ directory — the place hub.env, the database and its backups are most likely to be lost together — so a destination inside one, followed through symlinks, is refused.
func CheckPassphrase ¶
CheckPassphrase reports why passphrase is too weak to protect a root key. Length is what counts (NIST SP 800-63B rev. 4 drops composition rules); a passphrase of one or two repeated characters is refused as well, because it is long only on paper.
func EnvFileHoldsKey ¶ added in v0.0.6
EnvFileHoldsKey reports whether the env file at path assigns the key — the layout a hub bootstrapped before Task 20425 keeps it in.
func Fingerprint ¶
Fingerprint names key the way the hub already names it wherever an operator meets it: the fingerprint of the audit checkpoint key it derives, stamped on every checkpoint the hub seals and printed by `cloop hub doctor` and `cloop hub audit verify`. One key, one name — and recording it publishes nothing a checkpoint line has not, at the cost to a guesser that line already sets: a full PBKDF2 pass per try.
func SaveRecord ¶
SaveRecord writes the hub's escrow record, replacing any earlier one.
Types ¶
type EnvInstall ¶
type EnvInstall struct {
Path string
// Created: there was no file; it holds the key and nothing else.
Created bool
// Unchanged: the file already held this key, and was not touched.
Unchanged bool
// Previous is where the earlier file went when it held a different key.
Previous string
}
EnvInstall reports what WriteEnvFile did.
func WriteEnvFile ¶
WriteEnvFile puts key into the env file at path — the file a symlink there points to — as CLOOP_SECRET_KEY, keeping every other line. A file that already holds this key, once, is left alone; one holding a different key in any of its assignments is refused unless replace is set, and then kept beside it as <path>.pre-import.<UTC>.
type File ¶
type File struct {
Kind string `json:"kind"`
Version int `json:"v"`
// ID names this escrow; the hub's Record carries it, so `cloop hub key
// escrow verify` can say whether a file is the one on record.
ID string `json:"escrow_id"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
// Hub is the directory it was exported from, for whoever finds the file.
Hub string `json:"hub,omitempty"`
// KeyFingerprint is Fingerprint of the key inside.
KeyFingerprint string `json:"key_fingerprint"`
KDF KDF `json:"kdf"`
AEAD AEAD `json:"aead"`
// Ciphertext is the key sealed under the passphrase, base64.
Ciphertext string `json:"ciphertext"`
}
File is an escrow file. Every field but Ciphertext is bound into the AEAD's associated data, so none can be edited without the passphrase noticing.
func Parse ¶
Parse decodes an escrow file and checks its structure. It needs no passphrase, and proves nothing about the ciphertext.
func Wrap ¶
func Wrap(key, passphrase []byte, opts WrapOptions) (*File, error)
Wrap seals key under passphrase.
type KeyFileInstall ¶ added in v0.0.6
type KeyFileInstall = EnvInstall
KeyFileInstall reports what WriteKeyFile did; the same shape as EnvInstall.
func WriteKeyFile ¶ added in v0.0.6
WriteKeyFile puts key into the key file at path — the file a symlink there points to — the way the hub reads it from CLOOP_SECRET_KEY_FILE or loads it as a systemd credential: the key and a newline, nothing else, mode 0600 (Task 20425).
The same care as WriteEnvFile: a file that already holds this key is left alone, one holding a different key is refused unless replace is set and then kept beside it as <path>.pre-import.<UTC>, and a temporary that fails on the way is destroyed with pkg/securewipe. A path inside a .cloop/ directory is refused, because the hub refuses to read a key from there.
type Params ¶
type Params struct {
Time uint32 `json:"time"`
MemoryKiB uint32 `json:"memory_kib"`
Threads uint8 `json:"threads"`
}
Params are argon2id's work factors.
type Record ¶
type Record struct {
ID string `json:"escrow_id"`
KeyFingerprint string `json:"key_fingerprint"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
KDF string `json:"kdf"`
// RecordedAt and RecordedBy say when the record was last written — by the
// export, or by a `verify --record` after a restore brought back a
// database older than the escrow.
RecordedAt string `json:"recorded_at"`
RecordedBy string `json:"recorded_by,omitempty"`
}
Record is everything the hub keeps about its escrow. Nothing in it is secret, and nothing in it helps open the file.
func LoadRecord ¶
LoadRecord reads the hub's escrow record from its control plane.
func ReadRecord ¶
ReadRecord is LoadRecord through a bare connection — one statedb.OpenConn opened ReadOnly — so asking which escrow a hub records writes nothing to it.
type WrapOptions ¶
type WrapOptions struct {
// Params are the KDF's work factors; zero means DefaultParams.
Params Params
// CreatedBy and Hub are recorded in the file, for whoever finds it.
CreatedBy string
Hub string
// Now is the creation time; zero means time.Now.
Now time.Time
}
WrapOptions describe an escrow being made.