2e6436608f
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---
297 lines
9.9 KiB
Go
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
|