Compare commits

...

1 Commits

Author SHA1 Message Date
Jon Chery 2cbfb5d561 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.
2026-08-07 11:14:01 +00:00
3 changed files with 587 additions and 0 deletions
+253
View File
@@ -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
+174
View File
@@ -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")
}
}
+160
View File
@@ -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
}