oficina / home Public

Merge branch 'feat/shared-purpose-seal' into main

claude agent, operated by paul@keymail.dev cc619760ad2c10faae230e77e0665a2d37625160 parent 3947850parent 16024be
7 files changed, +257 −146
  • go.mod +1 −1
  • go.sum +2 −0
  • handoff/seal/seal.go +75 −0
  • handoff/seal/seal_test.go +123 −0
  • implementation/shared-purpose-seal.md +7 −0
  • internal/home/seal/seal.go +6 −100
  • internal/home/seal/vectors_test.go +43 −45

CI passed — run details

diff --git a/go.mod b/go.mod
index 5cfbf05..3342adf 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.25.0
require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
- amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db
+ amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
diff --git a/go.sum b/go.sum
index 4946db0..9089c80 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNX
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db h1:3aN3BlNC03KJuFEk/C7EFEprt0Csto/LFjdwYeDv0FM=
amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
+amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA=
+amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
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")
+ }
+ }
+}
diff --git a/implementation/shared-purpose-seal.md b/implementation/shared-purpose-seal.md
new file mode 100644
index 0000000..95bcffd
--- /dev/null
+++ b/implementation/shared-purpose-seal.md
@@ -0,0 +1,7 @@
+# Shared purpose sealing
+
+This is the extraction required by the Fable-approved Docs account-link design2e69825. The existing Home algorithm becomes handoff/seal, with an explicit application namespace; Home retains an adapter selecting exactly oficina/home. No key, database, purpose name, envelope layout or ciphertext format changes. Go1.25's standard crypto/hkdf replaces the older x/crypto implementation with the same SHA256/key/salt/info/output length. Historical fixed derivation vectors and old/new envelope readers must prove compatibility in both the shared package and Home adapter.
+
+The new constructor owns a copy of a 32-byte key. Namespace is an explicit 1–128 byte ASCII name using letters, digits, slash, underscore or hyphen; colon is refused so namespace/purpose boundaries cannot collide. Purpose remains caller-controlled application metadata, with the historical namespace + colon + purpose derivation. Docs will use its own namespace and separate handoff key; no reuse of the document-content encryption key. Existing Home namespace and DEK remain unchanged.
+
+A consumer imports the published nested module, not a local replace. First published source967d29f is remotely fetchable as v0.0.0-20260908100301-967d29f96288; Home's adapter consumes that exact immutable version. Compatibility, tamper/truncation/wrong-key/wrong-purpose/wrong-namespace, constructor validation and copied-key concurrency tests precede independent Fable review and whole CI. A changed namespace or delimiter must fail a historical-vector mutation check. The extraction is not a new authentication or membership permission.
diff --git a/internal/home/seal/seal.go b/internal/home/seal/seal.go
index 9228590..07d427c 100644
--- a/internal/home/seal/seal.go
+++ b/internal/home/seal/seal.go
@@ -1,106 +1,12 @@
-// Package seal is the home's only encryption surface.
-//
-// The one rule: an address, a team display name and a provisioning queue
-// payload are sealed at rest, so the database file — and every WAL frame
-// Litestream ships from it — never holds one in the clear. 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 under one
-// purpose cannot be opened under another. In the home these live in three
-// different tables — signin_links, teams, the provisioning queue — so the
-// swap this prevents is not a mistyped column name but a store handed the
-// wrong Sealer purpose, which no type checks and no test would notice
-// while both values are just opaque bytes.
-//
-// (Sheets' version of this comment says "adjacent columns of the same
-// row", which is true of its cell.input/cell.cached pair and not of
-// anything here. The sentence was carried over with the port and is
-// corrected rather than kept for symmetry.)
-//
-// Ported from oficina/sheets-core's internal/sheets/seal, deliberately
-// unchanged apart from this doc and the HKDF info string. Two apps in one
-// suite disagreeing about their envelope format is a bug waiting to
-// differ.
+// Package seal keeps Home's historical encryption namespace while sharing the
+// implementation with other Oficina apps.
package seal
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "crypto/sha256"
- "errors"
- "hash"
- "io"
+import shared "amadan.net/oficina/home/handoff/seal"
- "golang.org/x/crypto/hkdf"
-)
+type Sealer = shared.Sealer
-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.
+// New preserves the deployed Home key derivation and nonce-prefixed envelope.
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. A
-// fixed 32-byte read is always inside HKDF-SHA256's 8160-byte expand
-// limit, so this cannot fail today — but the error is still checked and
-// propagated: this is the app's only encryption surface, and a silently
-// discarded read here would degrade into a short or zero-padded key if
-// the key length ever changed, which is the kind of bug that leaves
-// ciphertext looking perfectly fine while being trivially breakable.
-func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
- key := make([]byte, 32)
- r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/home:"+purpose))
- if _, err := io.ReadFull(r, key); err != nil {
- return nil, err
- }
- return key, nil
-}
-
-// aeadFor derives the purpose key and returns its AEAD.
-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 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)
+ return shared.New(dek, "oficina/home")
}
diff --git a/internal/home/seal/vectors_test.go b/internal/home/seal/vectors_test.go
index 739d2a8..066df91 100644
--- a/internal/home/seal/vectors_test.go
+++ b/internal/home/seal/vectors_test.go
@@ -1,79 +1,77 @@
package seal
import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hkdf"
"crypto/sha256"
"encoding/hex"
- "io"
"testing"
-
- "golang.org/x/crypto/hkdf"
)
-// purposeKeyWithInfo derives a key exactly as purposeKey does but with an
-// info string the caller supplies, so a test can derive Sheets' key from
-// the same DEK and compare. It is a copy of the two lines that matter
-// rather than a parameter on the real function: the info string being
-// fixed is the property under test.
-func purposeKeyWithInfo(s *Sealer, info string) ([]byte, error) {
- key := make([]byte, 32)
- r := hkdf.New(sha256.New, s.dek, nil, []byte(info))
- if _, err := io.ReadFull(r, key); err != nil {
- return nil, err
- }
- return key, nil
-}
-
-// Pins the HKDF derivation. If this changes, every sealed row in every
-// deployed home becomes unreadable — so it changes only with a
-// migration, never as a refactor.
-//
-// This is the one test that would catch the drift the port was careful
-// about: seal.go is a copy of Sheets' with a different info string, and
-// nothing else in this package would notice if a later edit made the two
-// derive the same key or a different one by accident.
+// The fixed keys predate extraction. Verify envelopes through the old reader
+// shape so an adapter namespace change cannot hide behind a round trip.
func TestPurposeKeyVectors(t *testing.T) {
s, err := New(testDEK())
if err != nil {
t.Fatal(err)
}
- for _, tc := range []struct{ purpose, want string }{
- {"signin.address", "1c677fc03245425f809a5a8ba5fda280f745ee96babd14ac8a75857ec9c7fe00"},
- {"team.name", "8cadd5d2a5921541918563b0046844c371de29743a4b9335759da8c345bfc732"},
- {"queue.payload", "2d6b4e963b93489c6a9515469f274516e48014d0bdb01831a9ff65086d0cb999"},
+ for purpose, want := range map[string]string{
+ "signin.address": "1c677fc03245425f809a5a8ba5fda280f745ee96babd14ac8a75857ec9c7fe00",
+ "team.name": "8cadd5d2a5921541918563b0046844c371de29743a4b9335759da8c345bfc732",
+ "queue.payload": "2d6b4e963b93489c6a9515469f274516e48014d0bdb01831a9ff65086d0cb999",
} {
- key, err := s.purposeKey(tc.purpose)
+ key, err := hex.DecodeString(want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
- got := hex.EncodeToString(key)
- if tc.want == "" {
- t.Logf("RECORD %s = %s", tc.purpose, got)
- continue
+ ct, err := s.Seal(purpose, []byte("saved Home value"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ plaintext, err := aead.Open(nil, ct[:aead.NonceSize()], ct[aead.NonceSize():], nil)
+ if err != nil || string(plaintext) != "saved Home value" {
+ t.Fatal("historical Home key/envelope changed", purpose, err)
}
- if got != tc.want {
- t.Fatalf("%s key = %s, want %s", tc.purpose, got, tc.want)
+ oldNonce := make([]byte, aead.NonceSize())
+ oldNonce[0] = 0x42
+ old := aead.Seal(append([]byte(nil), oldNonce...), oldNonce, []byte("old Home value"), nil)
+ plaintext, err = s.Open(purpose, old)
+ if err != nil || string(plaintext) != "old Home value" {
+ t.Fatal("old Home value unreadable", purpose, err)
}
}
}
-
-// The home's keys must differ from Sheets' for the same purpose name.
-// That is the entire content of the one-line change the port made, and
-// nothing else asserts it.
func TestHomeInfoStringIsDistinct(t *testing.T) {
s, err := New(testDEK())
if err != nil {
t.Fatal(err)
}
- home, err := s.purposeKey("cell.input")
+ ct, err := s.Seal("cell.input", []byte("Home only"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := hkdf.Key(sha256.New, testDEK(), nil, "oficina/sheets:cell.input", 32)
+ if err != nil {
+ t.Fatal(err)
+ }
+ block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
- // Derived the way Sheets does, from the same DEK.
- sheets, err := purposeKeyWithInfo(s, "oficina/sheets:cell.input")
+ aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
- if hex.EncodeToString(home) == hex.EncodeToString(sheets) {
- t.Fatal("the home and Sheets derive the same key for the same purpose")
+ if _, err := aead.Open(nil, ct[:aead.NonceSize()], ct[aead.NonceSize():], nil); err == nil {
+ t.Fatal("Home and Sheets namespaces collided")
}
}