Sealing: AES-GCM with HKDF purpose separation, golden key vectors
Sealer.Seal/Open derive a per-purpose AES-256-GCM key from the DEK via HKDF-SHA256, so a value sealed under one purpose (e.g. cell.input) cannot be opened under another (e.g. cell.cached). Golden vectors pin the HKDF-derived purpose keys so a future refactor can't silently change the envelope and orphan every sealed row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
5 files changed,
+171
−2
go.mod+3 −2go.sum+9 −0internal/sheets/seal/seal.go+81 −0internal/sheets/seal/seal_test.go+52 −0internal/sheets/seal/vectors_test.go+26 −0
Intent
diff --git a/go.mod b/go.mod| index d6b41b9..70c0c7e 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -16,8 +16,9 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| - golang.org/x/sys v0.46.0 // indirect |
| - golang.org/x/text v0.20.0 // indirect |
| + golang.org/x/crypto v0.55.0 // indirect |
| + golang.org/x/sys v0.47.0 // indirect |
| + golang.org/x/text v0.41.0 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/go.sum b/go.sum| index 7d0ab14..d2fa6b8 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -24,17 +24,26 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
| golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
| gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
diff --git a/internal/sheets/seal/seal.go b/internal/sheets/seal/seal.go| new file mode 100644 |
| index 0000000..e59ecfa |
| --- /dev/null |
| +++ b/internal/sheets/seal/seal.go |
| @@ -0,0 +1,81 @@ |
| +// Package seal is the app's only encryption surface. |
| +// |
| +// The one rule: a sheet's contents are sealed at rest, and the bucket never |
| +// holds a plaintext cell. The DEK arrives as platform config and is never |
| +// written to the database it decrypts. |
| +// |
| +// Purpose keys are derived with HKDF so that a value sealed as a cell input |
| +// cannot be opened as a cached value. That is not paranoia: the two are |
| +// adjacent columns of the same row, and a bug that swapped them would be |
| +// invisible without this. |
| +package seal |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| + "hash" |
| + "io" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +func sha256New() hash.Hash { return sha256.New() } |
| + |
| +// Sealer holds the data encryption key. One per process. |
| +type Sealer struct{ dek []byte } |
| + |
| +// New returns a Sealer. The DEK must be exactly 32 bytes. |
| +func New(dek []byte) (*Sealer, error) { |
| + if len(dek) != 32 { |
| + return nil, errors.New("seal: DEK must be 32 bytes") |
| + } |
| + c := make([]byte, 32) |
| + copy(c, dek) |
| + return &Sealer{dek: c}, nil |
| +} |
| + |
| +// purposeKey derives the AEAD key for purpose via HKDF over the DEK. |
| +func (s *Sealer) purposeKey(purpose string) []byte { |
| + key := make([]byte, 32) |
| + r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/sheets:"+purpose)) |
| + io.ReadFull(r, key) |
| + return key |
| +} |
| + |
| +// aeadFor derives the purpose key and returns its AEAD. |
| +func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) { |
| + block, err := aes.NewCipher(s.purposeKey(purpose)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| + |
| +// Seal encrypts plaintext under purpose. The nonce is prepended. |
| +func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + nonce := make([]byte, aead.NonceSize()) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return nil, err |
| + } |
| + return aead.Seal(nonce, nonce, plaintext, nil), nil |
| +} |
| + |
| +// Open decrypts a value sealed under the same purpose. |
| +func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(ct) < aead.NonceSize() { |
| + return nil, errors.New("seal: ciphertext too short") |
| + } |
| + nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():] |
| + return aead.Open(nil, nonce, body, nil) |
| +} |
diff --git a/internal/sheets/seal/seal_test.go b/internal/sheets/seal/seal_test.go| new file mode 100644 |
| index 0000000..0366cc9 |
| --- /dev/null |
| +++ b/internal/sheets/seal/seal_test.go |
| @@ -0,0 +1,52 @@ |
| +package seal |
| + |
| +import ( |
| + "bytes" |
| + "testing" |
| +) |
| + |
| +func testDEK() []byte { |
| + d := make([]byte, 32) |
| + for i := range d { |
| + d[i] = byte(i) |
| + } |
| + return d |
| +} |
| + |
| +func TestSealOpenRoundTrip(t *testing.T) { |
| + s, err := New(testDEK()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []byte("=SUM(A1:A10)") |
| + ct, err := s.Seal("cell.input", want) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(ct, want) { |
| + t.Fatal("ciphertext contains the plaintext") |
| + } |
| + got, err := s.Open("cell.input", ct) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Fatalf("Open = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// A value sealed under one purpose must not open under another. Purpose |
| +// separation is what stops a cached value being swapped for an input. |
| +func TestPurposeSeparation(t *testing.T) { |
| + s, _ := New(testDEK()) |
| + ct, _ := s.Seal("cell.input", []byte("secret")) |
| + if _, err := s.Open("cell.cached", ct); err == nil { |
| + t.Fatal("opened under the wrong purpose") |
| + } |
| +} |
| + |
| +func TestRejectsShortDEK(t *testing.T) { |
| + if _, err := New([]byte("too short")); err == nil { |
| + t.Fatal("accepted a short DEK") |
| + } |
| +} |
diff --git a/internal/sheets/seal/vectors_test.go b/internal/sheets/seal/vectors_test.go| new file mode 100644 |
| index 0000000..a81f695 |
| --- /dev/null |
| +++ b/internal/sheets/seal/vectors_test.go |
| @@ -0,0 +1,26 @@ |
| +package seal |
| + |
| +import ( |
| + "encoding/hex" |
| + "testing" |
| +) |
| + |
| +// Pins the HKDF derivation. If this changes, every sealed row in every |
| +// deployed instance becomes unreadable — so it changes only with a |
| +// migration, never as a refactor. |
| +func TestPurposeKeyVectors(t *testing.T) { |
| + s, _ := New(testDEK()) |
| + for _, tc := range []struct{ purpose, want string }{ |
| + {"cell.input", "cbc5cfc98184cea8e11127274e61a8973520c1d8cc81014678a7e9160ce68b63"}, |
| + {"cell.cached", "6c94c298a000b2fd440767b542a77b97366e653d7412e4b1fab9b7545cee34b5"}, |
| + } { |
| + got := hex.EncodeToString(s.purposeKey(tc.purpose)) |
| + if tc.want == "" { |
| + t.Logf("RECORD %s = %s", tc.purpose, got) |
| + continue |
| + } |
| + if got != tc.want { |
| + t.Fatalf("%s key = %s, want %s", tc.purpose, got, tc.want) |
| + } |
| + } |
| +} |