gitmote

gitmote / internal / secrets / keyring.go  ·  history  ·  branches

5965 bytes · raw

// Package secrets encrypts per-repo CI secrets at rest with a server-held master
// key, so a compromise of the S3 replica / DB snapshot (the litestream WAL, the
// object bucket) does not leak secret values — the master key lives only in the
// process env, not in the replicated DB. It is **not** a defense against a
// compromised running server, which holds the key by necessity to inject secrets
// into the runner (see docs/architecture/safety.md and tasks/16-ci.md §Stage 0 #3).
//
// keyring.go is pure crypto: no meta, no I/O beyond reading keys from the env at
// construction — unit-testable in isolation. The meta-backed store and the
// encrypt/decrypt service live alongside it.
package secrets

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/hkdf"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"os"
	"strconv"
	"strings"
)

const (
	// keyEnvPrefix is the env-var stem for a master key; the version number
	// follows (GITMOTE_CI_SECRET_KEY_V1, …). The value is base64 of 32 raw bytes.
	keyEnvPrefix = "GITMOTE_CI_SECRET_KEY_V"
	masterKeyLen = 32 // AES-256
	ivLen        = 12 // GCM standard nonce
	// subkeyInfo is the HKDF info stem; the repo id follows, giving a distinct
	// per-repo subkey from one master (repo isolation for free).
	subkeyInfo = "ci-secret:"
)

// Envelope is an encrypted secret at rest: the key version that sealed it, the
// random IV, and the AES-GCM ciphertext (which carries the auth tag).
type Envelope struct {
	Version int
	IV      []byte
	CT      []byte
}

// Keyring holds the server-held master keys, one per version. The highest
// version present is *current*: new secrets seal under it, while existing
// envelopes decrypt under their own version — lazy, O(1) rotation with no
// re-encryption. It is immutable after construction, so safe for concurrent use.
type Keyring struct {
	keys    map[int][masterKeyLen]byte
	current int
}

// NewKeyringFromEnv builds the keyring from GITMOTE_CI_SECRET_KEY_V<n> env vars
// (base64 of 32 bytes each). With none set it returns a disabled keyring
// (Enabled() == false) rather than an error, so an instance without CI secrets
// runs unchanged. A present-but-malformed key is a hard error — fail loud rather
// than silently drop a key version.
func NewKeyringFromEnv() (*Keyring, error) {
	kr := &Keyring{keys: map[int][masterKeyLen]byte{}}
	for _, kv := range os.Environ() {
		name, val, _ := strings.Cut(kv, "=")
		if !strings.HasPrefix(name, keyEnvPrefix) {
			continue
		}
		if strings.TrimSpace(val) == "" {
			continue // an empty/cleared var is "unset", not a malformed key
		}
		v, err := strconv.Atoi(strings.TrimPrefix(name, keyEnvPrefix))
		if err != nil || v < 1 {
			return nil, fmt.Errorf("secrets: %s has a non-numeric key version", name)
		}
		raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(val))
		if err != nil {
			return nil, fmt.Errorf("secrets: %s is not valid base64: %w", name, err)
		}
		if len(raw) != masterKeyLen {
			return nil, fmt.Errorf("secrets: %s must decode to %d bytes, got %d", name, masterKeyLen, len(raw))
		}
		var k [masterKeyLen]byte
		copy(k[:], raw)
		kr.keys[v] = k
		if v > kr.current {
			kr.current = v
		}
	}
	return kr, nil
}

// Enabled reports whether any master key is configured. When false, Encrypt
// fails and the secrets UI/injection are inert.
func (k *Keyring) Enabled() bool { return len(k.keys) > 0 }

// Encrypt seals plaintext for (repoID, name) under the current key version. The
// IV is fresh per call, so two encryptions of the same value differ. The AAD
// binds the envelope to (repoID, name, version): a ciphertext moved to a
// different repo/name/version fails to open.
func (k *Keyring) Encrypt(repoID int64, name, plaintext string) (Envelope, error) {
	if !k.Enabled() {
		return Envelope{}, errors.New("secrets: no master key configured (set GITMOTE_CI_SECRET_KEY_V1)")
	}
	gcm, err := k.gcm(k.current, repoID)
	if err != nil {
		return Envelope{}, err
	}
	iv := make([]byte, ivLen)
	if _, err := rand.Read(iv); err != nil {
		return Envelope{}, err
	}
	ct := gcm.Seal(nil, iv, []byte(plaintext), aad(repoID, name, k.current))
	return Envelope{Version: k.current, IV: iv, CT: ct}, nil
}

// Decrypt opens an envelope for (repoID, name). It fails closed: an absent key
// version, a bad IV, or a tag mismatch (wrong repo/name/version, or tampering)
// all return an error — never a silent empty value or a panic. The error carries
// the repo/name (not secret) but never the plaintext.
func (k *Keyring) Decrypt(repoID int64, name string, e Envelope) (string, error) {
	if len(e.IV) != ivLen {
		return "", fmt.Errorf("secrets: bad IV length %d for repo %d secret %q", len(e.IV), repoID, name)
	}
	gcm, err := k.gcm(e.Version, repoID)
	if err != nil {
		return "", err
	}
	pt, err := gcm.Open(nil, e.IV, e.CT, aad(repoID, name, e.Version))
	if err != nil {
		return "", fmt.Errorf("secrets: decrypt failed for repo %d secret %q: %w", repoID, name, err)
	}
	return string(pt), nil
}

// gcm derives the per-repo subkey for the given master version via HKDF and
// returns an AES-256-GCM AEAD over it. An unknown version fails closed.
func (k *Keyring) gcm(version int, repoID int64) (cipher.AEAD, error) {
	master, ok := k.keys[version]
	if !ok {
		return nil, fmt.Errorf("secrets: key version %d is not configured (set %s%d) — cannot process this secret", version, keyEnvPrefix, version)
	}
	sub, err := hkdf.Key(sha256.New, master[:], nil, subkeyInfo+strconv.FormatInt(repoID, 10), masterKeyLen)
	if err != nil {
		return nil, err
	}
	block, err := aes.NewCipher(sub)
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

// aad binds an envelope to its (repoID, name, version). name is NUL-free (the
// service validates it), so the concatenation is unambiguous.
func aad(repoID int64, name string, version int) []byte {
	return []byte(strconv.FormatInt(repoID, 10) + "\x00" + name + "\x00" + strconv.Itoa(version))
}