Documentation
¶
Overview ¶
Package crypto provides the symmetric primitives used by sentra: Argon2id passphrase-to-KEK derivation and versioned AEAD blob sealing.
The plaintext repo key (32 bytes) is wrapped on disk with a KEK derived from the user's passphrase. New blobs are sealed under XChaCha20-Poly1305 with a fresh 24-byte random nonce. Open also accepts legacy v1 AES-GCM blobs for repositories created before the v2 format switch.
Index ¶
- Constants
- Variables
- func DeriveKEK(passphrase, salt []byte, p KDFParams) []byte
- func DeriveSubKey(masterKey []byte, info string) ([]byte, error)
- func GenerateRepoKey() ([]byte, error)
- func GenerateSalt() ([]byte, error)
- func HMACSHA256(key, data []byte) []byte
- func Open(key, sealed []byte) ([]byte, error)
- func Seal(key, plaintext []byte) ([]byte, error)
- func VerifyHMACSHA256(key, data, candidate []byte) bool
- func Zeroize(b []byte)
- type KDFParams
Constants ¶
const ( // BlobVersion is the wire format version Seal writes for new // blobs. // // v3: XChaCha20-Poly1305, 24-byte nonce, version byte bound // into the AEAD's associated data. Sealed under // []byte{0x03}, so flipping the on-disk version byte to // any other value invalidates the tag. // // Open still accepts older versions for backward compatibility: // v2: XChaCha20-Poly1305, 24-byte nonce, AD=nil. // v1: AES-GCM, 12-byte nonce within a 24-byte nonce slot, // AD=nil. // // All three share an identical wire layout (1 version + 24 // nonce bytes + ciphertext + tag); the only difference is what // the AEAD authenticates. BlobVersion byte = 0x03 // KeyLen is the required byte length of the symmetric key // passed to Seal and Open. XChaCha20-Poly1305 requires 32 bytes. KeyLen = 32 )
const ( MaxMemoryKiB uint32 = 1 << 20 // 1 GiB MaxTime uint32 = 64 MaxThreads uint8 = 64 )
MaxMemoryKiB, MaxTime, and MaxThreads are the ceilings Validate enforces. They are denial-of-service bounds on an UNTRUSTED config, not tuning guidance: repo.Open must derive the KEK from the on-disk params before it can check the config MAC (the MAC key derives from the KEK), so anyone with bucket write access could otherwise plant a Memory of 16 GiB and OOM-kill every client, or a Time in the millions and hang it, before ErrConfigTampered was ever reachable. Each ceiling is therefore something any client machine can honor in bounded time: 1 GiB of memory, 64 passes over it, 64 lanes. The worst case they admit — all three at the ceiling — is 64 passes over 1 GiB, roughly 64 GiB of memory traffic, which Argon2id finishes on the order of a minute on a laptop: an annoyance, not a hang, and the process still needs only the 1 GiB. Lanes do not add to that cost; they only split each pass across threads, so MaxThreads bounds goroutine count rather than time or memory. DefaultKDFParams (64 MiB, 3 passes, 4 lanes) sits far inside all three, so a future default bump has headroom without moving them.
const ( // SaltLen is the length in bytes of the KDF salt stored in the // encrypted repo config. 16 bytes (128 bits) is the design default // and matches Argon2id common practice. SaltLen = 16 // RepoKeyLen is the length in bytes of the repo key. XChaCha20- // Poly1305 requires 32 bytes. RepoKeyLen = KeyLen )
const MACSize = 32
MACSize is the byte length of an HMAC-SHA256 tag — exactly 32 bytes. Exposed as a constant so callers building wire formats can size buffers without importing crypto/sha256 themselves.
const MinMemoryKiB uint32 = 4 * 1024
MinMemoryKiB is the lower bound enforced by Validate on Memory. 4 MiB is well below the 64 MiB design default but still high enough that a corrupted or tampered config cannot trivialize brute-force against the wrapped repo key. OWASP's 2024+ Argon2id recommendations (m >= 19 MiB) sit comfortably above this floor; the floor exists to catch configs whose memory parameter has been zeroed or downgraded to single-digit KiB, not to enforce best practice.
const SubKeyLen = 32
SubKeyLen is the byte length of a sub-key produced by DeriveSubKey. 32 bytes (256 bits) is the right size for HMAC-SHA256 and gives a comfortable security margin for any other symmetric primitive the caller might pair the sub-key with.
Variables ¶
var ErrInvalidKey = errors.New("crypto: key must be 32 bytes")
ErrInvalidKey is returned when the caller passes a key of the wrong length to Seal or Open.
var ErrSealedTooShort = errors.New("crypto: sealed blob too short")
ErrSealedTooShort is returned when Open is given input shorter than a valid header.
Functions ¶
func DeriveKEK ¶
DeriveKEK runs Argon2id over (passphrase, salt) using p and returns the resulting key. The output length is p.KeyLen.
func DeriveSubKey ¶
DeriveSubKey produces a domain-separated 32-byte sub-key from masterKey using HKDF-Expand with SHA-256. The masterKey is expected to already be high-entropy (e.g., the output of DeriveKEK / Argon2id); the salt is empty per the HKDF "the master key is already pseudorandom" recipe.
info is the domain separator: a short, fixed string identifying the sub-key's purpose. Different purposes MUST use different info strings so a sub-key compromise in one context can't affect the others. Convention in this codebase: lowercase hyphenated, ending in "/vN" so a future protocol change can rotate without invalidating existing on-disk artifacts.
Examples:
- "sentra/config-mac/v1" — config blob authentication
- "sentra/manifest-sig/v1" — future per-snapshot signing
func GenerateRepoKey ¶
GenerateRepoKey returns a fresh random 32-byte repo key from crypto/rand. The repo key encrypts every blob, manifest, and index; it is itself stored encrypted (wrapped) by a passphrase-derived KEK.
func GenerateSalt ¶
GenerateSalt returns a fresh random 16-byte salt suitable for Argon2id passphrase-to-KEK derivation. The salt is stored alongside the wrapped repo key in the encrypted repo config.
func HMACSHA256 ¶
HMACSHA256 returns the HMAC-SHA256 tag over data, keyed with key. The output is exactly MACSize bytes. Pass the result of DeriveSubKey as key — using KEK directly would conflate the authentication and encryption purposes of the same secret, which is a well-known footgun.
func Open ¶
Open decrypts a blob produced by Seal. It validates the version byte, extracts the nonce, and verifies the AEAD tag (with version- as-AD on v3, no AD on v2/v1). Any tampering with the version, nonce, ciphertext, or tag produces a non-nil error.
func Seal ¶
Seal encrypts plaintext with key under XChaCha20-Poly1305 and returns the v3 versioned blob layout used by sentra:
[1 byte version (0x03)][24 byte random nonce][ciphertext + 16 byte tag]
The version byte is included as AEAD associated data, so any tamper of the on-disk version byte invalidates the tag — closing the future-downgrade path that v2 left open by sealing under AD=nil.
The nonce is generated with crypto/rand and every nonce byte is consumed by the AEAD.
func VerifyHMACSHA256 ¶
VerifyHMACSHA256 returns true iff candidate equals the HMAC-SHA256 of data under key. Comparison is constant-time so the caller doesn't have to remember to reach for hmac.Equal at the call site.
func Zeroize ¶
func Zeroize(b []byte)
Zeroize overwrites every byte of b with zero. Used by callers holding defensive copies of secret material — passphrases, derived keys — to collapse the leak window between "secret no longer needed" and "Go's GC reclaims the slice" into "between Zeroize and the next allocation."
Best-effort: the Go runtime is free to move slices during goroutine scheduling and GC, so a heap dump or live memory acquisition during execution may still recover the bytes from a stale copy. The threat model documents this explicitly. We still wipe the live slice because that's the cheapest defense available to a CLI process holding short-lived key material.
Previously each package that handled secrets defined its own unexported `zeroize` (cli, repo, ui — three identical bodies). Centralizing here keeps the implementation one-source-of-truth and lets a future improvement (e.g. //go:noinline + runtime. KeepAlive guards) land in exactly one place.
Types ¶
type KDFParams ¶
type KDFParams struct {
// Time is the number of passes Argon2id makes over memory.
// Higher values increase the cost of brute-force attacks linearly.
Time uint32
// Memory is the working-set size in KiB. 64 MiB is the design
// default. Watch the unit: setting this to "64*1024*1024" instead
// of "64*1024" requests 64 GiB and will OOM the process.
Memory uint32
// Threads is the parallelism factor (lanes). Increasing this
// reduces wall-clock time on the legitimate side without
// proportionally raising attacker cost.
Threads uint8
// KeyLen is the output length in bytes. 32 (AES-256) for sentra.
KeyLen uint32
}
KDFParams configures Argon2id key derivation.
Defaults come from DefaultKDFParams; the parameters are stored in the encrypted repo config so future bumps do not break existing repos.
func DefaultKDFParams ¶
func DefaultKDFParams() KDFParams
DefaultKDFParams returns the parameters used at sentra init time: Argon2id, time=3, memory=64 MiB, parallelism=4, 32-byte output.