Add sealed local identities for explicit Home account linking
4 files changed,
+191
−0
internal/memoria/home_identity.go+90 −0internal/memoria/home_identity_test.go+77 −0internal/memoria/home_migrations.go+19 −0internal/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 |
| +); |