Files
orca/internal/seal/seal.go
T
Jon Chery 9e832387c6 feat(P05): seal/audit CLI + chain race fix + key zeroing (REQ-154)
New CLI commands:
- orca cluster seal: OIDC/CA-derived seal + Shamir 3-of-5 shards
- orca cluster unseal: OIDC/CA unseal + --recovery Shamir path
- orca doctor audit: VerifyChain + chain head report
- orca doctor modes: EnforceFileModes across ORCA_HOME

Fixes:
- audit hash-chain race: Append uses BEGIN IMMEDIATE transaction
  (concurrent appends no longer corrupt tamper-evidence)
- secrets rotate-master: re-seals to OIDC on sealed clusters
  (was writing raw key, docstring claimed re-seal)
- key zeroing: ZeroKey helper + defer after master/namespace key use
  (defense-in-depth against pprof heap extraction)
- store.Open: busy_timeout(5000) pragma (concurrent writers wait)

Tests: 18 new test functions (seal round-trip, Shamir recovery, doctor
audit tamper detection, doctor modes 0644 rejection, concurrent append
chain integrity, rotate-master re-seal, key zeroing).

---ci---
project: orca
phase: 5
milestone: v0.13
status: complete
requirements:
  covered: [154]
---/ci---
2026-08-07 21:06:39 +00:00

268 lines
8.7 KiB
Go

// Package seal implements the master key sealing mechanism (REQ-147,
// D-241, C-35). The secrets master key (32 random bytes) is sealed
// (encrypted) with a key derived from an OIDC ID token exchange at
// unseal time. The raw master key never touches disk; the sealed blob
// (salt + ciphertext) is stored at ClusterDir()/master.key.sealed (0600).
//
// Shamir 3-of-5 recovery: at seal time, 5 shards are generated; the
// operator stores them offline. If the IdP is permanently lost, the
// master key can be recovered with any 3 of the 5 shards. No backdoor.
//
// For the mTLS-only offline path (no OIDC), the seal key is derived
// from the cluster's own CA (the operator holds the CA, not a password).
package seal
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"golang.org/x/crypto/hkdf"
)
// SealedBlob is the on-disk format for the sealed master key.
// Salt is used with the OIDC token sub (or CA fingerprint) to derive
// the unwrapping key via HKDF-SHA256.
type SealedBlob struct {
Salt []byte `json:"salt"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
// Mode indicates how the seal key was derived: "oidc" or "ca".
Mode string `json:"mode"`
// Hint is a non-secret hint for recovery (e.g. the OIDC issuer URL
// or the CA fingerprint). Used to identify which seal key to use.
Hint string `json:"hint"`
}
// Seal encrypts the master key with a key derived from the OIDC token
// subject + salt. The seal key = HKDF-SHA256(oidcSub, salt, info="orca-master-key-seal").
// Returns the sealed blob (to store on disk) + 5 Shamir shards (to
// print for offline recovery).
func Seal(masterKey []byte, oidcSub string, issuerHint string) (*SealedBlob, [][]byte, error) {
if len(masterKey) != 32 {
return nil, nil, fmt.Errorf("seal: master key must be 32 bytes, got %d", len(masterKey))
}
if oidcSub == "" {
return nil, nil, fmt.Errorf("seal: oidc sub is empty")
}
salt := make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, nil, fmt.Errorf("seal: salt rand: %w", err)
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return nil, nil, fmt.Errorf("seal: nonce rand: %w", err)
}
sealKey := deriveSealKey(oidcSub, salt)
block, err := aes.NewCipher(sealKey)
if err != nil {
return nil, nil, fmt.Errorf("seal: aes: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("seal: gcm: %w", err)
}
ciphertext := aead.Seal(nil, nonce, masterKey, []byte("orca-seal"))
blob := &SealedBlob{
Salt: salt,
Nonce: nonce,
Ciphertext: ciphertext,
Mode: "oidc",
Hint: issuerHint,
}
// Generate 5 Shamir shards for recovery.
shards, err := ShamirSplit(masterKey, 5, 3)
if err != nil {
return nil, nil, fmt.Errorf("seal: shamir: %w", err)
}
return blob, shards, nil
}
// Unseal decrypts the sealed master key using the OIDC token subject.
// The seal key = HKDF-SHA256(oidcSub, salt, info="orca-master-key-seal").
func Unseal(blob *SealedBlob, oidcSub string) ([]byte, error) {
if blob.Mode != "oidc" {
return nil, fmt.Errorf("seal: blob mode is %q, not oidc", blob.Mode)
}
sealKey := deriveSealKey(oidcSub, blob.Salt)
block, err := aes.NewCipher(sealKey)
if err != nil {
return nil, fmt.Errorf("seal: aes: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("seal: gcm: %w", err)
}
masterKey, err := aead.Open(nil, blob.Nonce, blob.Ciphertext, []byte("orca-seal"))
if err != nil {
return nil, fmt.Errorf("seal: decrypt (wrong sub or corrupted): %w", err)
}
return masterKey, nil
}
// UnsealWithShamir recovers the master key from a quorum of Shamir
// shards (3 of 5). Used when the IdP is permanently lost (C-35).
func UnsealWithShamir(blob *SealedBlob, shards [][]byte) ([]byte, error) {
if len(shards) < 3 {
return nil, fmt.Errorf("seal: need at least 3 shards, got %d", len(shards))
}
masterKey, err := ShamirCombine(shards[:3])
if err != nil {
return nil, fmt.Errorf("seal: shamir combine: %w", err)
}
if len(masterKey) != 32 {
return nil, fmt.Errorf("seal: recovered key is %d bytes, want 32", len(masterKey))
}
return masterKey, nil
}
// SealWithCA encrypts the master key using a key derived from the
// cluster CA fingerprint (mTLS-only offline path, D-241). The seal
// key = HKDF-SHA256(caFingerprint, salt, info="orca-master-key-seal-ca").
func SealWithCA(masterKey []byte, caFingerprint string) (*SealedBlob, error) {
if len(masterKey) != 32 {
return nil, fmt.Errorf("seal: master key must be 32 bytes, got %d", len(masterKey))
}
salt := make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("seal: salt rand: %w", err)
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("seal: nonce rand: %w", err)
}
sealKey := deriveCASealKey(caFingerprint, salt)
block, err := aes.NewCipher(sealKey)
if err != nil {
return nil, fmt.Errorf("seal: aes: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("seal: gcm: %w", err)
}
ciphertext := aead.Seal(nil, nonce, masterKey, []byte("orca-seal-ca"))
return &SealedBlob{
Salt: salt,
Nonce: nonce,
Ciphertext: ciphertext,
Mode: "ca",
Hint: caFingerprint,
}, nil
}
// UnsealWithCA decrypts using the CA fingerprint.
func UnsealWithCA(blob *SealedBlob, caFingerprint string) ([]byte, error) {
if blob.Mode != "ca" {
return nil, fmt.Errorf("seal: blob mode is %q, not ca", blob.Mode)
}
sealKey := deriveCASealKey(caFingerprint, blob.Salt)
block, err := aes.NewCipher(sealKey)
if err != nil {
return nil, fmt.Errorf("seal: aes: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("seal: gcm: %w", err)
}
masterKey, err := aead.Open(nil, blob.Nonce, blob.Ciphertext, []byte("orca-seal-ca"))
if err != nil {
return nil, fmt.Errorf("seal: decrypt (wrong CA or corrupted): %w", err)
}
return masterKey, nil
}
// deriveSealKey derives a 32-byte AES key from the OIDC subject + salt
// via HKDF-SHA256.
func deriveSealKey(oidcSub string, salt []byte) []byte {
hk := hkdf.New(sha256.New, []byte(oidcSub), salt, []byte("orca-master-key-seal"))
key := make([]byte, 32)
hk.Read(key)
return key
}
// deriveCASealKey derives a 32-byte AES key from the CA fingerprint +
// salt via HKDF-SHA256.
func deriveCASealKey(caFingerprint string, salt []byte) []byte {
hk := hkdf.New(sha256.New, []byte(caFingerprint), salt, []byte("orca-master-key-seal-ca"))
key := make([]byte, 32)
hk.Read(key)
return key
}
// SaveSealed writes the sealed blob to disk at 0600.
func SaveSealed(path string, blob *SealedBlob) error {
data, err := json.MarshalIndent(blob, "", " ")
if err != nil {
return fmt.Errorf("seal: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("seal: write tmp: %w", err)
}
return os.Rename(tmp, path)
}
// LoadSealed reads the sealed blob from disk.
func LoadSealed(path string) (*SealedBlob, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("seal: read: %w", err)
}
var blob SealedBlob
if err := json.Unmarshal(data, &blob); err != nil {
return nil, fmt.Errorf("seal: parse: %w", err)
}
return &blob, nil
}
// EncodeShard base64-encodes a shard for display/storage.
func EncodeShard(shard []byte) string {
return base64.StdEncoding.EncodeToString(shard)
}
// DecodeShard base64-decodes a shard.
func DecodeShard(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
// VerifySealedKey verifies that a candidate master key matches the
// sealed blob (by re-sealing and comparing). Used after unseal to
// confirm correctness before use.
func VerifySealedKey(blob *SealedBlob, masterKey []byte, oidcSub string) bool {
sealKey := deriveSealKey(oidcSub, blob.Salt)
block, err := aes.NewCipher(sealKey)
if err != nil {
return false
}
aead, err := cipher.NewGCM(block)
if err != nil {
return false
}
ct := aead.Seal(nil, blob.Nonce, masterKey, []byte("orca-seal"))
return hmac.Equal(ct, blob.Ciphertext)
}
// ensure binary import is used (for shard encoding).
var _ = binary.BigEndian
// ZeroKey overwrites the byte slice with zeros. Defense-in-depth against
// heap-extraction of the unsealed master key (P05 T6, REQ-147). Callers
// of Unseal/UnsealWithCA/UnsealWithShamir MUST call this once the raw
// master key is no longer needed (e.g. after deriving namespace sub-keys
// or re-sealing). Best-effort under Go's GC but raises the bar against
// pprof heap scraping.
//
// ZeroKey is safe to call on nil or empty slices (no-op).
func ZeroKey(b []byte) {
for i := range b {
b[i] = 0
}
}