Documentation
¶
Overview ¶
Package tumbler implements envelope encryption with independent keyslots: a single random data key (DEK) wraps a payload, and each enrolled unlock method wraps the DEK in its own keyslot. Any one keyslot yields the DEK; adding or removing a method touches only that slot, never the payload or its siblings — the LUKS / systemd-cryptenroll model.
It is named for a lock's tumbler, the mechanism whose pins must align for the lock to open; each keyslot holds one wrapped key.
Unlock methods ¶
- PasswordMethod — KDF(password) only.
- YubiKeyMethod — YubiKey HMAC-SHA1 challenge-response, alone (touch-gated, presence not identity) or as true two-factor with a password (secret challenge derived from the password key, which is also mixed directly into the KEK).
- RecoveryCodeMethod — a 256-bit printed code, an additive escape hatch.
Cryptography ¶
Each slot derives a single-use key-encryption key with HKDF-SHA256 — PRK = Extract(perSlotSalt, IKM); KEK = Expand(PRK, version||type||slotID) — where IKM is a length-prefixed, role-tagged combination of the present factors (password key, YubiKey response, recovery code). The DEK is sealed with ChaCha20-Poly1305 (pure-Go, constant-time on every target) and the slot's own metadata is bound in as associated data, so tampering with any slot parameter breaks that slot's tag and surfaces as a uniform ErrAuthFailed — downgrade-evident by construction.
Wire format ¶
Envelopes serialize to a versioned, self-describing, strictly-bounded binary form ("TMBL" magic + version + advisory policy hint + length- prefixed keyslots). ParseEnvelope performs only structural and bounds checks; enforcement of policy uses EffectivePolicy (recomputed from the authenticated slot types), never the advisory header byte.
Secret handling ¶
Every data key, key-encryption key, derived password key, YubiKey response, and recovery code lives in a securebytes.SecureBytes for its whole lifetime and is Destroy-ed as soon as it is no longer needed. The module adds no third-party cryptographic or hardware dependency: it is built entirely on the Go standard library and golang.org/x/crypto (the Go team's own packages), and delegates raw YubiKey I/O to Yubico's official ykman CLI via a small, fixed-argv transport. It compiles under CGO_ENABLED=0 to every supported target.
See SECURITY.md for the threat model, the presence-vs-identity limit of the challenge-response path, and the v2 PIV upgrade seam.
Index ¶
- Constants
- Variables
- func FormatRecoveryCode(code *securebytes.SecureBytes) (string, error)
- func GenerateDEK(size int) (*securebytes.SecureBytes, error)
- func GenerateRecoveryCode() (*securebytes.SecureBytes, error)
- func ParseRecoveryCode(s string) (*securebytes.SecureBytes, error)
- type AEADID
- type Argon2idKDF
- type Envelope
- func (e *Envelope) AddSlot(ctx context.Context, dek *securebytes.SecureBytes, m Method) error
- func (e *Envelope) EffectivePolicy() Policy
- func (e *Envelope) Marshal() ([]byte, error)
- func (e *Envelope) PolicyHint() Policy
- func (e *Envelope) RemoveSlot(id [8]byte) error
- func (e *Envelope) SlotCount() int
- func (e *Envelope) SlotInfos() []SlotInfo
- func (e *Envelope) Unlock(ctx context.Context, methods ...Method) (*securebytes.SecureBytes, error)
- func (e *Envelope) ValidateSafety(force bool) (*SafetyReport, error)
- func (e *Envelope) Version() uint8
- type KDF
- type KDFID
- type Method
- type MethodType
- type Option
- type PasswordMethod
- type Policy
- type RecoveryCodeMethod
- type SafetyReport
- type ScryptKDF
- type Slot
- type SlotInfo
- type YubiKeyConfig
- type YubiKeyMethod
Constants ¶
const FormatVersion uint8 = 1
FormatVersion is the current on-disk envelope format version. It is bound into every KEK derivation (as HKDF info) and written into the file header. Bumping it is a deliberate, breaking-format act; golden testdata pins the v1 layout for back-compat.
const RecoveryCodeLen = 32
RecoveryCodeLen is the byte length of a recovery code (256 bits of entropy).
Variables ¶
var ( // ErrAuthFailed is the single, uniform failure returned by every unlock // path (wrong password, wrong/absent YubiKey, tampered slot, bad tag). ErrAuthFailed = errors.New("tumbler: authentication failed") // ErrBadMagic is returned by ParseEnvelope when the leading magic bytes // are not "TMBL". ErrBadMagic = errors.New("tumbler: bad magic") // ErrBadVersion is returned by ParseEnvelope for an unknown format version. ErrBadVersion = errors.New("tumbler: unsupported format version") // ErrShortData is returned when the input is too short to contain a // required field. ErrShortData = errors.New("tumbler: short data") // ErrMalformed is returned for structurally invalid (but non-truncated) // envelopes: bad field lengths, out-of-range enums, oversized fields. ErrMalformed = errors.New("tumbler: malformed envelope") // ErrSlotNotFound is returned by RemoveSlot when no slot has the given ID. ErrSlotNotFound = errors.New("tumbler: slot not found") // ErrNoMethods is returned by NewEnvelope/Unlock when no methods are given. ErrNoMethods = errors.New("tumbler: no methods supplied") // ErrDEKSize is returned when a data key is empty or exceeds the bound. ErrDEKSize = errors.New("tumbler: data key size out of range") // ErrKDFParams is returned when serialized KDF parameters are out of the // safe bounds enforced on parse (a defense against resource-exhaustion // via a tampered file). ErrKDFParams = errors.New("tumbler: kdf parameters out of range") // ErrUnsupportedKDF is returned for an unknown KDFID. ErrUnsupportedKDF = errors.New("tumbler: unsupported kdf") // ErrUnsupportedAEAD is returned for an unknown AEADID. ErrUnsupportedAEAD = errors.New("tumbler: unsupported aead") // ErrPolicyMismatch is returned when a caller's expected policy does not // match the envelope's EffectivePolicy. ErrPolicyMismatch = errors.New("tumbler: policy mismatch") // ErrPolicyUnsafe is returned by the safe-default validator for a // dangerous configuration (e.g. single-slot YubiKey-only without force). ErrPolicyUnsafe = errors.New("tumbler: unsafe policy") )
Sentinel errors. Compare with errors.Is. Unlock failures deliberately collapse to a single ErrAuthFailed so no information about WHICH check failed (KDF, transport, AEAD tag) can leak to an attacker.
var ErrInvalidRecoveryCode = fmt.Errorf("tumbler: invalid recovery code")
ErrInvalidRecoveryCode is returned by ParseRecoveryCode for input that does not decode to exactly RecoveryCodeLen bytes.
Functions ¶
func FormatRecoveryCode ¶
func FormatRecoveryCode(code *securebytes.SecureBytes) (string, error)
FormatRecoveryCode renders a code as grouped base32 for one-time display, e.g. "ABCD-EFGH-IJKL-...". The result is an ordinary Go string (it must be shown to a human and cannot be zeroed); treat it as sensitive and do not log or persist it.
func GenerateDEK ¶
func GenerateDEK(size int) (*securebytes.SecureBytes, error)
GenerateDEK returns a fresh random data key of the given size, held in a SecureBytes the caller owns and must Destroy. size must be in [1, 240].
Apps that already hold a seed (sigil's wallet seed, hush's master seed) pass that seed as the DEK to NewEnvelope instead of generating one here.
func GenerateRecoveryCode ¶
func GenerateRecoveryCode() (*securebytes.SecureBytes, error)
GenerateRecoveryCode returns a fresh 256-bit recovery code in a SecureBytes the caller owns and must Destroy. Display it exactly once (see FormatRecoveryCode) and never persist it.
func ParseRecoveryCode ¶
func ParseRecoveryCode(s string) (*securebytes.SecureBytes, error)
ParseRecoveryCode decodes a user-entered recovery code (any casing, with or without grouping separators) back into a SecureBytes the caller owns and must Destroy.
Types ¶
type AEADID ¶
type AEADID uint8
AEADID selects the authenticated cipher used to wrap the data key in a slot. Only ChaCha20-Poly1305 is defined today; the byte leaves room for AES-GCM later without a format break.
const ( // AEADChaCha20Poly1305 is the only AEAD defined in v1. It is pure-Go and // constant-time on every target (unlike AES-GCM without AES-NI). AEADChaCha20Poly1305 AEADID = 1 )
type Argon2idKDF ¶
type Argon2idKDF struct {
// contains filtered or unexported fields
}
Argon2idKDF implements KDF with Argon2id (RFC 9106).
func NewArgon2idKDF ¶
func NewArgon2idKDF(time, memoryKiB uint32, threads uint8) *Argon2idKDF
NewArgon2idKDF constructs an Argon2id KDF. hush uses t=4, m=256*1024 KiB, p=4 to mirror internal/keys/derive.go.
func (*Argon2idKDF) Derive ¶
func (k *Argon2idKDF) Derive(password *securebytes.SecureBytes, salt []byte) (*securebytes.SecureBytes, error)
Derive implements KDF.
func (*Argon2idKDF) MarshalParams ¶
func (k *Argon2idKDF) MarshalParams() []byte
MarshalParams implements KDF: time(4) || memoryKiB(4) || threads(1).
type Envelope ¶
type Envelope struct {
// contains filtered or unexported fields
}
Envelope is a set of independent keyslots wrapping one data key.
An Envelope is NOT safe for concurrent use when any goroutine mutates it (AddSlot, RemoveSlot) — it carries no internal lock, matching the stdlib norm for value-like containers. Concurrent read-only use (Unlock, Marshal, SlotInfos, EffectivePolicy) is safe only if no goroutine is mutating. Callers that share an Envelope across goroutines must serialize access themselves. (The package's test-only RNG/securebytes hook globals are set once at test startup and never mutated concurrently.)
func NewEnvelope ¶
func NewEnvelope(ctx context.Context, dek *securebytes.SecureBytes, policy Policy, methods ...Method) (*Envelope, error)
NewEnvelope wraps dek once per method and returns the sealed envelope. The declared policy must match the policy implied by the enrolled primary slots (EffectivePolicy); a mismatch is rejected. dek is not consumed — the caller still owns and must Destroy it.
func ParseEnvelope ¶
ParseEnvelope validates and decodes an envelope. It performs only structural and bounds checks — no cryptography — and rejects anything malformed with ErrBadMagic / ErrBadVersion / ErrShortData / ErrMalformed.
func (*Envelope) AddSlot ¶
func (e *Envelope) AddSlot(ctx context.Context, dek *securebytes.SecureBytes, m Method) error
AddSlot wraps dek under one more method and appends the slot — the O(1) path for enrolling a backup YubiKey or a printed recovery code. A non-recovery slot must match the envelope's current EffectivePolicy; recovery slots are always permitted.
func (*Envelope) EffectivePolicy ¶
EffectivePolicy derives the policy from the enrolled slots, ignoring recovery slots (an intentional escape hatch present under any policy). It returns the policy shared by all primary slots, or PolicyInvalid if there are no primary slots or they disagree (only reachable via tampering or misuse). Enforcement compares this against the caller's expected policy; never trust the advisory header byte.
func (*Envelope) PolicyHint ¶
PolicyHint returns the advisory policy byte from the header. Prefer EffectivePolicy for any enforcement decision.
func (*Envelope) RemoveSlot ¶
RemoveSlot deletes the slot with the given ID. It refuses to remove the only remaining slot, or the last primary (non-recovery) slot: either would leave an envelope that EffectivePolicy reports as PolicyInvalid and that AddSlot then refuses to re-add a primary to — an unrecoverable invariant break. Recovery slots are always removable (they are the escape hatch, not the policy). Callers that swap a primary (e.g. a passphrase rewrap) must add the replacement BEFORE removing the old one. Removing a slot protects only THIS file; a possibly-compromised key is truly revoked only by rotating the data key — see SECURITY.md.
func (*Envelope) Unlock ¶
func (e *Envelope) Unlock(ctx context.Context, methods ...Method) (*securebytes.SecureBytes, error)
Unlock tries each slot against every method whose Type matches the slot's, returning the data key from the first slot that opens. On total failure it returns the single uniform ErrAuthFailed. The returned key is owned by the caller and must be Destroyed.
func (*Envelope) ValidateSafety ¶
func (e *Envelope) ValidateSafety(force bool) (*SafetyReport, error)
ValidateSafety assesses an envelope against safe-default rules and returns advisory warnings plus a fatal error for dangerous configurations.
Hard-unsafe (returns ErrPolicyUnsafe unless force is true):
- a single-slot YubiKey-only envelope. The CR path has no PIN, so a stolen key plus the stolen file is enough to unlock, and with no second slot a lost key means permanent lockout.
Soft warnings (never fatal):
- fewer than two unlock slots (no backup key / recovery code);
- no recovery slot enrolled;
- YubiKey-only posture (presence, not identity).
type KDF ¶
type KDF interface {
// ID reports the KDFID written into the slot.
ID() KDFID
// SaltLen is the salt length Derive expects (and enroll generates).
SaltLen() int
// MarshalParams serializes the cost parameters for storage in the slot.
MarshalParams() []byte
// Derive stretches password with salt into a fresh pkLen-byte key held in
// a SecureBytes the caller must Destroy.
Derive(password *securebytes.SecureBytes, salt []byte) (*securebytes.SecureBytes, error)
}
KDF is a pluggable password key-derivation function. Each app keeps its own parameters (hush: Argon2id; sigil: scrypt) and the ID plus serialized parameters are stored in — and authenticated by — every password slot, so the envelope is self-describing and re-derivable without app-side config.
type KDFID ¶
type KDFID uint8
KDFID selects the password key-derivation function used by a slot. The ID plus its serialized parameters are stored in (and authenticated by) the slot, so the file is self-describing.
const ( // KDFNone means no password KDF is applied (recovery-code slots): the // input keying material is already uniform and high-entropy. KDFNone KDFID = 0 // KDFArgon2id is Argon2id (RFC 9106), used by hush. KDFArgon2id KDFID = 1 // KDFScrypt is scrypt (RFC 7914), used by sigil to preserve age's cost. KDFScrypt KDFID = 2 )
type Method ¶
type Method interface {
Type() MethodType
Enroll(ctx context.Context, dek *securebytes.SecureBytes) (Slot, error)
Unlock(ctx context.Context, slot Slot) (*securebytes.SecureBytes, error)
}
Method wraps the data key into a Slot (Enroll) and recovers it from a Slot (Unlock). Implementations MUST hold every secret in securebytes and MUST collapse all unlock failures to ErrAuthFailed so nothing leaks about which check failed.
type MethodType ¶
type MethodType uint8
MethodType identifies the kind of unlock method a keyslot was enrolled with. It is written into each slot (and authenticated as AAD), and it is the value Envelope.Unlock matches a caller-supplied Method against.
const ( // MethodPassword is a keyslot unlocked by a password alone: the data key // is wrapped under a KEK derived from KDF(password) only. MethodPassword MethodType = 1 // MethodYubiKey is a keyslot unlocked by a YubiKey alone (HMAC-SHA1 // challenge-response, touch-gated, no PIN): the KEK is derived from the // 20-byte YubiKey response to a stored random challenge. Presence, not // identity — see SECURITY.md. MethodYubiKey MethodType = 2 // MethodPasswordAndYubiKey is a true two-factor keyslot: the challenge // sent to the YubiKey is secret and derived from the password key, and // the password key is ALSO mixed directly into the KEK. Compromise of // either factor alone yields nothing. MethodPasswordAndYubiKey MethodType = 3 // MethodRecovery is a keyslot unlocked by a high-entropy printed recovery // code (256-bit). No KDF is applied (kdfID=0); the code is uniform input // keying material fed straight to HKDF. MethodRecovery MethodType = 4 )
func (MethodType) String ¶
func (t MethodType) String() string
String renders a MethodType for diagnostics (never secret).
type Option ¶
type Option func(*methodOptions)
Option configures an unlock method at construction time.
func WithLabel ¶
WithLabel attaches a short, non-secret human label to a slot (e.g. "backup-key", "recovery-code"). Labels are stored in — and authenticated by — the slot and surface via Envelope.SlotInfos.
func WithTouchAnnounce ¶
func WithTouchAnnounce(fn func()) Option
WithTouchAnnounce registers a callback invoked immediately before the YubiKey is asked for a response — i.e. at the exact moment the key begins blinking for a touch. Apps use it to print a "touch your key now" prompt at the right moment (after any password prompt and key derivation), rather than too early. It is a no-op for non-YubiKey methods.
type PasswordMethod ¶
type PasswordMethod struct {
// contains filtered or unexported fields
}
PasswordMethod wraps the data key under a KEK derived solely from KDF(password). It is the sole method for PolicyPasswordOnly and preserves each app's existing password cost via a pluggable KDF.
func NewPasswordMethod ¶
func NewPasswordMethod(kdf KDF, password *securebytes.SecureBytes, opts ...Option) *PasswordMethod
NewPasswordMethod builds a password method from a KDF and the live password. The method borrows the password for enroll/unlock; the caller retains ownership and must Destroy it. kdf is used at enroll to choose parameters; at unlock the parameters stored in the slot are used instead, so the file remains self-describing.
func (*PasswordMethod) Enroll ¶
func (m *PasswordMethod) Enroll(ctx context.Context, dek *securebytes.SecureBytes) (Slot, error)
Enroll implements Method.
func (*PasswordMethod) Unlock ¶
func (m *PasswordMethod) Unlock(ctx context.Context, slot Slot) (*securebytes.SecureBytes, error)
Unlock implements Method.
type Policy ¶
type Policy uint8
Policy is the per-envelope security posture. It is stored in the header as an ADVISORY hint only; enforcement always uses Envelope.EffectivePolicy, which is recomputed from the authenticated slot types.
const ( // PolicyInvalid is the zero value and the result of EffectivePolicy when // an envelope's primary slots disagree (only reachable via tampering or // misuse). Callers comparing against an expected policy will reject it. PolicyInvalid Policy = 0 // PolicyPasswordOnly unlocks with a password. PolicyPasswordOnly Policy = 1 // PolicyPasswordAndYubiKey unlocks with password AND YubiKey (2FA). PolicyPasswordAndYubiKey Policy = 2 // PolicyYubiKeyOnly unlocks with a YubiKey alone (no password). PolicyYubiKeyOnly Policy = 3 )
type RecoveryCodeMethod ¶
type RecoveryCodeMethod struct {
// contains filtered or unexported fields
}
RecoveryCodeMethod wraps the data key under a KEK derived directly from a high-entropy recovery code (no password KDF — the code is already uniform). It is an additive escape hatch: enroll it via Envelope.AddSlot under any policy so a lost password or YubiKey cannot cause permanent lockout.
func NewRecoveryMethod ¶
func NewRecoveryMethod(code *securebytes.SecureBytes, opts ...Option) *RecoveryCodeMethod
NewRecoveryMethod builds a recovery method around a live code (as returned by GenerateRecoveryCode or ParseRecoveryCode). The method borrows the code; the caller retains ownership and must Destroy it.
func (*RecoveryCodeMethod) Enroll ¶
func (m *RecoveryCodeMethod) Enroll(ctx context.Context, dek *securebytes.SecureBytes) (Slot, error)
Enroll implements Method.
func (*RecoveryCodeMethod) Type ¶
func (m *RecoveryCodeMethod) Type() MethodType
Type implements Method.
func (*RecoveryCodeMethod) Unlock ¶
func (m *RecoveryCodeMethod) Unlock(ctx context.Context, slot Slot) (*securebytes.SecureBytes, error)
Unlock implements Method.
type SafetyReport ¶
type SafetyReport struct {
// Warnings are human-readable advisories (e.g. "no recovery code enrolled").
Warnings []string
// UnlockSlots is the total number of slots that can open the envelope.
UnlockSlots int
// HasRecovery reports whether at least one recovery slot is enrolled.
HasRecovery bool
}
SafetyReport is the result of the safe-default validator: a list of non-fatal warnings the caller should surface to the user. A fatal condition is reported via the error return of ValidateSafety, not here.
type ScryptKDF ¶
type ScryptKDF struct {
// contains filtered or unexported fields
}
ScryptKDF implements KDF with scrypt (RFC 7914). N is stored as its log2 so the power-of-two invariant is structural.
func NewScryptKDF ¶
NewScryptKDF constructs a scrypt KDF from log2(N), r, and p. sigil uses logN=18 (age's secure default), r=8, p=1.
func (*ScryptKDF) Derive ¶
func (k *ScryptKDF) Derive(password *securebytes.SecureBytes, salt []byte) (*securebytes.SecureBytes, error)
Derive implements KDF.
func (*ScryptKDF) MarshalParams ¶
MarshalParams implements KDF: logN(1) || r(4) || p(4).
type Slot ¶
type Slot struct {
Type MethodType
ID [8]byte
Flags uint8
AEADID AEADID
KDFID KDFID
YKSlot uint8
KDFParams []byte
KDFSalt []byte
ChalSalt []byte
HKDFSalt [hkdfSaltLen]byte
Challenge []byte
Label []byte
Nonce [nonceLen]byte
Wrapped []byte
// contains filtered or unexported fields
}
Slot is one wrapped copy of the data key, unlockable by exactly one method. Adding or removing a slot never touches the payload or any sibling slot.
Every field except Wrapped is authenticated as AEAD associated data, so any edit to a slot's metadata (its type, KDF cost, stored challenge, salts, nonce) breaks that slot's tag and surfaces as a uniform ErrAuthFailed — downgrade-evident by construction.
type SlotInfo ¶
type SlotInfo struct {
ID [8]byte
Type MethodType
Label string
}
SlotInfo is the non-secret view of a slot for listing in CLIs.
type YubiKeyConfig ¶
type YubiKeyConfig struct {
// Slot is the YubiKey OTP slot programmed for HMAC-SHA1 (1 or 2).
Slot uint8
// KDF derives the password key for 2FA (MethodPasswordAndYubiKey). It is
// required when a password is supplied to NewYubiKeyMethod and ignored for
// yubi-only enrollment. As with PasswordMethod, unlock re-derives from the
// slot's stored KDF parameters, so the file stays self-describing.
KDF KDF
}
YubiKeyConfig configures a YubiKey method.
type YubiKeyMethod ¶
type YubiKeyMethod struct {
// contains filtered or unexported fields
}
YubiKeyMethod wraps the data key under a KEK that incorporates a YubiKey HMAC-SHA1 response. With no password it is yubi-only (MethodYubiKey, presence not identity); with a password it is true two-factor (MethodPasswordAndYubiKey): the challenge is secret and derived from the password key, which is also mixed directly into the KEK.
func NewYubiKeyMethod ¶
func NewYubiKeyMethod(t transport.Transport, cfg YubiKeyConfig, password *securebytes.SecureBytes, opts ...Option) *YubiKeyMethod
NewYubiKeyMethod builds a YubiKey method. Pass password=nil for yubi-only, or a live password for two-factor (cfg.KDF then required). The method borrows the password; the caller retains ownership and must Destroy it.
func (*YubiKeyMethod) Enroll ¶
func (m *YubiKeyMethod) Enroll(ctx context.Context, dek *securebytes.SecureBytes) (Slot, error)
Enroll implements Method.
func (*YubiKeyMethod) Unlock ¶
func (m *YubiKeyMethod) Unlock(ctx context.Context, slot Slot) (*securebytes.SecureBytes, error)
Unlock implements Method.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package securebytes provides the SecureBytes container — an opaque, pointer-only secret holder that pins its payload in non-swappable memory (where the OS supports it), zeroes the payload on explicit Destroy AND on garbage collection (via a runtime finalizer), and renders as the literal string "[redacted]" through every standard log/format/JSON path.
|
Package securebytes provides the SecureBytes container — an opaque, pointer-only secret holder that pins its payload in non-swappable memory (where the OS supports it), zeroes the payload on explicit Destroy AND on garbage collection (via a runtime finalizer), and renders as the literal string "[redacted]" through every standard log/format/JSON path. |
|
Package transport abstracts the raw YubiKey I/O behind a small interface so the tumbler envelope logic never shells out or touches USB/CCID directly.
|
Package transport abstracts the raw YubiKey I/O behind a small interface so the tumbler envelope logic never shells out or touches USB/CCID directly. |
