crypto

package
v0.6.1 Latest Latest
Warning

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

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

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

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

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

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

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

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

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

func DeriveKEK(passphrase, salt []byte, p KDFParams) []byte

DeriveKEK runs Argon2id over (passphrase, salt) using p and returns the resulting key. The output length is p.KeyLen.

func DeriveSubKey

func DeriveSubKey(masterKey []byte, info string) ([]byte, error)

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

func GenerateRepoKey() ([]byte, error)

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

func GenerateSalt() ([]byte, error)

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

func HMACSHA256(key, data []byte) []byte

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

func Open(key, sealed []byte) ([]byte, error)

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

func Seal(key, plaintext []byte) ([]byte, error)

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

func VerifyHMACSHA256(key, data, candidate []byte) bool

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.

func (KDFParams) Validate

func (p KDFParams) Validate() error

Validate checks that the parameters are within sane bounds. Loaded configs are run through this before DeriveKEK so a corrupted or tampered on-disk config can neither trivialize brute-force (floors) nor turn the KDF into a memory bomb or an endless loop (ceilings).

Jump to

Keyboard shortcuts

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