Files
orca/internal/secrets/secrets.go
T
Jon Chery 2e6436608f feat(P03): secrets subsystem (REQ-080) — AES-256-GCM + HKDF-SHA256 per-ns
internal/secrets/secrets.go: master key (0600), HKDF-SHA256 per-ns
derivation, AES-256-GCM per-line with AAD=line-number (anti-swap),
EncryptEnvFile/DecryptEnvFile, LoadCredential= map generation.
internal/cli/secrets.go: orca secrets set/get/list/rotate/delete.
Tests: round-trip, nonce uniqueness, AAD anti-swap, 0600 enforcement.

---ci---
project: orca
phase: 03
milestone: v0.11
status: execute
---/ci---
2026-08-07 04:47:33 +00:00

297 lines
9.9 KiB
Go

// Package secrets implements orca's encrypted .env.secrets subsystem
// (REQ-080, v0.11 milestone, gate C-19).
//
// The model is master-key + per-namespace sub-keys + per-line AES-256-GCM:
//
// - The master key is 32 random bytes generated at `orca init`, persisted
// at ClusterDir()/master.key with mode 0600. LoadMasterKey refuses to
// load a key whose permissions are looser than 0600.
// - Per-namespace sub-keys are derived with HKDF-SHA256 using the
// namespace name as the `info` parameter. This isolates namespaces
// cryptographically without requiring operators to manage per-ns keys.
// - Each .env.secrets line is encrypted independently with AES-256-GCM.
// The nonce is 12 random bytes. The AAD is the 1-based line number
// encoded as a big-endian uint32 — this binds each ciphertext to its
// position, defeating line-swap / line-reorder attacks.
// - The on-disk format is one base64(nonce||ciphertext||tag) blob per
// line. There is no `KEY=` prefix; the entire line is the encrypted
// blob. Plaintext lines use the standard `KEY=value` form.
//
// The package never logs plaintext or key material. slog calls carry
// only metadata (line counts, namespace name, error text).
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/hkdf"
)
// MasterKeyLen is the length in bytes of the master key (AES-256).
const MasterKeyLen = 32
// SubKeyLen is the length in bytes of a derived per-namespace sub-key.
const SubKeyLen = 32
// NonceLen is the length in bytes of the AES-GCM nonce.
const NonceLen = 12
// gcmTagLen is the length in bytes of the GCM authentication tag appended
// to the ciphertext by crypto/cipher's GCM Seal.
const gcmTagLen = 16
// MasterKeyMode is the required file mode for master.key. LoadMasterKey
// refuses anything looser.
const MasterKeyMode os.FileMode = 0o600
// GenerateMasterKey returns MasterKeyLen cryptographically random bytes
// from crypto/rand. Used by `orca init`.
func GenerateMasterKey() ([]byte, error) {
key := make([]byte, MasterKeyLen)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("secrets: generate master key: %w", err)
}
return key, nil
}
// LoadMasterKey reads the master key from path and enforces the 0600
// permission requirement (REQ-080). A missing file is an error; a file
// with permissions looser than 0600 is refused to prevent accidental
// exposure via group/world-readable key files.
func LoadMasterKey(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("secrets: stat master key %s: %w", path, err)
}
if info.Mode().Perm() != MasterKeyMode {
return nil, fmt.Errorf("secrets: master key %s has mode %04o, want %04o (REFUSE)", path, info.Mode().Perm(), MasterKeyMode)
}
key, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("secrets: read master key %s: %w", path, err)
}
if len(key) != MasterKeyLen {
return nil, fmt.Errorf("secrets: master key %s is %d bytes, want %d", path, len(key), MasterKeyLen)
}
return key, nil
}
// SaveMasterKey persists the master key to path with mode 0600 using an
// atomic write (temp file + rename). The parent directory is created if
// missing.
func SaveMasterKey(path string, key []byte) error {
if len(key) != MasterKeyLen {
return fmt.Errorf("secrets: master key is %d bytes, want %d", len(key), MasterKeyLen)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("secrets: mkdir %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".master-key-*")
if err != nil {
return fmt.Errorf("secrets: create temp: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(key); err != nil {
_ = tmp.Close()
return fmt.Errorf("secrets: write master key: %w", err)
}
if err := tmp.Chmod(MasterKeyMode); err != nil {
_ = tmp.Close()
return fmt.Errorf("secrets: chmod master key: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("secrets: sync master key: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("secrets: close master key: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("secrets: rename master key: %w", err)
}
return nil
}
// DeriveNamespaceKey derives a per-namespace sub-key from the master key
// using HKDF-SHA256 with the namespace name as the `info` parameter. The
// same (master, namespace) pair always yields the same sub-key; a
// different namespace yields a different sub-key. The salt is empty
// (the master key is already high-entropy).
func DeriveNamespaceKey(masterKey []byte, namespace string) ([]byte, error) {
if len(masterKey) != MasterKeyLen {
return nil, fmt.Errorf("secrets: derive: master key is %d bytes, want %d", len(masterKey), MasterKeyLen)
}
if namespace == "" {
return nil, errors.New("secrets: derive: namespace is empty")
}
out := make([]byte, SubKeyLen)
r := hkdf.New(sha256.New, masterKey, nil, []byte(namespace))
if _, err := io.ReadFull(r, out); err != nil {
return nil, fmt.Errorf("secrets: hkdf: %w", err)
}
return out, nil
}
// gcmFor returns an AES-256-GCM AEAD keyed by nsKey.
func gcmFor(nsKey []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(nsKey)
if err != nil {
return nil, fmt.Errorf("secrets: aes new: %w", err)
}
g, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("secrets: gcm new: %w", err)
}
return g, nil
}
// lineAAD returns the AAD for a line number: a 4-byte big-endian uint32.
// A non-positive line number yields an empty AAD (callers that do not
// care about position-binding pass 0).
func lineAAD(lineNumber int) []byte {
if lineNumber <= 0 {
return nil
}
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], uint32(lineNumber))
return buf[:]
}
// EncryptLine encrypts a single plaintext line with AES-256-GCM using
// nsKey, binding the ciphertext to lineNumber via AAD (defeats line-swap
// attacks). The returned string is base64(nonce || ciphertext || tag).
func EncryptLine(nsKey []byte, lineNumber int, plaintext []byte) (string, error) {
g, err := gcmFor(nsKey)
if err != nil {
return "", err
}
nonce := make([]byte, NonceLen)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("secrets: nonce: %w", err)
}
ct := g.Seal(nil, nonce, plaintext, lineAAD(lineNumber))
blob := make([]byte, 0, len(nonce)+len(ct))
blob = append(blob, nonce...)
blob = append(blob, ct...)
return base64.StdEncoding.EncodeToString(blob), nil
}
// DecryptLine decrypts a single base64(nonce||ciphertext||tag) line
// produced by EncryptLine. lineNumber MUST match the value used at
// encryption time or the GCM tag check fails.
func DecryptLine(nsKey []byte, lineNumber int, encoded string) ([]byte, error) {
g, err := gcmFor(nsKey)
if err != nil {
return nil, err
}
blob, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return nil, fmt.Errorf("secrets: base64 decode: %w", err)
}
if len(blob) < NonceLen+gcmTagLen {
return nil, fmt.Errorf("secrets: ciphertext too short: %d bytes", len(blob))
}
nonce := blob[:NonceLen]
ct := blob[NonceLen:]
pt, err := g.Open(nil, nonce, ct, lineAAD(lineNumber))
if err != nil {
return nil, fmt.Errorf("secrets: gcm open: %w", err)
}
return pt, nil
}
// EncryptEnvFile encrypts each plaintext line independently with a fresh
// nonce. Line numbers are 1-based. The returned string is one base64 blob
// per line (newline-separated). Blank lines are preserved as empty lines
// so a re-decrypt round-trips to the same line count.
func EncryptEnvFile(nsKey []byte, plaintextLines []string) (string, error) {
var b strings.Builder
for i, line := range plaintextLines {
if line == "" {
b.WriteString("\n")
continue
}
enc, err := EncryptLine(nsKey, i+1, []byte(line))
if err != nil {
return "", fmt.Errorf("secrets: encrypt line %d: %w", i+1, err)
}
b.WriteString(enc)
b.WriteString("\n")
}
return b.String(), nil
}
// DecryptEnvFile decrypts the content produced by EncryptEnvFile. Each
// non-empty line is decrypted at its 1-based position; empty lines are
// preserved as empty strings in the output slice.
func DecryptEnvFile(nsKey []byte, encryptedContent string) ([]string, error) {
encryptedContent = strings.TrimRight(encryptedContent, "\n")
if encryptedContent == "" {
return nil, nil
}
rawLines := strings.Split(encryptedContent, "\n")
out := make([]string, 0, len(rawLines))
for i, line := range rawLines {
if line == "" {
out = append(out, "")
continue
}
pt, err := DecryptLine(nsKey, i+1, line)
if err != nil {
return nil, fmt.Errorf("secrets: decrypt line %d: %w", i+1, err)
}
out = append(out, string(pt))
}
return out, nil
}
// GenerateLoadCredentialFile decrypts the encrypted lines and returns a
// map of KEY->value for use with systemd LoadCredential=. The plaintext
// lines are expected to be in `KEY=value` form; lines that do not match
// are skipped.
func GenerateLoadCredentialFile(nsKey []byte, encryptedLines []string) (map[string]string, error) {
out := make(map[string]string)
for i, line := range encryptedLines {
if line == "" {
continue
}
pt, err := DecryptLine(nsKey, i+1, line)
if err != nil {
return nil, fmt.Errorf("secrets: loadcredential decrypt line %d: %w", i+1, err)
}
s := string(pt)
idx := strings.IndexByte(s, '=')
if idx <= 0 {
continue
}
out[s[:idx]] = s[idx+1:]
}
return out, nil
}
// hmacSHA256 is a tiny helper retained for parity with manual HKDF
// fallback implementations; the package uses golang.org/x/crypto/hkdf
// directly, so this is unused in production but keeps the import set
// stable if a future refactor drops the x/crypto dependency.
func hmacSHA256(key, msg []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(msg)
return h.Sum(nil)
}
var _ = hmacSHA256