keyescrow

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 24 Imported by: 0

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

View Source
const (
	KDFArgon2id = "argon2id"
	AEADAESGCM  = "aes-256-gcm"
)

The algorithms a version 1 file names.

View Source
const EnvKey = "CLOOP_SECRET_KEY"

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.

View Source
const Kind = "cloop.hub.key-escrow"

Kind marks an escrow file.

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

View Source
const Version = 1

Version is the file format this build writes and reads.

Variables

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

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

func CheckDestination(path string) error

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

func CheckPassphrase(passphrase []byte) error

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 Fingerprint

func Fingerprint(key []byte) (string, error)

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

func SaveRecord(db *statedb.DB, r Record) error

SaveRecord writes the hub's escrow record, replacing any earlier one.

func WriteFile

func WriteFile(path string, f *File, replace bool) error

WriteFile writes f to path, 0600, through a temporary in the same directory, and refuses to replace an existing file unless replace is set: an escrow already there may be the only one whose passphrase somebody remembers.

func Zero

func Zero(b []byte)

Zero overwrites a key Open returned.

Types

type AEAD

type AEAD struct {
	Alg   string `json:"alg"`
	Nonce string `json:"nonce"`
}

AEAD names the cipher the key is sealed with.

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

func WriteEnvFile(path string, key []byte, replace bool, now time.Time) (EnvInstall, error)

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

func Parse(data []byte) (*File, error)

Parse decodes an escrow file and checks its structure. It needs no passphrase, and proves nothing about the ciphertext.

func ReadFile

func ReadFile(path string) (*File, error)

ReadFile reads and parses the escrow file at path; "-" reads standard input.

func Wrap

func Wrap(key, passphrase []byte, opts WrapOptions) (*File, error)

Wrap seals key under passphrase.

func (*File) Created

func (f *File) Created() time.Time

Created parses CreatedAt, returning the zero time when it does not parse.

func (*File) Marshal

func (f *File) Marshal() ([]byte, error)

Marshal renders the file as written to disk.

func (*File) Open

func (f *File) Open(passphrase []byte) ([]byte, error)

Open recovers the key. The caller owns the returned slice and should zero it.

func (*File) Record

func (f *File) Record(by string, at time.Time) Record

Record is what the hub keeps about f.

func (*File) Validate

func (f *File) Validate() error

Validate checks f's structure and parameters without the passphrase.

type KDF

type KDF struct {
	Alg string `json:"alg"`
	Params
	Salt string `json:"salt"`
}

KDF names the derivation of the wrapping key from the passphrase.

type Params

type Params struct {
	Time      uint32 `json:"time"`
	MemoryKiB uint32 `json:"memory_kib"`
	Threads   uint8  `json:"threads"`
}

Params are argon2id's work factors.

func (Params) String

func (p Params) String() string

String renders the parameters for an operator.

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

func LoadRecord(db *statedb.DB) (Record, bool, error)

LoadRecord reads the hub's escrow record from its control plane.

func ReadRecord

func ReadRecord(conn *sql.DB) (Record, bool, error)

ReadRecord is LoadRecord through a bare connection — one statedb.OpenConn opened ReadOnly — so asking which escrow a hub records writes nothing to it.

func (Record) Created

func (r Record) Created() time.Time

Created parses CreatedAt.

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.

Jump to

Keyboard shortcuts

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