Extract purpose-bound sealing for shared app handoffs
2 files changed,
+198
−0
handoff/seal/seal.go+75 −0handoff/seal/seal_test.go+123 −0
CI passed — run details
diff --git a/handoff/seal/seal.go b/handoff/seal/seal.go| new file mode 100644 |
| index 0000000..56fadc9 |
| --- /dev/null |
| +++ b/handoff/seal/seal.go |
| @@ -0,0 +1,75 @@ |
| +// Package seal encrypts purpose-bound values for app handoffs and local storage. |
| +package seal |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/hkdf" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| +) |
| + |
| +// Sealer owns a copy of the key and an application namespace. |
| +type Sealer struct { |
| + dek []byte |
| + namespace string |
| +} |
| + |
| +// New requires a 32-byte key and an explicit namespace. Keep the namespace |
| +// stable: changing it makes previously sealed values unreadable. |
| +func New(dek []byte, namespace string) (*Sealer, error) { |
| + if len(dek) != 32 { |
| + return nil, errors.New("seal: DEK must be 32 bytes") |
| + } |
| + if len(namespace) == 0 || len(namespace) > 128 { |
| + return nil, errors.New("seal: invalid namespace") |
| + } |
| + for _, b := range []byte(namespace) { |
| + if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '/' || b == '_' || b == '-') { |
| + return nil, errors.New("seal: invalid namespace") |
| + } |
| + } |
| + return &Sealer{dek: append([]byte(nil), dek...), namespace: namespace}, nil |
| +} |
| + |
| +func (s *Sealer) purposeKey(purpose string) ([]byte, error) { |
| + return hkdf.Key(sha256.New, s.dek, nil, s.namespace+":"+purpose, 32) |
| +} |
| +func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) { |
| + key, err := s.purposeKey(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| + |
| +// Seal uses the historical Oficina envelope: random nonce followed by GCM |
| +// ciphertext and authentication tag. The namespace and purpose bind the key. |
| +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 refuses a different key, namespace or purpose, and altered ciphertext. |
| +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") |
| + } |
| + return aead.Open(nil, ct[:aead.NonceSize()], ct[aead.NonceSize():], nil) |
| +} |
diff --git a/handoff/seal/seal_test.go b/handoff/seal/seal_test.go| new file mode 100644 |
| index 0000000..14e1b96 |
| --- /dev/null |
| +++ b/handoff/seal/seal_test.go |
| @@ -0,0 +1,123 @@ |
| +package seal |
| + |
| +import ( |
| + "bytes" |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "encoding/hex" |
| + "sync" |
| + "testing" |
| +) |
| + |
| +func testKey() []byte { |
| + b := make([]byte, 32) |
| + for i := range b { |
| + b[i] = byte(i) |
| + } |
| + return b |
| +} |
| + |
| +// These derivation vectors predate extraction, from Home's deployed sealer. |
| +func TestHistoricalHomeVectors(t *testing.T) { |
| + s, err := New(testKey(), "oficina/home") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for purpose, want := range map[string]string{"signin.address": "1c677fc03245425f809a5a8ba5fda280f745ee96babd14ac8a75857ec9c7fe00", "team.name": "8cadd5d2a5921541918563b0046844c371de29743a4b9335759da8c345bfc732", "queue.payload": "2d6b4e963b93489c6a9515469f274516e48014d0bdb01831a9ff65086d0cb999"} { |
| + key, err := s.purposeKey(purpose) |
| + if err != nil || hex.EncodeToString(key) != want { |
| + t.Fatal("historical key changed", purpose, err) |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + aead, err := cipher.NewGCM(block) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + nonce := bytes.Repeat([]byte{0x5a}, aead.NonceSize()) |
| + plaintext := []byte("historical fixture") |
| + old := aead.Seal(append([]byte(nil), nonce...), nonce, plaintext, nil) |
| + got, err := s.Open(purpose, old) |
| + if err != nil || !bytes.Equal(got, plaintext) { |
| + t.Fatal("old envelope unreadable", err) |
| + } |
| + current, err := s.Seal(purpose, plaintext) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err = aead.Open(nil, current[:aead.NonceSize()], current[aead.NonceSize():], nil) |
| + if err != nil || !bytes.Equal(got, plaintext) { |
| + t.Fatal("old reader cannot read new envelope", err) |
| + } |
| + } |
| +} |
| +func TestNamespacePurposeKeyAndTamperIsolation(t *testing.T) { |
| + s, _ := New(testKey(), "oficina/home") |
| + ct, err := s.Seal("pending", []byte("private")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, ns := range []string{"oficina/docs/handoff", "oficina/sheets"} { |
| + other, _ := New(testKey(), ns) |
| + if _, err := other.Open("pending", ct); err == nil { |
| + t.Fatal("namespace crossed") |
| + } |
| + } |
| + if _, err := s.Open("inbox", ct); err == nil { |
| + t.Fatal("purpose crossed") |
| + } |
| + other, _ := New(bytes.Repeat([]byte{99}, 32), "oficina/home") |
| + if _, err := other.Open("pending", ct); err == nil { |
| + t.Fatal("key crossed") |
| + } |
| + for i := range ct { |
| + bad := append([]byte(nil), ct...) |
| + bad[i] ^= 1 |
| + if _, err := s.Open("pending", bad); err == nil { |
| + t.Fatal("altered envelope accepted") |
| + } |
| + } |
| + for i := 0; i < len(ct); i++ { |
| + if _, err := s.Open("pending", ct[:i]); err == nil { |
| + t.Fatal("truncated envelope accepted") |
| + } |
| + } |
| +} |
| +func TestConstructorCopiesKeyAndRejectsAmbiguousNamespaces(t *testing.T) { |
| + key := testKey() |
| + s, err := New(key, "oficina/home") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + clear(key) |
| + expected, _ := New(testKey(), "oficina/home") |
| + var wg sync.WaitGroup |
| + for i := 0; i < 8; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + ct, err := s.Seal("parallel", []byte("value")) |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + p, err := expected.Open("parallel", ct) |
| + if err != nil || string(p) != "value" { |
| + t.Error("caller key mutation changed sealer") |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + for _, ns := range []string{"", "a:b", "a\n", "a b", string(bytes.Repeat([]byte{'a'}, 129))} { |
| + if _, err := New(testKey(), ns); err == nil { |
| + t.Fatal("invalid namespace accepted") |
| + } |
| + } |
| + for _, n := range []int{0, 31, 33} { |
| + if _, err := New(make([]byte, n), "oficina/home"); err == nil { |
| + t.Fatal("invalid key length accepted") |
| + } |
| + } |
| +} |