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>
6 files changed,
+626
−2
go.mod+3 −2go.sum+9 −0internal/fichas/catalogue.go+166 −0internal/fichas/catalogue_test.go+138 −0internal/fichas/seal/seal.go+182 −0internal/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") |
| + } |
| +} |