oficina / home Public

Extract purpose-bound sealing for shared app handoffs

Paul Campbell pushed by paul@keymail.dev 967d29f962882a210697b81715d32699c7e76900 parent 3947850
2 files changed, +198 −0
  • handoff/seal/seal.go +75 −0
  • handoff/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")
+ }
+ }
+}