oficina / memoria Public

Add sealed local identities for explicit Home account linking

Paul Campbell pushed by paul@keymail.dev 4365b2b98bfa99338ec982e0029df2912bdd8d9e parent b9e5de9
4 files changed, +191 −0
  • internal/memoria/home_identity.go +90 −0
  • internal/memoria/home_identity_test.go +77 −0
  • internal/memoria/home_migrations.go +19 −0
  • internal/memoria/home_migrations/migrations/0001_local_identities.sql +5 −0

CI passed — run details

diff --git a/internal/memoria/home_identity.go b/internal/memoria/home_identity.go
new file mode 100644
index 0000000..f205d49
--- /dev/null
+++ b/internal/memoria/home_identity.go
@@ -0,0 +1,90 @@
+package memoria
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+var errHomeIdentity = errors.New("memoria: Home identity unavailable")
+
+type homeIdentityQueryer interface {
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
+
+const homeAddressFingerprintPurpose = "home.local-address.lookup.v1"
+
+func homeAddressPurpose(id int64) string {
+ return fmt.Sprintf("home.local-address.v1:%d", id)
+}
+
+// localHomeIdentity allocates only after independent local verification. The
+// row is an identity binding, not a membership or content ownership record.
+func (a *app) localHomeIdentity(ctx context.Context, session sessions.Session) (int64, string, error) {
+ if session.Method != "magiclink" && session.Method != "keymail" {
+ return 0, "", errHomeIdentity
+ }
+ address := normaliseAddress(session.Subject)
+ if address == "" || !a.Admit(address) {
+ return 0, "", errHomeIdentity
+ }
+ fingerprint, err := a.store.sealer.Fingerprint(homeAddressFingerprintPurpose, []byte(address))
+ if err != nil {
+ return 0, "", errHomeIdentity
+ }
+ tx, err := a.db.Writer().BeginTx(ctx, nil)
+ if err != nil {
+ return 0, "", errHomeIdentity
+ }
+ defer tx.Rollback()
+ var id int64
+ err = tx.QueryRowContext(ctx, "SELECT id FROM memoria_home_local_identities WHERE address_lookup=?", fingerprint).Scan(&id)
+ if errors.Is(err, sql.ErrNoRows) {
+ result, insertErr := tx.ExecContext(ctx, "INSERT INTO memoria_home_local_identities(address_lookup,address_sealed) VALUES(?,?)", fingerprint, []byte{})
+ if insertErr != nil {
+ return 0, "", errHomeIdentity
+ }
+ id, err = result.LastInsertId()
+ if err != nil || id <= 0 {
+ return 0, "", errHomeIdentity
+ }
+ sealed, sealErr := a.store.sealer.Seal(homeAddressPurpose(id), []byte(address))
+ if sealErr != nil {
+ return 0, "", errHomeIdentity
+ }
+ if _, err = tx.ExecContext(ctx, "UPDATE memoria_home_local_identities SET address_sealed=? WHERE id=?", sealed, id); err != nil {
+ return 0, "", errHomeIdentity
+ }
+ } else if err != nil {
+ return 0, "", errHomeIdentity
+ }
+ label, err := a.lookupHomeLocalIdentity(ctx, tx, id)
+ if err != nil || label != address || tx.Commit() != nil {
+ return 0, "", errHomeIdentity
+ }
+ return id, label, nil
+}
+
+func (a *app) lookupHomeLocalIdentity(ctx context.Context, q homeIdentityQueryer, id int64) (string, error) {
+ if id <= 0 {
+ return "", errHomeIdentity
+ }
+ var fingerprint string
+ var sealed []byte
+ if err := q.QueryRowContext(ctx, "SELECT address_lookup,address_sealed FROM memoria_home_local_identities WHERE id=?", id).Scan(&fingerprint, &sealed); err != nil {
+ return "", errHomeIdentity
+ }
+ plain, err := a.store.sealer.Open(homeAddressPurpose(id), sealed)
+ if err != nil {
+ return "", errHomeIdentity
+ }
+ address := string(plain)
+ lookup, err := a.store.sealer.Fingerprint(homeAddressFingerprintPurpose, plain)
+ if err != nil || lookup != fingerprint || address != normaliseAddress(address) || !a.Admit(address) {
+ return "", errHomeIdentity
+ }
+ return address, nil
+}
diff --git a/internal/memoria/home_identity_test.go b/internal/memoria/home_identity_test.go
new file mode 100644
index 0000000..6731eb4
--- /dev/null
+++ b/internal/memoria/home_identity_test.go
@@ -0,0 +1,77 @@
+package memoria
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+func homeIdentityFixture(t *testing.T) *app {
+ t.Helper()
+ store := storeForTest(t)
+ if _, err := migrate.Apply(t.Context(), store.db, homeIdentitySchema); err != nil {
+ t.Fatal(err)
+ }
+ return &app{db: store.db, store: store, owners: []string{"alice@example.test", "bob@example.test"}}
+}
+
+func TestHomeLocalIdentityRequiresIndependentAdmittedLocalSession(t *testing.T) {
+ a := homeIdentityFixture(t)
+ for _, session := range []sessions.Session{
+ {Subject: "alice@example.test", Method: "home-assertion"},
+ {Subject: "alice@example.test", Method: ""},
+ {Subject: "stranger@example.test", Method: "magiclink"},
+ {Subject: "", Method: "keymail"},
+ } {
+ if _, _, err := a.localHomeIdentity(t.Context(), session); err == nil {
+ t.Fatal("unverified or unadmitted identity allocated a binding")
+ }
+ }
+ var count int
+ if err := a.db.Writer().QueryRow("SELECT COUNT(*) FROM memoria_home_local_identities").Scan(&count); err != nil || count != 0 {
+ t.Fatal("refusal created a binding", count, err)
+ }
+ first, label, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "Alice@Example.Test", Method: "magiclink"})
+ if err != nil || first <= 0 || label != "alice@example.test" {
+ t.Fatal(first, label, err)
+ }
+ again, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "keymail"})
+ if err != nil || again != first {
+ t.Fatal("local account binding changed with method", again, err)
+ }
+ var sealed []byte
+ var lookup string
+ if err := a.db.Writer().QueryRow("SELECT address_lookup,address_sealed FROM memoria_home_local_identities WHERE id=?", first).Scan(&lookup, &sealed); err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(sealed, []byte(label)) || bytes.Contains([]byte(lookup), []byte(label)) {
+ t.Fatal("binding persisted a readable address")
+ }
+ a.owners = []string{"bob@example.test"}
+ if _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil {
+ t.Fatal("removed owner retained Home admission")
+ }
+}
+
+func TestHomeLocalIdentityCiphertextIsBoundToRowAndLookup(t *testing.T) {
+ a := homeIdentityFixture(t)
+ first, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "bob@example.test", Method: "magiclink"})
+ if err != nil || second == first {
+ t.Fatal(second, err)
+ }
+ if _, err := a.db.Writer().Exec("UPDATE memoria_home_local_identities SET address_sealed=(SELECT address_sealed FROM memoria_home_local_identities WHERE id=?) WHERE id=?", second, first); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.lookupHomeLocalIdentity(t.Context(), a.db.Writer(), first); err == nil {
+ t.Fatal("moved ciphertext changed the account identity")
+ }
+ if _, _, err := a.localHomeIdentity(t.Context(), sessions.Session{Subject: "alice@example.test", Method: "magiclink"}); err == nil {
+ t.Fatal("local sign-in repaired a corrupt binding silently")
+ }
+}
diff --git a/internal/memoria/home_migrations.go b/internal/memoria/home_migrations.go
new file mode 100644
index 0000000..75d6b11
--- /dev/null
+++ b/internal/memoria/home_migrations.go
@@ -0,0 +1,19 @@
+package memoria
+
+import (
+ "embed"
+ "io/fs"
+
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed home_migrations/migrations/*.sql
+var homeMigrationFS embed.FS
+
+var homeIdentitySchema = func() *migrate.Set {
+ sub, err := fs.Sub(homeMigrationFS, "home_migrations")
+ if err != nil {
+ panic(err)
+ }
+ return migrate.MustFromFS(sub, "memoria_home_identity")
+}()
diff --git a/internal/memoria/home_migrations/migrations/0001_local_identities.sql b/internal/memoria/home_migrations/migrations/0001_local_identities.sql
new file mode 100644
index 0000000..65a0106
--- /dev/null
+++ b/internal/memoria/home_migrations/migrations/0001_local_identities.sql
@@ -0,0 +1,5 @@
+CREATE TABLE memoria_home_local_identities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ address_lookup TEXT NOT NULL UNIQUE,
+ address_sealed BLOB NOT NULL
+);