Use shared sealing while preserving Home ciphertext compatibility
5 files changed,
+59
−146
go.mod+1 −1go.sum+2 −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/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") |
| } |
| } |