oficina / sheets Public

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
Paul Campbell cd4780e9cbdbf272fcbde5a090a7cb5b2db47ba4 parent 0c85471
5 files changed, +171 −2
  • go.mod +3 −2
  • go.sum +9 −0
  • internal/sheets/seal/seal.go +81 −0
  • internal/sheets/seal/seal_test.go +52 −0
  • internal/sheets/seal/vectors_test.go +26 −0
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)
+ }
+ }
+}