From 2cbfb5d5618b8c4920880a8b434004be6bc5bf96 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Fri, 7 Aug 2026 11:14:01 +0000 Subject: [PATCH] feat(P08): master key seal-to-OIDC + Shamir 3-of-5 (REQ-147, D-241, C-35) ---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. --- internal/seal/seal.go | 253 +++++++++++++++++++++++++++++++++++++ internal/seal/seal_test.go | 174 +++++++++++++++++++++++++ internal/seal/shamir.go | 160 +++++++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 internal/seal/seal.go create mode 100644 internal/seal/seal_test.go create mode 100644 internal/seal/shamir.go diff --git a/internal/seal/seal.go b/internal/seal/seal.go new file mode 100644 index 0000000..2af6999 --- /dev/null +++ b/internal/seal/seal.go @@ -0,0 +1,253 @@ +// 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 diff --git a/internal/seal/seal_test.go b/internal/seal/seal_test.go new file mode 100644 index 0000000..e3c3e35 --- /dev/null +++ b/internal/seal/seal_test.go @@ -0,0 +1,174 @@ +package seal + +import ( + "bytes" + "testing" +) + +// TestSealUnsealRoundTrip verifies the OIDC seal/unseal round-trip. +func TestSealUnsealRoundTrip(t *testing.T) { + masterKey := make([]byte, 32) + for i := range masterKey { + masterKey[i] = byte(i) + } + blob, shards, err := Seal(masterKey, "user-oidc-sub-123", "https://idp.example") + if err != nil { + t.Fatalf("Seal: %v", err) + } + if len(shards) != 5 { + t.Errorf("shards = %d, want 5", len(shards)) + } + if blob.Mode != "oidc" { + t.Errorf("mode = %q, want oidc", blob.Mode) + } + unsealed, err := Unseal(blob, "user-oidc-sub-123") + if err != nil { + t.Fatalf("Unseal: %v", err) + } + if !bytes.Equal(unsealed, masterKey) { + t.Error("unsealed key != original") + } +} + +// TestSealWrongSubFails verifies unseal with the wrong subject fails. +func TestSealWrongSubFails(t *testing.T) { + masterKey := make([]byte, 32) + blob, _, err := Seal(masterKey, "correct-sub", "https://idp") + if err != nil { + t.Fatalf("Seal: %v", err) + } + _, err = Unseal(blob, "wrong-sub") + if err == nil { + t.Error("Unseal with wrong sub should fail") + } +} + +// TestShamirRecovery verifies 3-of-5 recovery works. +func TestShamirRecovery(t *testing.T) { + masterKey := make([]byte, 32) + for i := range masterKey { + masterKey[i] = byte(i + 1) + } + blob, shards, err := Seal(masterKey, "sub-123", "https://idp") + if err != nil { + t.Fatalf("Seal: %v", err) + } + // Recover with first 3 shards. + recovered, err := UnsealWithShamir(blob, shards[:3]) + if err != nil { + t.Fatalf("UnsealWithShamir (3 shards): %v", err) + } + if !bytes.Equal(recovered, masterKey) { + t.Error("recovered key != original") + } + // Recover with last 3 shards (different subset). + recovered2, err := UnsealWithShamir(blob, shards[2:]) + if err != nil { + t.Fatalf("UnsealWithShamir (last 3): %v", err) + } + if !bytes.Equal(recovered2, masterKey) { + t.Error("recovered key (last 3) != original") + } +} + +// TestShamirTwoShardsFails verifies 2 shards are insufficient. +func TestShamirTwoShardsFails(t *testing.T) { + masterKey := make([]byte, 32) + _, shards, _ := Seal(masterKey, "sub", "https://idp") + _, err := UnsealWithShamir(nil, shards[:2]) + if err == nil { + t.Error("2 shards should fail") + } +} + +// TestShamirSplitCombine verifies direct split/combine round-trip. +func TestShamirSplitCombine(t *testing.T) { + secret := make([]byte, 32) + for i := range secret { + secret[i] = byte(i + 100) + } + if len(secret) != 32 { + t.Fatalf("test secret is %d bytes, want 32", len(secret)) + } + shards, err := ShamirSplit(secret, 5, 3) + if err != nil { + t.Fatalf("ShamirSplit: %v", err) + } + if len(shards) != 5 { + t.Errorf("shards = %d, want 5", len(shards)) + } + // Any 3 shards reconstruct the secret. + for _, combo := range [][][]byte{shards[:3], shards[1:4], shards[2:5], [][]byte{shards[0], shards[2], shards[4]}} { + recovered, err := ShamirCombine(combo) + if err != nil { + t.Fatalf("Combine: %v", err) + } + if !bytes.Equal(recovered, secret) { + t.Error("recovered != secret") + } + } +} + +// TestSealWithCA verifies the mTLS-only offline path. +func TestSealWithCA(t *testing.T) { + masterKey := make([]byte, 32) + for i := range masterKey { + masterKey[i] = byte(i) + } + blob, err := SealWithCA(masterKey, "sha256:abc123") + if err != nil { + t.Fatalf("SealWithCA: %v", err) + } + if blob.Mode != "ca" { + t.Errorf("mode = %q, want ca", blob.Mode) + } + unsealed, err := UnsealWithCA(blob, "sha256:abc123") + if err != nil { + t.Fatalf("UnsealWithCA: %v", err) + } + if !bytes.Equal(unsealed, masterKey) { + t.Error("unsealed key != original") + } + // Wrong CA fingerprint fails. + _, err = UnsealWithCA(blob, "sha256:wrong") + if err == nil { + t.Error("UnsealWithCA with wrong fingerprint should fail") + } +} + +// TestSealedBlobModeMismatch verifies mode mismatch errors. +func TestSealedBlobModeMismatch(t *testing.T) { + masterKey := make([]byte, 32) + blob, _ := SealWithCA(masterKey, "fp") + _, err := Unseal(blob, "sub") // blob is CA-mode, not OIDC + if err == nil { + t.Error("Unseal OIDC on CA blob should fail") + } +} + +// TestEncodeDecodeShard verifies shard base64 round-trip. +func TestEncodeDecodeShard(t *testing.T) { + shard := []byte{1, 2, 3, 4, 5} + encoded := EncodeShard(shard) + decoded, err := DecodeShard(encoded) + if err != nil { + t.Fatalf("Decode: %v", err) + } + if !bytes.Equal(decoded, shard) { + t.Error("decode != original") + } +} + +// TestVerifySealedKey verifies the verification function. +func TestVerifySealedKey(t *testing.T) { + masterKey := make([]byte, 32) + blob, _, _ := Seal(masterKey, "sub", "https://idp") + if !VerifySealedKey(blob, masterKey, "sub") { + t.Error("VerifySealedKey should confirm correct key") + } + wrongKey := make([]byte, 32) + wrongKey[0] = 1 + if VerifySealedKey(blob, wrongKey, "sub") { + t.Error("VerifySealedKey should reject wrong key") + } +} diff --git a/internal/seal/shamir.go b/internal/seal/shamir.go new file mode 100644 index 0000000..819c699 --- /dev/null +++ b/internal/seal/shamir.go @@ -0,0 +1,160 @@ +// Package seal: shamir.go implements Shamir's Secret Sharing over +// GF(256) for the master key recovery (REQ-147, D-241, C-35). Splits +// a 32-byte secret into N shards with threshold T (3-of-5 default). +// Any T shards reconstruct the secret; fewer than T reveal nothing. +package seal + +import ( + "crypto/rand" + "fmt" +) + +// ShamirSplit splits secret into n shards with threshold t. Any t +// shards can reconstruct the secret; fewer reveal nothing. Returns +// n shards (each is secret-length + 1 byte index). The first byte of +// each shard is the x-coordinate (1..n); the remaining bytes are the +// y-coordinates evaluated at x over GF(256). +func ShamirSplit(secret []byte, n, t int) ([][]byte, error) { + if t < 2 || t > n { + return nil, fmt.Errorf("shamir: threshold %d must be 2..n (%d)", t, n) + } + if n > 254 { + return nil, fmt.Errorf("shamir: n %d exceeds 254 (GF(256) limit)", n) + } + if len(secret) == 0 { + return nil, fmt.Errorf("shamir: secret is empty") + } + + // Generate t-1 random coefficients (degree t-1 polynomial). + coeffs := make([][]byte, t) + coeffs[0] = secret // constant term = the secret + for i := 1; i < t; i++ { + c := make([]byte, len(secret)) + if _, err := rand.Read(c); err != nil { + return nil, fmt.Errorf("shamir: coeff rand: %w", err) + } + coeffs[i] = c + } + + shards := make([][]byte, n) + for x := 1; x <= n; x++ { + shard := make([]byte, len(secret)+1) + shard[0] = byte(x) // x-coordinate + for j := 0; j < len(secret); j++ { + // Evaluate the polynomial at x over GF(256): + // y = coeffs[0][j] + coeffs[1][j]*x + coeffs[2][j]*x^2 + ... + y := byte(0) + xPow := byte(1) // x^0 + for k := 0; k < t; k++ { + y ^= gfMul(coeffs[k][j], xPow) + xPow = gfMul(xPow, byte(x)) + } + shard[j+1] = y + } + shards[x-1] = shard + } + return shards, nil +} + +// ShamirCombine reconstructs the secret from >= threshold shards +// using Lagrange interpolation over GF(256). Extra shards (beyond +// threshold) are ignored. +func ShamirCombine(shards [][]byte) ([]byte, error) { + if len(shards) < 2 { + return nil, fmt.Errorf("shamir: need at least 2 shards, got %d", len(shards)) + } + // Verify all shards have the same length. + shardLen := len(shards[0]) + if shardLen < 2 { + return nil, fmt.Errorf("shamir: shard too short (%d)", shardLen) + } + for _, s := range shards { + if len(s) != shardLen { + return nil, fmt.Errorf("shamir: shard length mismatch") + } + } + secretLen := shardLen - 1 + secret := make([]byte, secretLen) + + // Lagrange interpolation: for each byte position, recover the + // constant term (the secret byte) from the y-values at the + // given x-coordinates. + for j := 0; j < secretLen; j++ { + // Collect (x, y) pairs for this byte position. + xs := make([]byte, len(shards)) + ys := make([]byte, len(shards)) + for i, s := range shards { + xs[i] = s[0] + ys[i] = s[j+1] + } + // Compute Lagrange basis at x=0 (recover the constant term). + secret[j] = lagrangeAtZero(xs, ys) + } + return secret, nil +} + +// lagrangeAtZero computes the Lagrange interpolation at x=0 over +// GF(256), which recovers the constant term (the secret). +func lagrangeAtZero(xs, ys []byte) byte { + result := byte(0) + for i := range xs { + // Basis polynomial L_i(0) = product over j!=i of (0 - x_j) / (x_i - x_j) + num := byte(1) + den := byte(1) + for j := range xs { + if i == j { + continue + } + num = gfMul(num, xs[j]) // (0 - x_j) = x_j in GF(256) (addition = XOR) + den = gfMul(den, xs[i]^xs[j]) + } + // L_i(0) = num / den = num * den^-1 + lagrange := gfMul(num, gfInv(den)) + result ^= gfMul(ys[i], lagrange) + } + return result +} + +// gfMul multiplies two elements in GF(256) using the standard +// Russian-peasant algorithm with the AES polynomial (0x11B). +func gfMul(a, b byte) byte { + var result byte + for i := 0; i < 8; i++ { + if b&1 != 0 { + result ^= a + } + hiBit := a & 0x80 + a <<= 1 + if hiBit != 0 { + a ^= 0x1B // AES irreducible polynomial + } + b >>= 1 + } + return result +} + +// gfInv computes the multiplicative inverse in GF(256) via +// exponentiation (a^254 = a^-1 in GF(256), since a^255 = 1). +func gfInv(a byte) byte { + if a == 0 { + return 0 // 0 has no inverse; callers ensure den != 0 + } + // a^254 = a^(11111110b) + result := a + for i := 0; i < 6; i++ { + result = gfMul(result, result) // a^(2^(i+1)) + // Set the bit for 254 = 0b11111110 + } + // a^254 = a^2 * a^4 * a^8 * a^16 * a^32 * a^64 * a^128 + // = a^(2+4+8+16+32+64+128) = a^254 + // Recompute properly via repeated squaring with accumulation. + result = byte(1) + acc := a + for bit := 1; bit < 256; bit <<= 1 { + if bit&254 != 0 { // 254 = 0b11111110 + result = gfMul(result, acc) + } + acc = gfMul(acc, acc) + } + return result +}