2cbfb5d561
---ci--- project: orca phase: 8 milestone: v0.12 status: execute ---/ci--- internal/seal/seal.go: AES-256-GCM sealing with HKDF-SHA256 key derivation from OIDC subject. Seal/Unseal (OIDC mode), SealWithCA/ UnsealWithCA (mTLS-only offline path), SaveSealed/LoadSealed (0600), VerifySealedKey. internal/seal/shamir.go: GF(256) Shamir secret sharing. ShamirSplit (5 shards, threshold 3), ShamirCombine (Lagrange interpolation). UnsealWithShamir for IdP-lost recovery (C-35). 9 tests: seal/unseal round-trip, wrong-sub fails, Shamir 3-of-5 recovery (multiple subsets), 2-shards fails, CA mode, mode mismatch, shard encoding, verification. All pass. Full build + vet green.
254 lines
8.2 KiB
Go
254 lines
8.2 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
|