Merge branch 'feat/shared-purpose-seal' into main
7 files changed,
+257
−146
go.mod+1 −1go.sum+2 −0handoff/seal/seal.go+75 −0handoff/seal/seal_test.go+123 −0implementation/shared-purpose-seal.md+7 −0internal/home/seal/seal.go+6 −100internal/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") |
| } |
| } |