// 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