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