oficina / home Public

Use shared sealing while preserving Home ciphertext compatibility

Paul Campbell pushed by paul@keymail.dev 16024beadb3294df1f7e9cf7f732f6de9a80284b parent 967d29f
5 files changed, +59 −146
  • go.mod +1 −1
  • go.sum +2 −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/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")
}
}