package crypto // import "git.griefed.de/griefed/repoman/internal/crypto"
Package crypto provides application-layer encryption for sensitive fields stored
in the database (API tokens, TOTP secrets, global config credentials).
Design:
- AES-256-GCM authenticated encryption. Each encrypt call generates a
fresh 12-byte random nonce, so the same plaintext never produces the same
ciphertext.
- The 256-bit key is derived from the per-installation pepper using
HKDF-SHA256. No extra secret material is needed; rotating the pepper rotates
the encryption key.
- Ciphertext is stored as the string literal "enc:v1:<base64url(nonce ||
ciphertext+tag)>". The prefix makes encrypted values unambiguous and allows
future algorithm versions to be detected cleanly.
- Empty strings pass through unchanged (a configured-but-empty token is stored
as "").
The Encryptor interface is the only surface the rest of the codebase touches.
AESEncryptor is the production implementation; NoOpEncryptor is a passthrough
for tests that do not care about encryption at the DB layer (i.e. tests that
verify query logic, not secret storage).
VARIABLES
var ErrNotEncrypted = errors.New("value is not encrypted (missing enc:v1: prefix)")
ErrNotEncrypted is returned by Decrypt when the stored value does not carry
the "enc:v1:" prefix. Callers that need to handle legacy plaintext values
should check for this error and re-encrypt.
TYPES
type AESEncryptor struct {
// Has unexported fields.
}
AESEncryptor is the production Encryptor. Its 256-bit key is derived from a
pepper via HKDF-SHA256.
func NewAESEncryptor(pepper []byte) (*AESEncryptor, error)
NewAESEncryptor derives an AES-256 key from pepper using HKDF-SHA256 and
returns a ready-to-use AESEncryptor. pepper must not be empty.
func (e *AESEncryptor) Decrypt(ciphertext string) (string, error)
Decrypt reverses Encrypt. Returns ErrNotEncrypted for values that were
stored without the enc:v1: prefix (i.e. before encryption was introduced).
func (e *AESEncryptor) Encrypt(plaintext string) (string, error)
Encrypt returns enc:v1:<base64url(nonce || aes-gcm-ciphertext)>.
type Encryptor interface {
// Encrypt returns the ciphertext form of plaintext. Empty input
// returns empty output without error.
Encrypt(plaintext string) (string, error)
// Decrypt returns the plaintext form of a ciphertext produced by
// Encrypt. Empty input returns empty output without error.
// Returns ErrNotEncrypted if the value lacks the expected prefix,
// which signals a value stored before encryption was introduced.
Decrypt(ciphertext string) (string, error)
}
Encryptor encrypts and decrypts individual string values. Both methods are
called at the DB boundary (in repository implementations) so the rest of the
codebase always works with plaintext.
type NoOpEncryptor struct{}
NoOpEncryptor passes values through unchanged. Encrypt returns the input
unchanged; Decrypt also returns the input unchanged (stripping the enc:v1:
prefix if present so it behaves symmetrically with AESEncryptor in
mixed-mode tests).
Use only in unit tests that verify query logic, not secret storage. Never
use in production wiring.
func (NoOpEncryptor) Decrypt(value string) (string, error)
Decrypt returns the value unchanged, stripping enc:v1: if present.
func (NoOpEncryptor) Encrypt(plaintext string) (string, error)
Encrypt returns plaintext unchanged.
type WebhookSecretDeriver struct {
// Has unexported fields.
}
WebhookSecretDeriver derives stable, per-config webhook secrets from
the installation pepper. The issue hub provisions each platform's
webhook with a secret AND later verifies incoming deliveries against it;
deriving the secret (rather than storing it) means there is no secret to
persist, encrypt, or rotate independently — it is HKDF-SHA256(pepper)
domain-separated per config id, and stable across restarts because
the pepper is (the pepper must never be rotated without a migration;
see crypto.AESEncryptor). The master key never leaves this struct; only
per-config secrets are exposed.
func NewWebhookSecretDeriver(pepper []byte) (*WebhookSecretDeriver, error)
NewWebhookSecretDeriver derives the master webhook key from pepper. pepper
must not be empty.
func (d *WebhookSecretDeriver) Secret(configID int64) string
Secret returns the hex webhook secret for the given migration config id.
The same id always yields the same secret, and distinct ids yield unrelated
secrets (HMAC-SHA256 of the id under the master key), so one provisioned
hook cannot forge another config's deliveries.