oficina / fichas Public

The seal, and the seeded catalogue

seal is the app's only encryption surface: HKDF purpose keys over a 32-byte
DEK, AES-GCM with a prepended nonce, and keyed HMAC prints. A near-copy of
memoria's, which says the same of Sheets'. Three copies is where that trade
is worth re-examining, and the package comment records that it was noticed
rather than missed.

Prints are scoped per field key rather than sharing one purpose. A single
print space would make equality comparable ACROSS fields — an observer
holding the file could see that one card's value in one field equals
another card's in a different one, without opening either. The cost of
scoping is nothing; there is a test for it.

The catalogue seeds Customer and Call Log as rows. Every field carries
§4.2's tier with its reasoning beside it: name, domain, email and phone are
printed because they are high-cardinality and the equality question is worth
answering without the key; stage is sealed because a print over five values
publishes five equality classes that frequency analysis orders; the call's
occurred_at is sealed and deliberately distinct from the entry's plaintext
created_at, because the feed needs to know a row was written and "when you
called them" is content.

Seed matches on Key and never on Label, because a label is sealed and
sealing is not deterministic — a label comparison would insert a duplicate
type on every boot. Tested by booting three times.

Two tests guard the rulings rather than the code: no seeded field may claim
the plaintext tier, and field keys must be unique, since a print is derived
from the key and renaming one silently invalidates every print taken under
the old name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5774623c47dfb0eb6430570fc88ac07fc98d8d8a parent 62d54ea
6 files changed, +626 −2
  • go.mod +3 −2
  • go.sum +9 −0
  • internal/fichas/catalogue.go +166 −0
  • internal/fichas/catalogue_test.go +138 −0
  • internal/fichas/seal/seal.go +182 −0
  • internal/fichas/seal/seal_test.go +128 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading golang.org/x/crypto v0.55.0
go: downloading golang.org/x/sys v0.47.0
go: downloading golang.org/x/text v0.41.0
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas	0.017s
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichas/seal	0.002s
ok  	amadan.net/oficina/fichas/internal/fichastest	0.019s
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/go.mod b/go.mod
index 7a7d887..0913943 100644
--- a/go.mod
+++ b/go.mod
@@ -25,8 +25,9 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.20.0 // indirect
+ golang.org/x/crypto v0.55.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
gorm.io/gorm v1.31.2 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.74.1 // indirect
diff --git a/go.sum b/go.sum
index 9983f7d..9e6296c 100644
--- a/go.sum
+++ b/go.sum
@@ -46,17 +46,26 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/fichas/catalogue.go b/internal/fichas/catalogue.go
new file mode 100644
index 0000000..c52c2c7
--- /dev/null
+++ b/internal/fichas/catalogue.go
@@ -0,0 +1,166 @@
+package fichas
+
+import (
+ "fmt"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+ "gorm.io/gorm"
+)
+
+// The seeded catalogue (§4.4): "ship a catalogue, not a builder".
+//
+// A card type is rows, never DDL (§4.3), so adding one costs seed data and
+// no migration. What it is NOT free of is a leak-list decision: every field
+// below carries a Tier, and a Tier ships as a default nobody will revisit.
+// A seeded type with a plaintext salary_band would be a leak this app
+// shipped into every team that ever clicked it. **A type joins this list
+// only with a per-field tier review**, which is why slice 1 ships two.
+//
+// Keys are opaque constants of OURS and never the user's words, because
+// they are the one part of a type that reaches the durable file in the
+// clear. Labels are sealed, even here.
+
+// Card type keys.
+const (
+ TypeCustomer = "t_customer"
+ TypeCallLog = "t_calllog"
+)
+
+// Field keys. Stable forever once shipped: a print is derived from the key
+// (seal.Print), so renaming one silently invalidates every print taken
+// under the old name and equality lookups start missing.
+const (
+ FieldName = "f_name"
+ FieldDomain = "f_domain"
+ FieldEmail = "f_email"
+ FieldPhone = "f_phone"
+ FieldStage = "f_stage"
+ FieldNextAction = "f_next_action"
+ FieldNotes = "f_notes"
+
+ FieldOccurredAt = "f_occurred_at"
+ FieldBody = "f_body"
+ FieldDuration = "f_duration"
+ FieldCallsRef = "f_calls_ref"
+)
+
+// Field kinds — this app's vocabulary for a value's shape, plaintext
+// because it is ours and not the team's content.
+const (
+ KindText = "text"
+ KindDate = "date"
+ KindEnum = "enum"
+ KindNumber = "number"
+ KindRef = "ref"
+)
+
+type seedField struct {
+ Key string
+ Label string
+ Kind string
+ Tier Tier
+}
+
+type seedType struct {
+ Key string
+ Label string
+ Fields []seedField
+}
+
+// catalogue is the shipped set. The Tier column is the whole of §4.2 and
+// each one has a reason; see the design's §4.2 table before changing any.
+var catalogue = []seedType{
+ {
+ Key: TypeCustomer,
+ Label: "Customer",
+ Fields: []seedField{
+ // Printed: high-cardinality, and the equality question is
+ // worth answering without the key — "is this the same
+ // company", "do we already have this contact".
+ {FieldName, "Name", KindText, TierPrint},
+ {FieldDomain, "Domain", KindText, TierPrint},
+ {FieldEmail, "Email", KindText, TierPrint},
+ {FieldPhone, "Phone", KindText, TierPrint},
+
+ // Sealed. Stage is low-cardinality, so a print would publish
+ // its equality classes and frequency analysis would order
+ // them — and a pipeline's composition is commercially
+ // sensitive. Grouped in the in-memory index instead.
+ {FieldStage, "Stage", KindEnum, TierSealed},
+ // Sealed although it is the field most wanted for ranges
+ // ("no call in six weeks"). §4.1: the index is in memory, so
+ // sealed is not unqueryable, and plaintext here would buy
+ // nothing the index does not already give.
+ {FieldNextAction, "Next action", KindDate, TierSealed},
+ {FieldNotes, "Notes", KindText, TierSealed},
+ },
+ },
+ {
+ Key: TypeCallLog,
+ Label: "Call log",
+ Fields: []seedField{
+ // Deliberately distinct from Entry.CreatedAt, which is
+ // plaintext because the feed needs to know a row was written.
+ // WHEN YOU CALLED THEM is content, and is sealed.
+ {FieldOccurredAt, "When", KindDate, TierSealed},
+ {FieldBody, "What was said", KindText, TierSealed},
+ {FieldDuration, "Duration", KindNumber, TierSealed},
+ // Printed for dedupe on re-import: capture from Calls must be
+ // able to ask "have I already logged this meeting" without
+ // unsealing every entry on the card.
+ {FieldCallsRef, "Calls meeting", KindRef, TierPrint},
+ },
+ },
+}
+
+// Seed writes the catalogue if it is not already there, and is safe to run
+// on every boot.
+//
+// It matches on Key and never on Label, because a Label is sealed and
+// sealing is not deterministic — two seals of "Customer" differ, so a
+// label comparison would insert a duplicate type on every start.
+//
+// It does not update an existing row. Changing a shipped field's Tier is a
+// re-encryption of every value under it, not a seed-time edit, and doing it
+// silently here would leave old values sealed under a tier the definition
+// no longer claims.
+func Seed(db *gorm.DB, s *seal.Sealer) error {
+ for _, t := range catalogue {
+ var ct CardType
+ err := db.Where("key = ?", t.Key).First(&ct).Error
+ switch {
+ case err == nil:
+ continue // already seeded; see the doc comment
+ case err != gorm.ErrRecordNotFound:
+ return fmt.Errorf("seed: looking up %s: %w", t.Key, err)
+ }
+
+ label, err := s.Seal(seal.PurposeLabel, []byte(t.Label))
+ if err != nil {
+ return fmt.Errorf("seed: sealing the label for %s: %w", t.Key, err)
+ }
+ ct = CardType{Key: t.Key, SealedLabel: label}
+ if err := db.Create(&ct).Error; err != nil {
+ return fmt.Errorf("seed: creating %s: %w", t.Key, err)
+ }
+
+ for i, f := range t.Fields {
+ fl, err := s.Seal(seal.PurposeLabel, []byte(f.Label))
+ if err != nil {
+ return fmt.Errorf("seed: sealing the label for %s: %w", f.Key, err)
+ }
+ fd := FieldDef{
+ CardTypeID: ct.ID,
+ Key: f.Key,
+ SealedLabel: fl,
+ Kind: f.Kind,
+ Tier: f.Tier,
+ Ordinal: i,
+ }
+ if err := db.Create(&fd).Error; err != nil {
+ return fmt.Errorf("seed: creating %s: %w", f.Key, err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/fichas/catalogue_test.go b/internal/fichas/catalogue_test.go
new file mode 100644
index 0000000..5d3d297
--- /dev/null
+++ b/internal/fichas/catalogue_test.go
@@ -0,0 +1,138 @@
+package fichas
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "testing"
+
+ "amadan.net/oficina/fichas/internal/fichas/seal"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "gorm.io/gorm"
+)
+
+func testDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "seed.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ return d.G
+}
+
+func testSealer(t *testing.T) *seal.Sealer {
+ t.Helper()
+ k := make([]byte, 32)
+ for i := range k {
+ k[i] = 0x7f
+ }
+ s, err := seal.New(k)
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ return s
+}
+
+func TestSeedWritesTheCatalogue(t *testing.T) {
+ g, s := testDB(t), testSealer(t)
+ if err := Seed(g, s); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+
+ var types []CardType
+ if err := g.Order("key").Find(&types).Error; err != nil {
+ t.Fatalf("listing types: %v", err)
+ }
+ if len(types) != len(catalogue) {
+ t.Fatalf("seeded %d types, want %d", len(types), len(catalogue))
+ }
+
+ for _, want := range catalogue {
+ var ct CardType
+ if err := g.Where("key = ?", want.Key).First(&ct).Error; err != nil {
+ t.Fatalf("type %s missing: %v", want.Key, err)
+ }
+ label, err := s.Open(seal.PurposeLabel, ct.SealedLabel)
+ if err != nil {
+ t.Fatalf("opening the label for %s: %v", want.Key, err)
+ }
+ if string(label) != want.Label {
+ t.Fatalf("label for %s is %q, want %q", want.Key, label, want.Label)
+ }
+
+ var fields []FieldDef
+ if err := g.Where("card_type_id = ?", ct.ID).Order("ordinal").Find(&fields).Error; err != nil {
+ t.Fatalf("listing fields for %s: %v", want.Key, err)
+ }
+ if len(fields) != len(want.Fields) {
+ t.Fatalf("%s has %d fields, want %d", want.Key, len(fields), len(want.Fields))
+ }
+ for i, wf := range want.Fields {
+ if fields[i].Key != wf.Key || fields[i].Tier != wf.Tier || fields[i].Kind != wf.Kind {
+ t.Fatalf("%s field %d is %+v, want key=%s kind=%s tier=%s",
+ want.Key, i, fields[i], wf.Key, wf.Kind, wf.Tier)
+ }
+ }
+ }
+}
+
+// Seed runs on every boot. Matching on a SEALED label would insert a
+// duplicate type each time, because sealing is not deterministic.
+func TestSeedIsIdempotent(t *testing.T) {
+ g, s := testDB(t), testSealer(t)
+ for i := 0; i < 3; i++ {
+ if err := Seed(g, s); err != nil {
+ t.Fatalf("Seed run %d: %v", i, err)
+ }
+ }
+ var types, fields int64
+ g.Model(&CardType{}).Count(&types)
+ g.Model(&FieldDef{}).Count(&fields)
+
+ wantFields := 0
+ for _, t := range catalogue {
+ wantFields += len(t.Fields)
+ }
+ if types != int64(len(catalogue)) || fields != int64(wantFields) {
+ t.Fatalf("after three boots: %d types and %d fields, want %d and %d",
+ types, fields, len(catalogue), wantFields)
+ }
+}
+
+// §4.3: a user-defined field may be sealed or sealed+print only, and the
+// plaintext tier is reserved for the structural columns this app ships.
+// Nothing in the catalogue may claim it.
+func TestNoSeededFieldIsPlaintext(t *testing.T) {
+ for _, ct := range catalogue {
+ for _, f := range ct.Fields {
+ if f.Tier == TierPlain {
+ t.Errorf("%s.%s ships as plaintext; §4.2 reserves that tier for structural columns", ct.Key, f.Key)
+ }
+ if f.Tier != TierSealed && f.Tier != TierPrint {
+ t.Errorf("%s.%s has tier %q, which is not a tier", ct.Key, f.Key, f.Tier)
+ }
+ }
+ }
+}
+
+// A print is derived from the field key, so two fields sharing a key would
+// share an equality space — and a renamed key silently invalidates every
+// print already taken.
+func TestFieldKeysAreUnique(t *testing.T) {
+ seen := map[string]string{}
+ for _, ct := range catalogue {
+ for _, f := range ct.Fields {
+ if prev, dup := seen[f.Key]; dup {
+ t.Errorf("field key %s is used by both %s and %s", f.Key, prev, ct.Key)
+ }
+ seen[f.Key] = ct.Key
+ }
+ }
+}
diff --git a/internal/fichas/seal/seal.go b/internal/fichas/seal/seal.go
new file mode 100644
index 0000000..cf44388
--- /dev/null
+++ b/internal/fichas/seal/seal.go
@@ -0,0 +1,182 @@
+// Package seal is the app's only encryption surface.
+//
+// The one rule: a card's values, its labels and its references are sealed
+// at rest, and the durable file never holds a readable one. The DEK
+// arrives as platform config and is never written to the database it
+// decrypts.
+//
+// Purpose keys are derived with HKDF so a value sealed as a field value
+// cannot be opened as a field label. That is not paranoia: they are rows of
+// adjacent tables, and a bug that swapped them would be invisible without
+// this.
+//
+// This package is deliberately a near-copy of memoria's
+// internal/memoria/seal, which says the same of Sheets'. Sharing it would
+// mean a module all three depend on, which is a coordination cost the
+// suite has not paid; the construction being identical is what matters.
+// **Three copies is the point at which that trade is worth re-examining,
+// and this comment is the record that it was noticed rather than missed.**
+//
+// The HKDF info string is app-namespaced — "oficina/fichas:" — so the same
+// DEK under two apps derives different keys. It must never be changed: it
+// is baked into every byte already sealed.
+package seal
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "hash"
+ "io"
+
+ "golang.org/x/crypto/hkdf"
+)
+
+// Purposes. One per kind of thing sealed, so a mix-up cannot decrypt.
+const (
+ // PurposeLabel seals what a person reads for a card type or a field —
+ // "Customer", "Next action". Sealed even for the built-in catalogue
+ // (§4.3): a label that is sometimes plaintext is a rule nobody can
+ // apply, and a user-added type's label is the user's own words.
+ PurposeLabel = "type.label"
+
+ // PurposeValue seals a field's value on a card or an entry. The
+ // largest class by far, and the reason this package exists.
+ PurposeValue = "field.value"
+
+ // PurposeEventPayload seals the before/after values in the change log.
+ // Named to match Sheets' "event.payload" because §5 takes that table
+ // field for field; the name travelling with the shape is deliberate.
+ //
+ // This is the purpose whose ciphertext is TOMBSTONED on erasure while
+ // its row survives (§5). Nothing else here is erased in place.
+ PurposeEventPayload = "event.payload"
+
+ // PurposeRefTarget seals a reference's (app, instance, item).
+ //
+ // REVERSIBLE, unlike a print: resolving a reference means handing the
+ // item id back to the app it came from, and an HMAC cannot be handed
+ // back. Sealed rather than plaintext because of §9 — a readable
+ // reference to a non-team item is at-rest knowledge, in a second app,
+ // that a private item exists. That is the leak S1's visibility flag
+ // was removed to prevent.
+ PurposeRefTarget = "ref.target"
+)
+
+// printPurpose returns the one-way purpose for a field's print.
+//
+// **Scoped per field key, not shared.** A single print purpose across every
+// field would make equality comparable ACROSS fields: an observer holding
+// the file could see that one card's value in field A equals another's in
+// field B without opening either. Scoping costs nothing and removes the
+// question. fieldKey is this app's opaque constant, never the user's words.
+func printPurpose(fieldKey string) string { return "field.print:" + fieldKey }
+
+// refPrintPurpose is printPurpose's twin for reference dedupe.
+const refPrintPurpose = "ref.print"
+
+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.
+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 expand limit, so this
+// cannot fail today — but the error is still 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/fichas:"+purpose))
+ if _, err := io.ReadFull(r, key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+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)
+}
+
+// fingerprint returns a keyed, deterministic identifier for plaintext.
+//
+// Keyed, never a bare hash. A bare SHA-256 of a value is a confirmation
+// oracle: anyone holding the file and a candidate value can hash it, find
+// the row, and learn that this team holds that customer. HMAC under a
+// purpose key keeps the idempotency and removes the oracle.
+func (s *Sealer) fingerprint(purpose string, plaintext []byte) (string, error) {
+ key, err := s.purposeKey(purpose)
+ if err != nil {
+ return "", err
+ }
+ m := hmac.New(sha256New, key)
+ m.Write(plaintext)
+ return hex.EncodeToString(m.Sum(nil)), nil
+}
+
+// Print returns the equality-only fingerprint for a value of the field
+// identified by fieldKey.
+//
+// **Only ever call this for a field whose Tier is TierPrint**, and only for
+// a high-cardinality one. A print over a low-cardinality field (a five-value
+// stage) publishes five equality classes that frequency analysis puts in
+// order — which is why §4.2 seals `stage` rather than printing it. This
+// function cannot enforce that; the field definition does.
+func (s *Sealer) Print(fieldKey string, plaintext []byte) (string, error) {
+ return s.fingerprint(printPurpose(fieldKey), plaintext)
+}
+
+// RefPrint returns the dedupe fingerprint for a reference target.
+func (s *Sealer) RefPrint(plaintext []byte) (string, error) {
+ return s.fingerprint(refPrintPurpose, plaintext)
+}
diff --git a/internal/fichas/seal/seal_test.go b/internal/fichas/seal/seal_test.go
new file mode 100644
index 0000000..4762d53
--- /dev/null
+++ b/internal/fichas/seal/seal_test.go
@@ -0,0 +1,128 @@
+package seal
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func testDEK(b byte) []byte {
+ k := make([]byte, 32)
+ for i := range k {
+ k[i] = b
+ }
+ return k
+}
+
+func newT(t *testing.T, b byte) *Sealer {
+ t.Helper()
+ s, err := New(testDEK(b))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return s
+}
+
+func TestNewRejectsWrongDEKLength(t *testing.T) {
+ for _, n := range []int{0, 16, 31, 33, 64} {
+ if _, err := New(make([]byte, n)); err == nil {
+ t.Fatalf("New accepted a %d-byte DEK", n)
+ }
+ }
+}
+
+func TestSealOpenRoundTrip(t *testing.T) {
+ s := newT(t, 0x01)
+ want := []byte("Rothar Ltd — renewal due, spoke to Ana")
+ ct, err := s.Seal(PurposeValue, want)
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ if bytes.Contains(ct, want) {
+ t.Fatal("ciphertext contains the plaintext")
+ }
+ got, err := s.Open(PurposeValue, ct)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("round trip: got %q want %q", got, want)
+ }
+}
+
+// The whole reason purposes exist: adjacent columns of related tables must
+// not be interchangeable, so that a bug which swaps them fails loudly
+// instead of decrypting cleanly.
+func TestOpenUnderAnotherPurposeFails(t *testing.T) {
+ s := newT(t, 0x01)
+ ct, err := s.Seal(PurposeValue, []byte("Next action"))
+ if err != nil {
+ t.Fatalf("Seal: %v", err)
+ }
+ for _, p := range []string{PurposeLabel, PurposeEventPayload, PurposeRefTarget} {
+ if _, err := s.Open(p, ct); err == nil {
+ t.Fatalf("a value sealed as %q opened as %q", PurposeValue, p)
+ }
+ }
+}
+
+func TestOpenRejectsShortCiphertext(t *testing.T) {
+ s := newT(t, 0x01)
+ if _, err := s.Open(PurposeValue, []byte{1, 2, 3}); err == nil {
+ t.Fatal("Open accepted a ciphertext shorter than a nonce")
+ }
+}
+
+func TestSealIsNotDeterministic(t *testing.T) {
+ s := newT(t, 0x01)
+ a, _ := s.Seal(PurposeValue, []byte("same"))
+ b, _ := s.Seal(PurposeValue, []byte("same"))
+ if bytes.Equal(a, b) {
+ t.Fatal("two seals of one plaintext are identical: the nonce is not random")
+ }
+}
+
+func TestPrintIsDeterministicAndKeyed(t *testing.T) {
+ s := newT(t, 0x01)
+ other := newT(t, 0x02)
+ v := []byte("ana@rothar.ie")
+
+ p1, err := s.Print("f_email", v)
+ if err != nil {
+ t.Fatalf("Print: %v", err)
+ }
+ p2, _ := s.Print("f_email", v)
+ if p1 != p2 {
+ t.Fatal("Print is not deterministic; equality lookups would miss")
+ }
+ if strings.Contains(p1, "rothar") {
+ t.Fatal("the print leaks the plaintext")
+ }
+
+ // Keyed, not a bare hash: another team's file must not confirm a guess.
+ if op, _ := other.Print("f_email", v); op == p1 {
+ t.Fatal("Print does not depend on the DEK: it is a confirmation oracle")
+ }
+}
+
+// Prints are scoped per field so that equality is not comparable across
+// fields — an observer must not learn that one card's value in one field
+// equals another card's value in a different one.
+func TestPrintIsScopedPerField(t *testing.T) {
+ s := newT(t, 0x01)
+ v := []byte("+353 1 555 0100")
+ a, _ := s.Print("f_phone", v)
+ b, _ := s.Print("f_fax", v)
+ if a == b {
+ t.Fatal("one value prints the same under two fields: prints are not scoped")
+ }
+}
+
+func TestRefPrintIsItsOwnPurpose(t *testing.T) {
+ s := newT(t, 0x01)
+ v := []byte("docs/acme/doc_412")
+ r, _ := s.RefPrint(v)
+ if f, _ := s.Print("f_ref", v); r == f {
+ t.Fatal("RefPrint collides with a field print")
+ }
+}