tumbler

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 14 Imported by: 0

README

🔐  go-tumbler

Envelope encryption with independent keyslots for Go — unlock one payload with a password, a YubiKey, both (2FA), or a recovery code, and add or remove methods without ever re-encrypting the payload.


Release Go Version Go Reference License


CI / CD    Build Last Commit      Quality    Coverage
Security    Scorecard Security      Community    Contributors Bitcoin

Named for a lock's tumbler — the mechanism whose pins must align before it turns. A random data key (DEK) encrypts your payload once; each enrolled unlock method wraps the DEK in its own independent keyslot. Any one slot yields the DEK, and adding or removing a method touches only that slot — the LUKS / systemd-cryptenroll model, in a small, auditable, pure-Go core.

Status: v0.x — the on-disk format is unstable until an external cryptographic review. Not yet recommended for production outside its originating projects. See SECURITY.md.


Project Navigation
🚀 Installation ⚡ Quick Start 🧩 How It Works
🔑 Policies 📦 Packages 📚 Documentation
🔐 Security 🧪 Examples & Tests 🛠️ Code Standards
🤖 AI Usage 🤝 Contributing ⚖️ License

🚀 Installation

go-tumbler requires a supported release of Go (1.26+). Add it to your module with a single command:

go get github.com/mrz1836/go-tumbler

Then import the packages you need:

import (
    tumbler "github.com/mrz1836/go-tumbler"
    "github.com/mrz1836/go-tumbler/securebytes"
    "github.com/mrz1836/go-tumbler/transport"
)

The module compiles under CGO_ENABLED=0 to every supported target and 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). Raw YubiKey I/O is delegated to Yubico's official ykman CLI through a fixed-argv, never-a-shell transport.


⚡ Quick Start

Get up and running with these essential flows.


Password-only enrollment
ctx := context.Background()

// A fresh random data key (or pass your app's existing seed as the DEK).
dek, _ := tumbler.GenerateDEK(32)
defer dek.Destroy()

pw, _ := securebytes.New([]byte("correct horse battery staple"))
defer pw.Destroy()

env, _ := tumbler.NewEnvelope(ctx, dek, tumbler.PolicyPasswordOnly,
    tumbler.NewPasswordMethod(tumbler.NewScryptKDF(18, 8, 1), pw))

// Persist the self-describing envelope anywhere (file, DB, sidecar).
blob, _ := env.Marshal()
// Later: parse and unlock.
parsed, _ := tumbler.ParseEnvelope(blob)

pw2, _ := securebytes.New([]byte("correct horse battery staple"))
defer pw2.Destroy()

key, err := parsed.Unlock(ctx, tumbler.NewPasswordMethod(tumbler.NewScryptKDF(18, 8, 1), pw2))
// key is the recovered DEK; err collapses to a uniform ErrAuthFailed on ANY failure.
defer key.Destroy()

Two-factor (password + YubiKey)

True 2FA: the challenge is secret and derived from the password key, and the password key is also mixed directly into the key-encryption key — so both factors are mandatory.

tr, _ := transport.NewYkmanTransport("ykman") // fixed-argv; never a shell

m := tumbler.NewYubiKeyMethod(
    tr,
    tumbler.YubiKeyConfig{Slot: 2, KDF: tumbler.NewArgon2idKDF(4, 256*1024, 4)},
    passphrase, // *securebytes.SecureBytes
)
env, _ := tumbler.NewEnvelope(ctx, dek, tumbler.PolicyPasswordAndYubiKey, m)

Add a recovery code (escape hatch)

Recovery slots are additive under any policy — the lockout insurance for a lost key.

code, _ := tumbler.GenerateRecoveryCode()
defer code.Destroy()

_ = env.AddSlot(ctx, dek, tumbler.NewRecoveryMethod(code))

printed, _ := tumbler.FormatRecoveryCode(code) // display ONCE, never persist

Add or remove methods — O(1), no re-encryption

Enrolling a backup key or revoking a slot rewraps (or drops) a single keyslot. The payload and its sibling slots are never touched.

// Enroll a backup YubiKey — one extra slot.
_ = env.AddSlot(ctx, dek, backupMethod)

// Inspect the enrolled slots (non-secret metadata only).
for _, info := range env.SlotInfos() {
    fmt.Printf("%x  %s  %s\n", info.ID, info.Type, info.Label)
}

// Revoke a slot by ID (refuses to remove the last slot, or the last
// primary slot — swap a primary with add-before-remove).
_ = env.RemoveSlot(slotID)

// Enforce the real posture — recomputed from authenticated slot types,
// never the advisory header byte.
policy := env.EffectivePolicy()

// Safe-default validator: advisory warnings + a fatal error for dangerous shapes.
report, err := env.ValidateSafety(false)

Hardware-free testing

transport.FakeTransport is a deterministic in-memory HMAC-SHA1 backend with touch, presence, and error injection, so the full enroll → unlock → tamper path is exercised without a physical key-race clean, no CGO.

fake := transport.NewFakeTransport(2, []byte("device-hmac-secret-20"))
m := tumbler.NewYubiKeyMethod(fake, tumbler.YubiKeyConfig{Slot: 2}, nil)
// enroll + unlock exactly as production, deterministically.

📖 Full API reference on pkg.go.dev →


🧩 How It Works

A single random data key (DEK) seals the payload once. Every enrolled unlock method wraps that DEK in its own keyslot; any one slot recovers the DEK, and add/remove touches only that slot — never the payload or its siblings.

                         ┌──────────────── envelope (self-describing blob) ────────────────┐
   password ─▶ KDF ─┐    │  "TMBL" magic │ version │ advisory policy hint │ [ keyslots… ]  │
                    ├─▶  │  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐     │
   YubiKey  ─▶ CR  ─┤    │  │ slot: pw  │  │ slot: 2FA │  │ slot: key │  │ slot: rec │ ··· │
                    │    │  │ wraps DEK │  │ wraps DEK │  │ wraps DEK │  │ wraps DEK │     │
   recovery ────────┘    │  └───────────┘  └───────────┘  └───────────┘  └───────────┘     │
                         └─────────────────────────────────────────────────────────────────┘
                                     any one slot ─▶ DEK ─▶ decrypt payload

Per-slot key derivation. Each slot derives a single-use key-encryption key (KEK) with HKDF-SHA256PRK = 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).

Tamper-evident sealing. 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. Mutating any slot parameter breaks that slot's tag and surfaces as a uniform ErrAuthFaileddowngrade-evident by construction.

Self-describing, bounded wire format. Envelopes serialize to a versioned binary form ("TMBL" magic + version + advisory policy hint + length-prefixed keyslots). ParseEnvelope performs only structural and bounds checks; enforcement always uses EffectivePolicy() (recomputed from the authenticated slot types), never the advisory header byte.

Pluggable, self-authenticating KDFs. Choose Argon2id or scrypt per method; cost parameters are stored in — and authenticated by — each slot, so files describe exactly how to open themselves.

Secrets never leak. Every data key, key-encryption key, derived password key, YubiKey response, and recovery code lives in a securebytes.SecureBytes for its whole lifetime (mlock where supported, zero-on-destroy, redacted rendering, borrow-only access) and is Destroy-ed the moment it is no longer needed.

For the full threat model, the presence-vs-identity limit of the challenge-response path, and the v2 PIV (PIN + touch) upgrade seam, see SECURITY.md.


Key Features
  • 🔑 Independent keyslots — one DEK, many wrapping methods; the LUKS / systemd-cryptenroll model.
  • 🧩 O(1) enroll & revoke — add a backup key or drop a slot without re-encrypting the payload.
  • 🔐 Password · YubiKey · 2FA · Recovery — mix factors per envelope; recovery codes are additive under any policy.
  • 🛡️ Downgrade-evident AEAD — ChaCha20-Poly1305 with slot metadata bound as associated data; tampering collapses to a uniform ErrAuthFailed.
  • 🧮 Pluggable KDF — Argon2id or scrypt, cost parameters authenticated inside each slot.
  • 🧠 securebytes everywhere — mlocked, zero-on-destroy, borrow-only secret containers; no secret ever becomes a Go string.
  • 🪶 Pure-Go, CGO_ENABLED=0 — zero new crypto/hardware deps beyond golang.org/x/crypto; cross-compiles everywhere.
  • 🔌 Fixed-argv ykman transport — never a shell; plus a FakeTransport for deterministic, hardware-free tests.
  • 🧭 Safe-default validatorValidateSafety flags lockout-prone shapes (single-factor yubikey-only, no backup/recovery).
  • 📐 Versioned, bounded, self-describing format — strict parsing; enforcement always via EffectivePolicy.

🔑 Policies

Policy Factors Notes
PolicyPasswordOnly password Argon2id- or scrypt-hardened.
PolicyPasswordAndYubiKey password and YubiKey True 2FA; the secret challenge is derived from the password key, which is also mixed into the KEK.
PolicyYubiKeyOnly YubiKey Presence, not identity — the CR path has no PIN (see SECURITY.md); the safe-default validator requires a backup or recovery slot.

Recovery slots (MethodRecovery) are additive under any policy. Enforcement always uses EffectivePolicy() — recomputed from the authenticated slot types — never the advisory header byte.


📦 Packages

Package Import Responsibility
tumbler github.com/mrz1836/go-tumbler Envelope, unlock methods, KDFs, policy, and the wire format.
securebytes github.com/mrz1836/go-tumbler/securebytes The mlocked, redacted, borrow-only secret container used for every secret.
transport github.com/mrz1836/go-tumbler/transport The Transport interface, the ykman backend, FakeTransport, and the v2 PIV seam.

📚 Documentation

Resource Description
pkg.go.dev Complete, generated API reference for every package.
SECURITY.md Threat model, cryptographic primitives, honest limits, and the v2 PIV upgrade seam.
doc.go The package-level overview rendered at the top of the Go reference.

Heads up! go-tumbler is built for a small, auditable surface. Every cryptographic operation uses battle-tested, first-party packages:

  • crypto/hkdf + crypto/sha256 (Go standard library) for key combination and derivation
  • golang.org/x/crypto/chacha20poly1305 for AEAD sealing
  • golang.org/x/crypto/argon2, .../scrypt for password hardening
  • crypto/hmac + crypto/sha1 for the YubiKey challenge-response contract (see SECURITY.md)

🔐 Security

Important Disclaimer

⚠️ Experimental Software — Use at Your Own Risk

go-tumbler is experimental, open-source software provided "AS-IS" without warranty. By using go-tumbler, you acknowledge:

  • The on-disk format is unstable until an external cryptographic review; it may change without a compatibility shim during v0.x.
  • You control your keys: go-tumbler never transmits secrets. A lost factor with no backup or recovery slot means the payload is unrecoverable — by design.
  • Presence is not identity: the YubiKey challenge-response path proves possession, not identity (no PIN). Prefer password-and-yubikey.
  • No formal audit: this software has not yet undergone professional cryptographic auditing.

Do not protect data you cannot afford to lose without an enrolled backup or recovery slot.

For the full threat model, defended-vs-undefended matrix, and reporting instructions, see the Security Policy.


Additional Documentation & Repository Management
Development Setup (Getting Started)

Install the MAGE-X build tool for development:

# Install MAGE-X for development and building
go install github.com/magefile/mage@latest
go install github.com/mrz1836/go-mage/magex@latest
magex update:install
Build Commands

View all build commands:

magex help

Common commands:

  • magex test — Run the test suite
  • magex test:race — Run the test suite with the race detector
  • magex lint — Run all linters (golangci-lint against this repo's profile)
  • magex bench — Run benchmarks
  • magex deps:update — Update dependencies
GitHub Workflows

go-tumbler uses the Fortress workflow system for comprehensive CI/CD:

  • fortress-test-suite.yml — Complete test suite across multiple Go versions
  • fortress-code-quality.yml — Code quality checks (gofmt, golangci-lint, staticcheck)
  • fortress-security-scans.yml — Security vulnerability scanning (govulncheck, gitleaks)
  • fortress-test-fuzz.yml — Fuzz targets over the envelope parser, enroll/unlock round-trip, recovery-code and KDF-parameter parsing, and the transport response/version parsers
  • fortress-coverage.yml — Code coverage reporting to Codecov
  • fortress-release.yml — Automated releases via GoReleaser

See all workflows in .github/workflows/.

Updating Dependencies

To update all dependencies (Go modules, linters, and related tools), run:

magex deps:update

This brings all dependencies up to date in a single step, including Go modules and any managed tools. It is the recommended way to keep your development environment and CI in sync.


🧪 Examples & Tests

All unit tests run via GitHub Actions using the workflows in .github/workflows/. Every YubiKey path is exercised without hardware via transport.FakeTransport, so the full enroll/unlock/tamper surface is -race clean and CGO-free.

Run all tests (fast):

magex test

Run all tests with the race detector (slower):

magex test:race
Test Coverage

View the coverage report:

magex test:coverage

Coverage is automatically uploaded to Codecov on every commit. The suite includes table/property tests, tamper and fault-injection paths, adversarial-finding regressions, a checked-in golden wire-format fixture, and fuzz targets over ParseEnvelope, recovery-code / KDF-parameter parsing, and the transport response/version parsers. Statement coverage sits at 100% (securebytes), ~98% (core), and ~99% (transport).


🛠️ Code Standards

Read more about this Go project's code standards. In short: pure-Go, CGO_ENABLED=0, no new dependencies beyond golang.org/x/crypto and golang.org/x/sys; every secret in securebytes end-to-end; constant-time comparisons; slot metadata bound as AEAD associated data; and hardware-free tests for every path via transport.FakeTransport.


🤖 AI Usage & Assistant Guidelines

Read the AI Usage & Assistant Guidelines for details on how AI is used in this project and how to interact with AI assistants.


👥 Maintainers

MrZ
MrZ

🤝 Contributing

View the contributing guidelines and please follow the code of conduct.

How can I help?

All kinds of contributions are welcome 🙌! The most basic way to show your support is to star 🌟 the project, or to raise issues 💬. You can also support this project by becoming a sponsor on GitHub 👏 or by making a bitcoin donation to ensure this journey continues indefinitely! 🚀

Stars


📝 License

License

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

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

View Source
const RecoveryCodeLen = 32

RecoveryCodeLen is the byte length of a recovery code (256 bits of entropy).

Variables

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

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

func (k *Argon2idKDF) ID() KDFID

ID implements KDF.

func (*Argon2idKDF) MarshalParams

func (k *Argon2idKDF) MarshalParams() []byte

MarshalParams implements KDF: time(4) || memoryKiB(4) || threads(1).

func (*Argon2idKDF) SaltLen

func (k *Argon2idKDF) SaltLen() int

SaltLen implements KDF.

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

func ParseEnvelope(b []byte) (*Envelope, error)

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

func (e *Envelope) EffectivePolicy() Policy

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) Marshal

func (e *Envelope) Marshal() ([]byte, error)

Marshal serializes the envelope to its versioned wire form.

func (*Envelope) PolicyHint

func (e *Envelope) PolicyHint() Policy

PolicyHint returns the advisory policy byte from the header. Prefer EffectivePolicy for any enforcement decision.

func (*Envelope) RemoveSlot

func (e *Envelope) RemoveSlot(id [8]byte) error

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) SlotCount

func (e *Envelope) SlotCount() int

SlotCount returns the number of enrolled slots.

func (*Envelope) SlotInfos

func (e *Envelope) SlotInfos() []SlotInfo

SlotInfos returns the non-secret metadata of each slot, in file order.

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).

func (*Envelope) Version

func (e *Envelope) Version() uint8

Version returns the envelope's format version.

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

func WithLabel(label string) Option

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

Enroll implements Method.

func (*PasswordMethod) Type

func (m *PasswordMethod) Type() MethodType

Type 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
)

func (Policy) String

func (p Policy) String() string

String renders a Policy for diagnostics and CLI flags.

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

Enroll implements Method.

func (*RecoveryCodeMethod) Type

func (m *RecoveryCodeMethod) Type() MethodType

Type implements Method.

func (*RecoveryCodeMethod) Unlock

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

func NewScryptKDF(logN uint8, r, p uint32) *ScryptKDF

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) ID

func (k *ScryptKDF) ID() KDFID

ID implements KDF.

func (*ScryptKDF) MarshalParams

func (k *ScryptKDF) MarshalParams() []byte

MarshalParams implements KDF: logN(1) || r(4) || p(4).

func (*ScryptKDF) SaltLen

func (k *ScryptKDF) SaltLen() int

SaltLen implements KDF.

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) Type

func (m *YubiKeyMethod) Type() MethodType

Type implements Method.

func (*YubiKeyMethod) Unlock

func (m *YubiKeyMethod) Unlock(ctx context.Context, slot Slot) (*securebytes.SecureBytes, error)

Unlock implements Method.

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.

Jump to

Keyboard shortcuts

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