From 16438bd1bfa9bf7a2c7439dd57d49c6b3224af74 Mon Sep 17 00:00:00 2001 From: Bretton Date: Thu, 13 Aug 2026 00:02:16 -0700 Subject: [PATCH] =?UTF-8?q?wip(task13):=20cycles=201-6=20=E2=80=94=20custo?= =?UTF-8?q?dian=20RSA,=20ap=5Factors=20store,=20local-part=20derivation,?= =?UTF-8?q?=20mint,=20serving=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outer acceptance contract green: webfinger → Person doc → signed round-trip through the production verifier → sealed-key proof. Co-Authored-By: Claude Fable 5 --- internal/db/migrations/017_ap_actors.sql | 63 ++++ internal/identity/actor_rsa.go | 60 ++++ internal/identity/actor_rsa_test.go | 134 ++++++++ internal/identity/keys.go | 9 +- internal/personas/create_test.go | 310 +++++++++++++++++ internal/personas/localpart.go | 86 +++++ internal/personas/localpart_test.go | 172 ++++++++++ internal/personas/outer_acceptance_test.go | 280 +++++++++++++++ internal/personas/personas.go | 195 +++++++++++ internal/personas/serving.go | 287 ++++++++++++++++ internal/personas/serving_test.go | 382 +++++++++++++++++++++ internal/store/ap_actors.go | 220 ++++++++++++ internal/store/ap_actors_test.go | 287 ++++++++++++++++ internal/store/interfaces.go | 44 +++ internal/store/migrations_test.go | 8 +- 15 files changed, 2535 insertions(+), 2 deletions(-) create mode 100644 internal/db/migrations/017_ap_actors.sql create mode 100644 internal/identity/actor_rsa.go create mode 100644 internal/identity/actor_rsa_test.go create mode 100644 internal/personas/create_test.go create mode 100644 internal/personas/localpart.go create mode 100644 internal/personas/localpart_test.go create mode 100644 internal/personas/outer_acceptance_test.go create mode 100644 internal/personas/personas.go create mode 100644 internal/personas/serving.go create mode 100644 internal/personas/serving_test.go create mode 100644 internal/store/ap_actors.go create mode 100644 internal/store/ap_actors_test.go diff --git a/internal/db/migrations/017_ap_actors.sql b/internal/db/migrations/017_ap_actors.sql new file mode 100644 index 0000000..82f2110 --- /dev/null +++ b/internal/db/migrations/017_ap_actors.sql @@ -0,0 +1,63 @@ +-- +goose Up +-- ap_actors is the other direction from bridged_actors: not fediverse actors +-- given atproto identities, but COVES users given ActivityPub ones (task 13). +-- One Person actor per DID, so the DID is the primary key rather than a +-- surrogate id — there is no second row to point at. +-- +-- actor_id stores the FULL actor URL, origin included, because the origin is +-- not a constant: vanity origins (decision 10) let a deployment serve actors +-- on hosts other than AP_USER_ORIGIN, and every derived URL — webfinger href, +-- HTTP-signature keyId, the signing identity itself — must come from what was +-- minted, not from whatever the config says at read time. +-- +-- (normalized_origin, local_part) is the webfinger lookup key and is unique +-- TOGETHER, not globally: alice@coves.social and alice@vanity.example are two +-- different people, and a global unique on local_part would make the first +-- vanity origin to mint an alice permanently own the name everywhere. The +-- composite constraint is NAMED EXPLICITLY: postgres would default it to +-- ap_actors_normalized_origin_local_part_key, and the store's 23505 → +-- ConflictError mapping switches on the name. +-- +-- rsa_key_sealed is the AP signing key sealed under BRIDGE_KEK and AAD-bound +-- to the DID (identity.Custodian's RSA surface). It is BYTEA and never PEM: +-- the private half exists in postgres only as ciphertext. rsa_key_version +-- makes rotation definable without a schema change. public_key_pem is the +-- published SPKI half, which is public by construction. +-- +-- kind is CHECKed to person|group even though nothing mints a group yet: +-- group actors are reserved for Scope B, so until a code path exists the +-- constraint is the only thing between a typo and a garbage actor kind. +-- +-- Lifecycle is three columns, not one state machine: enabled gates webfinger +-- resolution and is stamped on both transitions, while delivery_paused is the +-- transient #account state (decision 19) where delivery stops but the +-- identity survives. They are independent — a paused actor is still enabled. +-- Federation is default-on (decision 11), hence enabled DEFAULT TRUE. +-- +-- display_name/summary/avatar_url are a cache of the user's atproto profile +-- (task 14 owns the sync), NOT identity: local_part is frozen at creation, so +-- a rename refreshes these and nothing else. +CREATE TABLE ap_actors ( + did TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('person', 'group')), + actor_id TEXT NOT NULL, -- full actor URL, origin included + normalized_origin TEXT NOT NULL, -- scheme-less lowercase host, as Host routing yields + local_part TEXT NOT NULL, -- frozen at creation + rsa_key_sealed BYTEA NOT NULL, -- KEK-sealed, AAD-bound to did + rsa_key_version INT NOT NULL, + public_key_pem TEXT NOT NULL, + enabled BOOL NOT NULL DEFAULT TRUE, + enabled_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disabled_at TIMESTAMPTZ, + delivery_paused BOOL NOT NULL DEFAULT FALSE, + display_name TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT ap_actors_actor_id_key UNIQUE (actor_id), + CONSTRAINT ap_actors_origin_local_part_key UNIQUE (normalized_origin, local_part) +); + +-- +goose Down +DROP TABLE IF EXISTS ap_actors; diff --git a/internal/identity/actor_rsa.go b/internal/identity/actor_rsa.go new file mode 100644 index 0000000..0459e12 --- /dev/null +++ b/internal/identity/actor_rsa.go @@ -0,0 +1,60 @@ +package identity + +import ( + "crypto/rsa" + "crypto/x509" + "fmt" + + "tidepool/internal/errors" +) + +// This file holds the custodian's AP-side RSA surface (task 13): per-actor +// RSA private keys sealed under the bridge KEK, AAD-bound to their DID with +// a constant DISTINCT from actorKeyAADPrefix (the K256 escrow keys), so a +// ciphertext moved between columns fails to open. New keys never touch the +// plaintext-PEM path the v1 service-actor key still uses (keys.go:147). +// +// The sealed plaintext is PKCS#8 DER, not PEM: PEM is base64 with a header, +// so it would be ~40% larger and would put the string "-----BEGIN" inside a +// value that is only ever handled as opaque bytes. DER keeps the ciphertext +// tight and keeps the accidental-plaintext greps honest. + +// EncryptActorRSAKey seals a Coves user's AP signing key for storage in +// ap_actors.rsa_key_sealed, bound to the owning DID. Decrypting the result +// under any other DID — or under the K256 escrow AAD — fails authentication. +func (c *Custodian) EncryptActorRSAKey(did string, key *rsa.PrivateKey) ([]byte, error) { + if did == "" { + return nil, errors.NewValidationError("did", "must not be empty") + } + if key == nil { + return nil, errors.NewValidationError("key", "must not be nil") + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("identity: marshal AP RSA key for %s: %w", did, err) + } + return c.seal(der, []byte(actorRSAKeyAADPrefix+did)) +} + +// DecryptActorRSAKey opens a sealed AP signing key. did must be the DID the +// key was sealed for; an empty DID is refused outright rather than being +// folded into an AAD that could only ever fail, so the caller sees a +// validation error instead of a bare authentication failure. +func (c *Custodian) DecryptActorRSAKey(did string, ciphertext []byte) (*rsa.PrivateKey, error) { + if did == "" { + return nil, errors.NewValidationError("did", "must not be empty") + } + der, err := c.open(ciphertext, []byte(actorRSAKeyAADPrefix+did)) + if err != nil { + return nil, fmt.Errorf("identity: decrypt AP RSA key for %s: %w", did, err) + } + parsed, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, fmt.Errorf("identity: parse AP RSA key for %s: %w", did, err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("identity: AP key for %s is %T, want *rsa.PrivateKey", did, parsed) + } + return key, nil +} diff --git a/internal/identity/actor_rsa_test.go b/internal/identity/actor_rsa_test.go new file mode 100644 index 0000000..0ca74b5 --- /dev/null +++ b/internal/identity/actor_rsa_test.go @@ -0,0 +1,134 @@ +package identity + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "sync" + "testing" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +// The AP-side RSA keys of task 13's coves.social Person actors are sealed by +// the same KEK as the K256 escrow keys, so these tests reuse keys_test.go's +// testKEK/testCustodian helpers. No database is involved. + +const rsaTestDID = "did:plc:ewvi7nxzyoun6zhxrhs64oiz" + +// testRSAKey returns a shared 2048-bit RSA key: generating one per test +// dominates the runtime of a package that otherwise does no crypto work. +var ( + rsaKeyOnce sync.Once + rsaKey *rsa.PrivateKey + rsaKeyErr error +) + +func testRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + rsaKeyOnce.Do(func() { + rsaKey, rsaKeyErr = rsa.GenerateKey(rand.Reader, 2048) + }) + require.NoError(t, rsaKeyErr) + return rsaKey +} + +func TestCustodian_ActorRSAKeyRoundTrip(t *testing.T) { + custodian := testCustodian(t) + key := testRSAKey(t) + + sealed, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, sealed, "EncryptActorRSAKey must return the sealed key") + + opened, err := custodian.DecryptActorRSAKey(rsaTestDID, sealed) + require.NoError(t, err) + require.NotNil(t, opened, "DecryptActorRSAKey must return the key") + assert.True(t, opened.PublicKey.Equal(&key.PublicKey), + "decrypted key's public half must equal the original's") + assert.True(t, opened.Equal(key), "decrypted key must equal the original") +} + +func TestCustodian_ActorRSAKeyBoundToDID(t *testing.T) { + custodian := testCustodian(t) + key := testRSAKey(t) + + sealed, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, sealed) + + _, err = custodian.DecryptActorRSAKey("did:plc:44ybard66vv44zksje25o7dz", sealed) + require.Error(t, err, + "an RSA key sealed for one DID must not open under another (AAD binding)") +} + +func TestCustodian_ActorRSAKeyAADDistinctFromK256(t *testing.T) { + // The RSA surface must NOT reuse the K256 actor-key AAD: a sealed AP + // signing key copied into bridged_actors.signing_key (or the reverse) + // must fail to open, even under the same KEK and the same DID. Only a + // cross test proves the constants differ. + custodian := testCustodian(t) + key := testRSAKey(t) + + rsaSealed, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, rsaSealed) + + k256, err := atcrypto.GeneratePrivateKeyK256() + require.NoError(t, err) + k256Sealed, err := custodian.EncryptActorKey(rsaTestDID, k256) + require.NoError(t, err) + + // Isolated at the AAD layer (like TestCustodian_CrossContextAADRejected): + // if this open succeeded, the two AADs would be the same string. + _, err = custodian.open(rsaSealed, []byte(actorKeyAADPrefix+rsaTestDID)) + require.Error(t, err, + "the AP RSA AAD must differ from the K256 actor-key AAD for the same DID") + + // ... and through the production wrappers, both directions. + _, err = custodian.DecryptActorKey(rsaTestDID, rsaSealed) + require.Error(t, err, "a sealed AP RSA key must not open as a K256 signing key") + + _, err = custodian.DecryptActorRSAKey(rsaTestDID, k256Sealed) + require.Error(t, err, "a sealed K256 signing key must not open as an AP RSA key") +} + +func TestCustodian_ActorRSAKeyCiphertextLeaksNothing(t *testing.T) { + custodian := testCustodian(t) + key := testRSAKey(t) + + first, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, first) + second, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, second) + + assert.NotContains(t, string(first), "-----BEGIN", + "a sealed key must never carry a PEM header (the ap_actors grep test)") + assert.False(t, bytes.Contains(first, key.D.Bytes()), + "ciphertext must not contain the raw private exponent") + assert.False(t, bytes.Equal(first, second), + "each seal must use a fresh nonce") +} + +func TestCustodian_ActorRSAKeyRejectsEmptyDID(t *testing.T) { + custodian := testCustodian(t) + key := testRSAKey(t) + + _, err := custodian.EncryptActorRSAKey("", key) + require.Error(t, err, "sealing without a DID has no AAD to bind to") + assert.True(t, errors.IsValidation(err), "got %v", err) + + sealed, err := custodian.EncryptActorRSAKey(rsaTestDID, key) + require.NoError(t, err) + require.NotEmpty(t, sealed) + + _, err = custodian.DecryptActorRSAKey("", sealed) + require.Error(t, err, "opening without a DID must be refused explicitly") + assert.True(t, errors.IsValidation(err), "got %v", err) +} diff --git a/internal/identity/keys.go b/internal/identity/keys.go index 4c12d29..35ee245 100644 --- a/internal/identity/keys.go +++ b/internal/identity/keys.go @@ -36,7 +36,14 @@ const RotationKeyName = "plc-rotation" // sealed key copied into another row (or another column) fails to open. const ( actorKeyAADPrefix = "tidepool:actor-signing-key:v1:" - rotationKeyAAD = "tidepool:plc-rotation-key:v1" + // actorRSAKeyAADPrefix seals the AP-side RSA keys of task 13. It is + // deliberately distinct from actorKeyAADPrefix even though both bind the + // same DID under the same KEK: the atproto escrow key and the + // ActivityPub signing key are different trust domains, and a ciphertext + // that wandered from one column into the other must fail to open rather + // than silently authenticate in the wrong domain. + actorRSAKeyAADPrefix = "tidepool:actor-rsa-key:v1:" + rotationKeyAAD = "tidepool:plc-rotation-key:v1" ) // Custodian seals and opens per-actor secp256k1 private keys with the diff --git a/internal/personas/create_test.go b/internal/personas/create_test.go new file mode 100644 index 0000000..2f07cc5 --- /dev/null +++ b/internal/personas/create_test.go @@ -0,0 +1,310 @@ +package personas + +import ( + "bytes" + "context" + "crypto/rsa" + "database/sql" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/ap" + "tidepool/internal/errors" + "tidepool/internal/identity" + "tidepool/internal/store" + "tidepool/internal/testutil" +) + +// personasTestDB returns a migrated connection with ap_actors emptied, so +// the collision tests start from a known namespace. +func personasTestDB(t *testing.T) *sql.DB { + t.Helper() + database := testutil.DB(t) + testutil.Truncate(t, database, "ap_actors") + return database +} + +func newTestService(t *testing.T, database *sql.DB) (*Service, *identity.Custodian) { + t.Helper() + custodian, err := identity.NewCustodian(testKEK) + require.NoError(t, err) + svc, err := New(Options{DB: database, Custodian: custodian, UserOrigin: userOrigin}) + require.NoError(t, err) + require.NotNil(t, svc, "personas.New must return a service") + return svc, custodian +} + +// TestCreateActorForDID_Mint is the happy path: one call turns a Coves DID +// into a complete, sealed, persisted AP identity. +func TestCreateActorForDID_Mint(t *testing.T) { + database := personasTestDB(t) + svc, custodian := newTestService(t, database) + ctx := t.Context() + did := testDID(t) + + actor, err := svc.CreateActorForDID(ctx, did, testHandle) + require.NoError(t, err) + require.NotNil(t, actor, "CreateActorForDID must return the minted actor") + + assert.Equal(t, did, actor.DID) + assert.Equal(t, store.ActorTypePerson, actor.Kind, + "Person, not Service: Lemmy rejects votes from Service actors as bots") + assert.Equal(t, userOrigin+"/ap/actor/"+did, actor.ActorID, + "actor_id is the full URL, origin included") + assert.Equal(t, userHost, actor.NormalizedOrigin, + "normalized_origin is the scheme-less lowercase host — exactly what Host routing yields") + assert.Equal(t, testLocalPart, actor.LocalPart) + assert.True(t, actor.Enabled, "federation is default-on") + + // The private half exists only as ciphertext. + require.NotEmpty(t, actor.RSAKeySealed, "the minted key must be sealed into the row") + assert.NotContains(t, string(actor.RSAKeySealed), "-----BEGIN", + "the RSA private key must never be stored unsealed") + assert.Equal(t, 1, actor.RSAKeyVersion, "the first key is version 1") + + // The published half is real, and it is the public half of the sealed key. + published, err := ap.ParsePublicKeyPEM([]byte(actor.PublicKeyPEM)) + require.NoError(t, err, "public_key_pem must parse") + opened, err := custodian.DecryptActorRSAKey(did, actor.RSAKeySealed) + require.NoError(t, err, "the custodian must open the sealed key under its DID") + require.NotNil(t, opened) + assert.True(t, opened.PublicKey.Equal(published), + "the published key must be the public half of the sealed private key") + assert.Equal(t, ap.ServiceKeyBits, opened.N.BitLen(), "Lemmy expects 2048-bit RSA") + + // The row is persisted, not just returned. + stored, err := store.NewAPActors(database).GetByDID(ctx, did) + require.NoError(t, err, "the minted actor must be readable from ap_actors") + require.NotNil(t, stored) + assert.Equal(t, actor.ActorID, stored.ActorID) + assert.Equal(t, actor.LocalPart, stored.LocalPart) + assert.Equal(t, actor.NormalizedOrigin, stored.NormalizedOrigin) + assert.True(t, bytes.Equal(actor.RSAKeySealed, stored.RSAKeySealed)) + assert.Equal(t, actor.PublicKeyPEM, stored.PublicKeyPEM) +} + +// TestCreateActorForDID_Idempotent covers the lazy get-or-create contract: +// task 14/16 call this on every federating interaction, and a handle change +// must not re-mint or re-derive anything. +func TestCreateActorForDID_Idempotent(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + ctx := t.Context() + did := testDID(t) + + first, err := svc.CreateActorForDID(ctx, did, testHandle) + require.NoError(t, err) + require.NotNil(t, first) + + // Same DID, DIFFERENT handle: the user renamed. The local part is + // FROZEN — federated mentions of @alice@coves.social must keep + // resolving — so this returns the existing row untouched. + second, err := svc.CreateActorForDID(ctx, did, "alicia.coves.social") + require.NoError(t, err, "get-or-create must be idempotent") + require.NotNil(t, second) + + assert.Equal(t, first.LocalPart, second.LocalPart, + "the local part is frozen at creation; a handle change must not re-derive it") + assert.Equal(t, testLocalPart, second.LocalPart) + assert.Equal(t, first.ActorID, second.ActorID) + assert.True(t, bytes.Equal(first.RSAKeySealed, second.RSAKeySealed), + "re-minting a key would invalidate every signature the old one made") + assert.Equal(t, first.CreatedAt, second.CreatedAt) + + assert.Equal(t, 1, countActors(t, database, did), "exactly one row per DID") +} + +// TestCreateActorForDID_CollisionSuffix pins the suffix FORM: the first +// claimant keeps the bare local part, later ones get -2, -3, ... +func TestCreateActorForDID_CollisionSuffix(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + ctx := t.Context() + + firstDID, secondDID, thirdDID := testDID(t), testDID(t), testDID(t) + + first, err := svc.CreateActorForDID(ctx, firstDID, testHandle) + require.NoError(t, err) + require.NotNil(t, first) + assert.Equal(t, "alice", first.LocalPart) + + second, err := svc.CreateActorForDID(ctx, secondDID, testHandle) + require.NoError(t, err) + require.NotNil(t, second) + assert.Equal(t, "alice-2", second.LocalPart) + assert.Equal(t, userOrigin+"/ap/actor/"+secondDID, second.ActorID, + "the actor URL keys on the DID; only the local part is suffixed") + + third, err := svc.CreateActorForDID(ctx, thirdDID, testHandle) + require.NoError(t, err) + require.NotNil(t, third) + assert.Equal(t, "alice-3", third.LocalPart) + + // Each is independently resolvable under its own local part. + actors := store.NewAPActors(database) + for _, want := range []*store.APActor{first, second, third} { + got, err := actors.GetByOriginLocalPart(ctx, userHost, want.LocalPart) + require.NoError(t, err, "webfinger must resolve %q", want.LocalPart) + require.NotNil(t, got) + assert.Equal(t, want.DID, got.DID) + } +} + +// TestCreateActorForDID_ConcurrentDistinctDIDs proves the suffix search is +// driven by the unique violation, not by a SELECT-then-INSERT pre-check: N +// simultaneous mints of the same derived local part must all land, each with +// its own name. +func TestCreateActorForDID_ConcurrentDistinctDIDs(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + ctx := t.Context() + + const racers = 5 + dids := make([]string, racers) + for i := range dids { + dids[i] = testDID(t) + } + + results := make([]*store.APActor, racers) + failures := make([]error, racers) + var wg sync.WaitGroup + for i := range dids { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i], failures[i] = svc.CreateActorForDID(ctx, dids[i], testHandle) + }(i) + } + wg.Wait() + + seen := make(map[string]string, racers) + for i, err := range failures { + require.NoError(t, err, "concurrent mint %d must not fail", i) + require.NotNil(t, results[i], "concurrent mint %d returned no actor", i) + local := results[i].LocalPart + if other, dup := seen[local]; dup { + require.Failf(t, "duplicate local part", + "%s and %s both claimed %q", other, dids[i], local) + } + seen[local] = dids[i] + assert.True(t, local == "alice" || strings.HasPrefix(local, "alice-"), + "every racer derives from alice, got %q", local) + } + require.Len(t, seen, racers, "each racer must end up with its own local part") + assert.Contains(t, seen, "alice", "exactly one racer keeps the bare local part") +} + +// TestCreateActorForDID_ConcurrentSameDID: the get-or-create race. Two +// callers minting the same DID must converge on one row — losing the insert +// must return the winner's actor, not an error and not a second key. +func TestCreateActorForDID_ConcurrentSameDID(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + ctx := t.Context() + did := testDID(t) + + var ( + wg sync.WaitGroup + actors [2]*store.APActor + failure [2]error + ) + for i := range actors { + wg.Add(1) + go func(i int) { + defer wg.Done() + actors[i], failure[i] = svc.CreateActorForDID(ctx, did, testHandle) + }(i) + } + wg.Wait() + + require.NoError(t, failure[0]) + require.NoError(t, failure[1], "the loser of the mint race must get the winner's row") + require.NotNil(t, actors[0]) + require.NotNil(t, actors[1]) + assert.Equal(t, actors[0].ActorID, actors[1].ActorID) + assert.Equal(t, actors[0].LocalPart, actors[1].LocalPart) + assert.True(t, bytes.Equal(actors[0].RSAKeySealed, actors[1].RSAKeySealed), + "both callers must see the same key: a second key would orphan signatures") + assert.Equal(t, 1, countActors(t, database, did)) +} + +// TestActorSigner signs with the sealed key and verifies against the +// PUBLISHED key. The resolver is a stub on purpose: the outer acceptance +// test owns the real client/served-document path. +func TestActorSigner(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + ctx := t.Context() + did := testDID(t) + + actor, err := svc.CreateActorForDID(ctx, did, testHandle) + require.NoError(t, err) + require.NotNil(t, actor) + + signer, err := svc.actorSigner(ctx, did) + require.NoError(t, err) + require.NotNil(t, signer, "actorSigner must return a signer for a minted actor") + require.Equal(t, actor.ActorID+"#main-key", signer.KeyID(), + "the keyId must be the one the actor document publishes") + + published, err := ap.ParsePublicKeyPEM([]byte(actor.PublicKeyPEM)) + require.NoError(t, err) + verifier := ap.NewVerifier(ap.KeyResolverFunc( + func(_ context.Context, keyID string) (*rsa.PublicKey, string, error) { + require.Equal(t, actor.ActorID+"#main-key", keyID) + return published, actor.ActorID, nil + })) + + body := []byte(`{"@context":"https://www.w3.org/ns/activitystreams","type":"Create"}`) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + userOrigin+"/ap/inbox", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", ap.ContentTypeActivityJSON) + require.NoError(t, signer.SignRequest(req, body)) + + verified, err := verifier.Verify(ctx, req, body) + require.NoError(t, err, "a signature from the unsealed key must verify against the published key") + assert.Equal(t, actor.ActorID, verified) + + // An unminted DID has no key to hand out. + _, err = svc.actorSigner(ctx, testDID(t)) + assert.True(t, errors.IsNotFound(err), "unknown DID must be IsNotFound, got %v", err) +} + +// TestCreateActorForDID_NoProfileFetch pins that minting is local-only: the +// profile cache starts empty (task 14 owns the sync) and nothing reaches the +// network — this harness has no fixture server, so an egress attempt would +// stall or fail rather than quietly succeed. +func TestCreateActorForDID_NoProfileFetch(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + did := testDID(t) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + start := time.Now() + actor, err := svc.CreateActorForDID(ctx, did, testHandle) + require.NoError(t, err, "minting must not depend on a reachable appview/PDS") + require.NotNil(t, actor) + assert.Less(t, time.Since(start), 5*time.Second, + "minting is a local operation: key generation plus one INSERT") + + assert.Empty(t, actor.DisplayName, "the profile cache is task 14's to fill") + assert.Empty(t, actor.Summary) + assert.Empty(t, actor.AvatarURL) +} + +func countActors(t *testing.T, database *sql.DB, did string) int { + t.Helper() + var count int + require.NoError(t, database.QueryRowContext(context.Background(), + `SELECT count(*) FROM ap_actors WHERE did = $1`, did).Scan(&count)) + return count +} diff --git a/internal/personas/localpart.go b/internal/personas/localpart.go new file mode 100644 index 0000000..5e035b8 --- /dev/null +++ b/internal/personas/localpart.go @@ -0,0 +1,86 @@ +package personas + +import ( + "fmt" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "tidepool/internal/errors" +) + +// MaxLocalPartLen caps a derived local part. Lemmy stores a remote actor's +// preferredUsername in a varchar(255); the derivation stays under that with +// room left for a collision suffix ("-2" ... "-99"), so a suffixed part can +// never overflow what the far side will accept. +const MaxLocalPartLen = 251 + +// DeriveLocalPart maps an atproto handle to the WebFinger local part (and +// preferredUsername) of the user-origin actor, FROZEN at actor creation. +// +// nativeSuffix is the user origin's host (e.g. "coves.social"), injected +// rather than read from config, so the derivation is a pure function and +// vanity origins can each derive their own space. +// +// The rules (task 13): +// - exactly one label in front of nativeSuffix — the native handle space — +// yields that label: "alice.coves.social" → "alice"; +// - anything else keeps its FULL handle, preserving provenance: +// "bretton.dev" → "bretton.dev", "alice.blog.coves.social" → +// "alice.blog.coves.social" (a deeper subdomain is not native); +// - the result is lowercased and truncated to MaxLocalPartLen. +// +// Dots in the local part are deliberate: Lemmy 0.19.20 accepts them both in +// remote WebFinger resolution and in its mention regex. +// +// The native match is on a LABEL BOUNDARY ("."+nativeSuffix), never a bare +// suffix test. A bare strings.HasSuffix would read "evilcoves.social" as +// native and derive the local part "evil" — letting anyone who registers a +// domain ending in our host's name choose their name ON OUR ORIGIN, which is +// an impersonation primitive rather than a formatting bug. The apex itself is +// refused for the same reason: it is the instance actor's own name, and an +// actor holding it would collide with the origin's identity at WebFinger. +// +// A caller that derives a local part is deciding an actor's permanent name: +// the result is written once at creation and FROZEN there, so a later handle +// change refreshes the profile cache and nothing else. That is why this is a +// pure function of (handle, nativeSuffix) with no clock, config, or store +// reads — the same inputs must agree forever, including after the user +// renames and after the deployment's origin changes. +func DeriveLocalPart(handle, nativeSuffix string) (string, error) { + // Without a suffix every handle would look native and surrender its + // first label, so an unset origin is refused rather than defaulted. + if nativeSuffix == "" { + return "", errors.NewValidationError("native_suffix", "must not be empty") + } + suffix := strings.ToLower(nativeSuffix) + + // Case is not identity: handles are compared and stored lowercased, and + // normalizing before parsing keeps "Alice.Coves.Social" and its + // lowercase twin from deriving two different actors. + normalized := strings.ToLower(handle) + + // atproto handle syntax is the whole input gate: it rejects the empty + // string, single labels, leading/trailing dots, underscores, non-ASCII, + // and anything over 253 chars, so nothing downstream needs IDNA or + // punycode machinery. + if _, err := syntax.ParseHandle(normalized); err != nil { + return "", errors.NewValidationError("handle", err.Error()) + } + if normalized == suffix { + return "", errors.NewValidationError("handle", + fmt.Sprintf("%q is the origin apex, not a user handle", suffix)) + } + + local := normalized + if prefix, ok := strings.CutSuffix(normalized, "."+suffix); ok && !strings.Contains(prefix, ".") { + // Exactly one label in front of the suffix: the native space. + // A deeper subdomain keeps its full handle — it is a different + // namespace that merely lives under the same domain. + local = prefix + } + if len(local) > MaxLocalPartLen { + local = local[:MaxLocalPartLen] + } + return local, nil +} diff --git a/internal/personas/localpart_test.go b/internal/personas/localpart_test.go new file mode 100644 index 0000000..a0fe8b8 --- /dev/null +++ b/internal/personas/localpart_test.go @@ -0,0 +1,172 @@ +package personas + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" +) + +// longHandle is a 253-char handle (the atproto maximum): three 63-char +// labels plus a 61-char TLD label. Derived as a foreign handle it is the +// full 253 chars, which must be truncated to MaxLocalPartLen. +var longHandle = strings.Repeat("a", 63) + "." + + strings.Repeat("b", 63) + "." + + strings.Repeat("c", 63) + "." + + strings.Repeat("d", 61) + +// TestDeriveLocalPart is the golden table for the frozen local part. +func TestDeriveLocalPart(t *testing.T) { + require.Len(t, longHandle, 253, "fixture must sit exactly on the handle length limit") + + tests := []struct { + name string + handle string + nativeSuffix string + want string + }{ + { + name: "native single label", + handle: "alice.coves.social", + nativeSuffix: "coves.social", + want: "alice", + }, + { + name: "native label with hyphen survives", + handle: "alice-b.coves.social", + nativeSuffix: "coves.social", + // Lemmy's in-text mention regex does not match hyphens in the + // user part (a display nit); resolution still works, so the + // hyphen stands rather than being encoded away. + want: "alice-b", + }, + { + name: "uppercase input normalizes", + handle: "Alice.Coves.Social", + nativeSuffix: "coves.social", + want: "alice", + }, + { + name: "deep subdomain is not native", + handle: "alice.blog.coves.social", + nativeSuffix: "coves.social", + want: "alice.blog.coves.social", + }, + { + name: "foreign domain keeps the full handle", + handle: "bretton.dev", + nativeSuffix: "coves.social", + want: "bretton.dev", + }, + { + name: "foreign handle with many labels", + handle: "alice.staging.eu.example.com", + nativeSuffix: "coves.social", + want: "alice.staging.eu.example.com", + }, + { + name: "another PDS's native space is foreign here", + handle: "alice.bsky.social", + nativeSuffix: "coves.social", + want: "alice.bsky.social", + }, + { + name: "suffix match must be on a label boundary", + handle: "evilcoves.social", + nativeSuffix: "coves.social", + // A bare strings.HasSuffix would derive "evil" here and hand an + // attacker-chosen local part on our own origin. + want: "evilcoves.social", + }, + { + name: "vanity origin derives its own native space", + handle: "alice.vanity.example", + nativeSuffix: "vanity.example", + want: "alice", + }, + { + name: "coves handle is foreign under a vanity origin", + handle: "alice.coves.social", + nativeSuffix: "vanity.example", + want: "alice.coves.social", + }, + { + name: "253-char handle truncates to the cap", + handle: longHandle, + nativeSuffix: "coves.social", + want: longHandle[:MaxLocalPartLen], + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := DeriveLocalPart(tc.handle, tc.nativeSuffix) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + assert.LessOrEqual(t, len(got), MaxLocalPartLen, + "a derived local part must leave room for a collision suffix inside Lemmy's varchar(255)") + }) + } +} + +// TestDeriveLocalPart_Rejects covers the inputs that must not produce a +// local part at all. The gate is atproto handle syntax (indigo's +// syntax.ParseHandle), so no IDNA/punycode machinery is needed here: +// non-ASCII never reaches the derivation. +func TestDeriveLocalPart_Rejects(t *testing.T) { + tests := []struct { + name string + handle string + nativeSuffix string + }{ + {"empty handle", "", "coves.social"}, + {"single label", "alice", "coves.social"}, + {"leading dot", ".alice.coves.social", "coves.social"}, + {"trailing dot", "alice.coves.social.", "coves.social"}, + {"underscore", "alice_bob.coves.social", "coves.social"}, + {"non-ASCII", "álice.coves.social", "coves.social"}, + {"over 253 chars", longHandle + "x", "coves.social"}, + { + // The apex is the instance actor's own name: an actor claiming + // it would collide with the origin's identity at webfinger. + name: "the origin apex itself", + handle: "coves.social", + nativeSuffix: "coves.social", + }, + { + name: "the origin apex, differently cased", + handle: "Coves.Social", + nativeSuffix: "coves.social", + }, + { + // Without a suffix every handle would look native and derive + // its first label — a silent hijack of the whole namespace. + name: "empty native suffix", + handle: "alice.coves.social", + nativeSuffix: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := DeriveLocalPart(tc.handle, tc.nativeSuffix) + require.Error(t, err, "must not derive a local part from %q", tc.handle) + assert.True(t, errors.IsValidation(err), "want a validation error, got %v", err) + assert.Empty(t, got, "a rejected handle must not also return a local part") + }) + } +} + +// TestDeriveLocalPart_Deterministic pins that the derivation is pure: the +// local part is FROZEN at creation, so the same inputs must always agree. +func TestDeriveLocalPart_Deterministic(t *testing.T) { + first, err := DeriveLocalPart("alice.coves.social", "coves.social") + require.NoError(t, err) + second, err := DeriveLocalPart("ALICE.coves.social", "coves.social") + require.NoError(t, err) + assert.Equal(t, first, second) + assert.Equal(t, "alice", first) +} diff --git a/internal/personas/outer_acceptance_test.go b/internal/personas/outer_acceptance_test.go new file mode 100644 index 0000000..5d8d98d --- /dev/null +++ b/internal/personas/outer_acceptance_test.go @@ -0,0 +1,280 @@ +package personas + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "tidepool/internal/ap" + "tidepool/internal/identity" +) + +// The user origin under test. AP_USER_ORIGIN's production value; the config +// var itself is not wired here — the acceptance test constructs the Service +// directly rather than booting main. +const ( + userOrigin = "https://coves.social" + userHost = "coves.social" + + // A native Coves handle: the local part derives to "alice" (a + // non-native handle like bretton.dev would keep the full handle). + testHandle = "alice.coves.social" + testLocalPart = "alice" +) + +// testKEK seals the test actor's AP RSA key (32 bytes, AES-256). +var testKEK = []byte("0123456789abcdef0123456789abcdef") + +// TestUserOriginActorSurface is the outer acceptance test for task 13. +// +// GIVEN the bridge configured with user origin https://coves.social, +// WHEN CreateActorForDID mints a Person actor for a Coves DID, +// THEN, driven over HTTP with Host: coves.social — +// +// 1. WebFinger resolves acct:alice@coves.social to the actor URL; +// 2. that URL serves a Lemmy-parseable Person document; +// 3. a request signed with that actor's key verifies through the +// EXISTING ap.Verifier, whose resolver is a real ap.Client fetching +// the served document (authority binding included); +// 4. the private key is nowhere in the row in the clear. +// +// No network: the only outbound host the client may dial is coves.social, +// and that is rewritten onto the httptest listener. +func TestUserOriginActorSurface(t *testing.T) { + // Start from an empty namespace: a sibling test's alice would push this + // actor's frozen local part to alice-2 and the webfinger assertions + // below would be asserting the wrong name. + conn := personasTestDB(t) + ctx := context.Background() + + custodian, err := identity.NewCustodian(testKEK) + require.NoError(t, err, "build custodian") + + svc, err := New(Options{DB: conn, Custodian: custodian, UserOrigin: userOrigin}) + require.NoError(t, err, "build personas service") + require.NotNil(t, svc, "personas.New must return a service") + + did := testDID(t) + + // WHEN: the DID's first federating interaction mints its actor. + actor, err := svc.CreateActorForDID(ctx, did, testHandle) + require.NoError(t, err, "CreateActorForDID must mint an actor for %s", did) + require.NotNil(t, actor, "CreateActorForDID must return the minted actor") + + wantActorID := userOrigin + "/ap/actor/" + did + require.Equal(t, wantActorID, actor.ActorID, + "the stored actor_id is the full actor URL, origin included") + require.Equal(t, testLocalPart, actor.LocalPart, + "a native handle %q derives the local part %q", testHandle, testLocalPart) + + // --------------------------------------------------------------- + // 1. WebFinger on the user origin. + // --------------------------------------------------------------- + resource := fmt.Sprintf("acct:%s@%s", testLocalPart, userHost) + rec := serveOnUserOrigin(svc, http.MethodGet, + "/.well-known/webfinger?resource="+url.QueryEscape(resource), nil) + require.Equal(t, http.StatusOK, rec.Code, + "GET webfinger for %s (Host %s) must resolve the minted actor; body=%s", + resource, userHost, rec.Body.String()) + + var jrd ap.WebFingerResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &jrd), + "webfinger must serve a JRD document, got %s", rec.Body.String()) + selfHref := "" + for _, link := range jrd.Links { + if link.Rel == "self" && strings.Contains(link.Type, "activity+json") { + selfHref = link.Href + break + } + } + require.Equal(t, wantActorID, selfHref, + `the rel="self" link (type application/activity+json) must href the actor URL; JRD=%s`, + rec.Body.String()) + + // --------------------------------------------------------------- + // 2. The Person document at the href webfinger just handed out. + // --------------------------------------------------------------- + actorPath := mustPath(t, selfHref) + rec = serveOnUserOrigin(svc, http.MethodGet, actorPath, + http.Header{"Accept": []string{ap.ContentTypeActivityJSON}}) + require.Equal(t, http.StatusOK, rec.Code, + "GET %s (Host %s) must serve the actor document; body=%s", + actorPath, userHost, rec.Body.String()) + require.Contains(t, rec.Header().Get("Content-Type"), "activity+json", + "the actor document must be served as application/activity+json") + + var doc map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc), + "actor document must be JSON, got %s", rec.Body.String()) + + require.Equal(t, wantActorID, doc["id"], "actor id") + require.Equal(t, "Person", doc["type"], + "Person, NOT Service: Lemmy rejects votes from Service actors as bots") + require.Equal(t, testLocalPart, doc["preferredUsername"], "preferredUsername") + + publicKey, ok := doc["publicKey"].(map[string]any) + require.True(t, ok, "actor document must carry a publicKey object, got %v", doc["publicKey"]) + require.Equal(t, wantActorID+"#main-key", publicKey["id"], "publicKey.id") + require.Equal(t, wantActorID, publicKey["owner"], "publicKey.owner") + publishedPEM, ok := publicKey["publicKeyPem"].(string) + require.True(t, ok, "publicKey.publicKeyPem must be a string, got %v", publicKey["publicKeyPem"]) + publishedKey, err := ap.ParsePublicKeyPEM([]byte(publishedPEM)) + require.NoError(t, err, "publicKeyPem must parse as an RSA public key") + require.NotNil(t, publishedKey) + + wantInbox := userOrigin + "/ap/inbox" + require.Equal(t, wantInbox, doc["inbox"], "inbox") + endpoints, ok := doc["endpoints"].(map[string]any) + require.True(t, ok, "actor document must carry endpoints, got %v", doc["endpoints"]) + require.Equal(t, wantInbox, endpoints["sharedInbox"], "endpoints.sharedInbox") + + // outbox is REQUIRED for Lemmy's Person deserialization — omitting it + // rejects the whole actor. A URL string or an inline collection object + // both satisfy the shape. + outbox, present := doc["outbox"] + require.True(t, present, "actor document must carry an outbox field (Lemmy requires it)") + switch v := outbox.(type) { + case string: + require.NotEmpty(t, v, "outbox URL must not be empty") + case map[string]any: + require.NotEmpty(t, v, "inline outbox collection must not be empty") + default: + require.Failf(t, "bad outbox shape", + "outbox must be a URL string or a collection object, got %T (%v)", outbox, outbox) + } + + published, ok := doc["published"].(string) + require.True(t, ok, "actor document must carry published, got %v", doc["published"]) + _, err = time.Parse(time.RFC3339, published) + require.NoError(t, err, "published must be RFC3339, got %q", published) + + // --------------------------------------------------------------- + // 3. A signed request from this actor verifies through the EXISTING + // verifier, resolving the key off the document we just served. + // --------------------------------------------------------------- + origin := httptest.NewServer(svc) + t.Cleanup(origin.Close) + + // The rewrite transport is deliberately NOT an *http.Transport, so + // ap.NewClient's guardedTransport passes it through unchanged + // (client.go:279-294) — the request keeps its https://coves.social URL + // and Host while the bytes go to the httptest listener. That is what + // makes the client's authority binding (fetched id must match the + // fetch URL's authority) a real assertion here. + client := ap.NewClient(ap.ClientOptions{ + HTTPClient: &http.Client{Transport: originRewriteTransport{ + host: userHost, + target: origin.Listener.Addr().String(), + }}, + }) + verifier := ap.NewVerifier(client) + + signer, err := svc.actorSigner(ctx, did) + require.NoError(t, err, "the actor's sealed key must unseal into a signer") + require.NotNil(t, signer, "actorSigner must return a signer for %s", did) + require.Equal(t, wantActorID+"#main-key", signer.KeyID(), + "the signer's keyId must be the one published in the actor document") + + body := []byte(`{"@context":"https://www.w3.org/ns/activitystreams",` + + `"id":"https://coves.social/ap/activity/acceptance-probe","type":"Create"}`) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, wantInbox, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", ap.ContentTypeActivityJSON) + require.NoError(t, signer.SignRequest(req, body), "sign the inbox POST") + + verifiedActorID, err := verifier.Verify(ctx, req, body) + require.NoError(t, err, + "the served actor document must feed the production key resolver") + require.Equal(t, wantActorID, verifiedActorID, + "Verify must attribute the signature to the minted actor") + + // --------------------------------------------------------------- + // 4. Sealed-key proof: the private key is never at rest in the clear. + // Expected schema (migration 017): ap_actors(did PK, + // rsa_key_sealed BYTEA, public_key_pem TEXT, ...). + // --------------------------------------------------------------- + var sealed, storedPubPEM []byte + err = conn.QueryRowContext(ctx, + `SELECT rsa_key_sealed, public_key_pem FROM ap_actors WHERE did = $1`, did). + Scan(&sealed, &storedPubPEM) + if err != nil { + require.Failf(t, "ap_actors key columns not readable", + "SELECT rsa_key_sealed, public_key_pem FROM ap_actors WHERE did = %q: %v\n"+ + "(migration 017 must create ap_actors with a sealed private-key column "+ + "and a public-key PEM column)", did, err) + } + require.NotEmpty(t, sealed, "ap_actors.rsa_key_sealed must hold the sealed key") + require.NotContains(t, string(sealed), "-----BEGIN", + "the AP RSA private key must never be stored unsealed") + require.NotEmpty(t, storedPubPEM, "ap_actors.public_key_pem must hold the published key") + + storedPub, err := ap.ParsePublicKeyPEM(storedPubPEM) + require.NoError(t, err, "the stored public-key PEM must parse") + require.True(t, storedPub.Equal(publishedKey), + "the stored public key must be the one published in the actor document") + + opened, err := custodian.DecryptActorRSAKey(did, sealed) + require.NoError(t, err, "the custodian must open the sealed key under its DID") + require.NotNil(t, opened, "DecryptActorRSAKey must return the RSA private key") + require.True(t, opened.PublicKey.Equal(publishedKey), + "the sealed private key must match the published public key") +} + +// serveOnUserOrigin drives the personas handler directly with Host set to +// the user origin. +func serveOnUserOrigin(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder { + return serveOnHost(h, userHost, method, target, header) +} + +// originRewriteTransport sends requests for the user origin to the local +// httptest listener while preserving the request's URL and Host, so the AP +// client believes it is talking to https://coves.social. Anything else is +// refused: these tests never touch the network. +type originRewriteTransport struct { + host string + target string +} + +func (rt originRewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if !strings.EqualFold(req.URL.Hostname(), rt.host) { + return nil, fmt.Errorf("refusing outbound request to %s: tests may only reach %s", + req.URL, rt.host) + } + clone := req.Clone(req.Context()) + clone.Host = req.URL.Host + clone.URL.Scheme = "http" + clone.URL.Host = rt.target + return http.DefaultTransport.RoundTrip(clone) +} + +// mustPath returns the path (plus query) of an absolute URL. +func mustPath(t *testing.T, raw string) string { + t.Helper() + parsed, err := url.Parse(raw) + require.NoError(t, err, "parse actor URL %q", raw) + require.Equal(t, userHost, parsed.Host, "the actor URL must live on the user origin") + if parsed.RawQuery != "" { + return parsed.EscapedPath() + "?" + parsed.RawQuery + } + return parsed.EscapedPath() +} + +// testDID returns a fresh did:plc-shaped identifier so reruns never collide. +func testDID(t *testing.T) string { + t.Helper() + raw := make([]byte, 15) + _, err := rand.Read(raw) + require.NoError(t, err) + return "did:plc:" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)) +} diff --git a/internal/personas/personas.go b/internal/personas/personas.go new file mode 100644 index 0000000..c1fe7be --- /dev/null +++ b/internal/personas/personas.go @@ -0,0 +1,195 @@ +// Package personas serves the Coves user origin's ActivityPub identity +// surface (task 13): Person actors keyed by DID under AP_USER_ORIGIN +// (https://coves.social), WebFinger for their local parts, and the shared +// inbox. Key material stays behind internal/identity — the service holds a +// Custodian, never a plaintext PEM. +package personas + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "tidepool/internal/ap" + "tidepool/internal/errors" + "tidepool/internal/identity" + "tidepool/internal/store" +) + +// currentRSAKeyVersion stamps newly minted actor keys. Rotation would mint a +// version 2 alongside the published key it replaces; nothing does yet. +const currentRSAKeyVersion = 1 + +// maxLocalPartAttempts bounds the collision search: attempt 1 claims the bare +// local part and the rest append "-2" ... "-99", the suffix range +// MaxLocalPartLen reserves room for. A namespace that has genuinely exhausted +// 99 claimants on one name is a condition to report, not to keep grinding on. +const maxLocalPartAttempts = 99 + +// Options configures a Service. +type Options struct { + // DB is the bridge database (ap_actors lives here). + DB *sql.DB + // Custodian seals and opens per-actor AP RSA keys. + Custodian *identity.Custodian + // UserOrigin is AP_USER_ORIGIN: the scheme+host new actors are minted + // under, e.g. "https://coves.social". It seeds NEW rows only; serving + // derives every URL from the stored actor_id. + UserOrigin string +} + +// Service mints and serves Coves user actors. +type Service struct { + actors store.APActors + custodian *identity.Custodian + userOrigin string + // userHost is UserOrigin's scheme-less lowercase host. It is both the + // native handle suffix new local parts derive against and the + // normalized_origin they are stored under — the same string Host + // routing hands the webfinger endpoint, so a lookup needs no reshaping. + userHost string +} + +// New builds a Service. UserOrigin is parsed once here: the host it yields +// keys every actor this service mints, so an origin that cannot produce one +// is a startup error rather than a surprise at mint time. +func New(opts Options) (*Service, error) { + host, err := originHost(opts.UserOrigin) + if err != nil { + return nil, err + } + return &Service{ + actors: store.NewAPActors(opts.DB), + custodian: opts.Custodian, + userOrigin: opts.UserOrigin, + userHost: host, + }, nil +} + +// originHost reduces an origin URL to the scheme-less lowercase host. +func originHost(origin string) (string, error) { + parsed, err := url.Parse(origin) + if err != nil { + return "", errors.NewValidationError("user_origin", err.Error()) + } + if parsed.Host == "" { + return "", errors.NewValidationError("user_origin", + fmt.Sprintf("must be an absolute origin URL, got %q", origin)) + } + return strings.ToLower(parsed.Host), nil +} + +// CreateActorForDID get-or-creates the AP Person actor for a Coves DID: +// mints an RSA key, seals it via the custodian, derives and freezes the +// local part from handle, and writes the ap_actors row. +// +// It is called on every federating interaction, so the existing-row path +// returns FIRST and handle is then ignored entirely: the local part is frozen +// at creation, and re-deriving it after a rename would strand every federated +// mention of the old name. Re-minting the key would be worse — it would +// orphan every signature the published key has already made. +// +// The actor is Person, not Service: Lemmy classifies Service actors as bots +// and drops their votes. +func (s *Service) CreateActorForDID(ctx context.Context, did, handle string) (*store.APActor, error) { + existing, err := s.actors.GetByDID(ctx, did) + if err == nil { + return existing, nil + } + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("personas: look up actor for %s: %w", did, err) + } + + base, err := DeriveLocalPart(handle, s.userHost) + if err != nil { + return nil, err + } + + // Minting is entirely local — key generation plus one INSERT. Nothing + // here reads the appview or the PDS: the profile columns start empty + // and task 14's sync fills them, so a federating interaction never + // waits on a third party to get an identity. + key, err := ap.GenerateRSAKey() + if err != nil { + return nil, fmt.Errorf("personas: generate AP key for %s: %w", did, err) + } + sealed, err := s.custodian.EncryptActorRSAKey(did, key) + if err != nil { + return nil, fmt.Errorf("personas: seal AP key for %s: %w", did, err) + } + publicPEM, err := ap.EncodePublicKeyPEM(&key.PublicKey) + if err != nil { + return nil, fmt.Errorf("personas: encode public key for %s: %w", did, err) + } + + actor := store.APActor{ + DID: did, + Kind: store.ActorTypePerson, + ActorID: s.userOrigin + "/ap/actor/" + did, + NormalizedOrigin: s.userHost, + RSAKeySealed: sealed, + RSAKeyVersion: currentRSAKeyVersion, + PublicKeyPEM: string(publicPEM), + } + + // The collision search is driven by the INSERT's unique violation, not + // by a SELECT-then-INSERT pre-check: two simultaneous mints of the same + // derived name would both see a free namespace and one would fail. + // Letting the constraint arbitrate means the loser simply takes the + // next name. + for attempt := 1; attempt <= maxLocalPartAttempts; attempt++ { + actor.LocalPart = suffixedLocalPart(base, attempt) + created, createErr := s.actors.Create(ctx, actor) + if createErr == nil { + return created, nil + } + var conflict errors.ConflictError + if stderrors.As(createErr, &conflict) { + switch conflict.Field { + case "local_part": + continue + case "did", "actor_id": + // Lost a get-or-create race for this DID. The winner's row + // is the answer: returning it (rather than an error) is + // what makes concurrent callers converge on ONE key. + winner, getErr := s.actors.GetByDID(ctx, did) + if getErr != nil { + return nil, fmt.Errorf("personas: reload actor for %s after mint race: %w", did, getErr) + } + return winner, nil + } + } + return nil, fmt.Errorf("personas: create actor for %s: %w", did, createErr) + } + return nil, errors.NewConflictError("ap_actor", "local_part", base) +} + +// suffixedLocalPart names the attempt'th claimant of base: the first keeps +// the bare local part, later ones get "-2", "-3", ... There is no "-1" — +// the bare name IS the first claim. +func suffixedLocalPart(base string, attempt int) string { + if attempt <= 1 { + return base + } + return base + "-" + strconv.Itoa(attempt) +} + +// actorSigner unseals a minted actor's RSA key and returns a Signer whose +// keyID is "{actor_id}#main-key" — the same id the actor document publishes, +// so a verifier that fetches the document finds the key it needs there. +// A DID with no minted actor surfaces as errors.IsNotFound. +func (s *Service) actorSigner(ctx context.Context, did string) (*ap.Signer, error) { + actor, err := s.actors.GetByDID(ctx, did) + if err != nil { + return nil, err + } + key, err := s.custodian.DecryptActorRSAKey(did, actor.RSAKeySealed) + if err != nil { + return nil, fmt.Errorf("personas: unseal AP key for %s: %w", did, err) + } + return ap.NewSigner(actor.ActorID+"#main-key", key), nil +} diff --git a/internal/personas/serving.go b/internal/personas/serving.go new file mode 100644 index 0000000..607b84c --- /dev/null +++ b/internal/personas/serving.go @@ -0,0 +1,287 @@ +package personas + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "time" + + "tidepool/internal/ap" + "tidepool/internal/errors" + "tidepool/internal/store" +) + +const ( + webfingerPath = "/.well-known/webfinger" + // actorPathPrefix and inboxPath are the origin's URL shape. Serving + // derives every absolute URL from the stored actor_id rather than from + // these plus config (decision 10), so a vanity-origin actor advertises + // its OWN origin, not AP_USER_ORIGIN. + actorPathPrefix = "/ap/actor/" + outboxSuffix = "/outbox" + inboxPath = "/ap/inbox" + + // jrdContentType is WebFinger's media type (v1 precedent: + // ingest/inbox.go's service-actor webfinger). + jrdContentType = "application/jrd+json" + // asNamespace is the ActivityStreams context every served document + // names; securityNamespace is what makes publicKey meaningful to + // Mastodon's and Lemmy's parsers. + asNamespace = "https://www.w3.org/ns/activitystreams" + securityNamespace = "https://w3id.org/security/v1" +) + +// ServeHTTP serves the user-origin surface: /.well-known/webfinger, +// /ap/actor/{did}, and /ap/actor/{did}/outbox. +// +// Routing reads r.URL.Path, which net/http has already percent-decoded. A +// DID's colons are legal unescaped, so both "did:plc:x" and "did%3Aplc%3Ax" +// arrive from real implementations and must reach the same actor; matching on +// the raw path would answer one spelling and 404 the other. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == webfingerPath: + if !isGET(w, r) { + return + } + s.handleWebFinger(w, r) + case strings.HasPrefix(path, actorPathPrefix): + rest := strings.TrimPrefix(path, actorPathPrefix) + if !isGET(w, r) { + return + } + if did, isOutbox := strings.CutSuffix(rest, outboxSuffix); isOutbox { + s.handleOutbox(w, r, did) + return + } + if rest == "" || strings.Contains(rest, "/") { + http.NotFound(w, r) + return + } + s.handleActorDocument(w, r, rest) + default: + http.NotFound(w, r) + } +} + +func isGET(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + return true +} + +// handleActorDocument serves the Person document. A DISABLED actor still +// serves it: disabling removes an actor from discovery, not from the network, +// and already-federated references to it must not become dangling. Paused +// actors are invisible here too — delivery_paused is about outbound traffic +// and has no read-side meaning at all. +// +// Nothing on this path unseals a key: the published PEM is a stored column, +// so serving an actor document never touches the KEK. +func (s *Service) handleActorDocument(w http.ResponseWriter, r *http.Request, did string) { + actor, err := s.actors.GetByDID(r.Context(), did) + if err != nil { + writeStoreError(w, err) + return + } + + origin := actorOrigin(actor, s.userOrigin) + inbox := origin + inboxPath + // The display name falls back to the local part: Lemmy renders `name`, + // and an empty one shows as a blank user until task 14's profile sync + // fills the cache. + name := actor.DisplayName + if name == "" { + name = actor.LocalPart + } + + doc := map[string]any{ + "@context": []any{asNamespace, securityNamespace}, + "id": actor.ActorID, + "type": ap.TypePerson, + "preferredUsername": actor.LocalPart, + "name": name, + "inbox": inbox, + // One shared inbox serves every actor on the origin. + "endpoints": map[string]any{"sharedInbox": inbox}, + // outbox is REQUIRED for Lemmy's Person deserialization: omitting + // it rejects the whole actor, not just the collection. + "outbox": actor.ActorID + outboxSuffix, + "publicKey": map[string]any{ + "id": actor.ActorID + "#main-key", + "owner": actor.ActorID, + "publicKeyPem": actor.PublicKeyPEM, + }, + "published": actor.CreatedAt.UTC().Format(time.RFC3339), + } + // Empty profile fields are OMITTED rather than served blank: a present + // but empty summary or icon is a claim about the user that the cache + // cannot yet support. + if actor.Summary != "" { + doc["summary"] = actor.Summary + } + if actor.AvatarURL != "" { + // An Image object, not a bare URL string: Lemmy's parser rejects + // the string form. + doc["icon"] = map[string]any{"type": ap.TypeImage, "url": actor.AvatarURL} + } + + writeJSON(w, ap.ContentTypeActivityJSON, doc) +} + +// handleOutbox serves the (empty) collection Lemmy dereferences after parsing +// the actor. Task 13 mints identities only, so no content flows yet — but a +// MISSING outbox breaks the actor, so it is served empty rather than not at +// all. +func (s *Service) handleOutbox(w http.ResponseWriter, r *http.Request, did string) { + actor, err := s.actors.GetByDID(r.Context(), did) + if err != nil { + writeStoreError(w, err) + return + } + writeJSON(w, ap.ContentTypeActivityJSON, map[string]any{ + "@context": asNamespace, + "id": actor.ActorID + outboxSuffix, + "type": ap.TypeOrderedCollection, + "totalItems": 0, + "orderedItems": []any{}, + }) +} + +// handleWebFinger answers discovery for the local parts hosted on the ROUTED +// Host. The lookup is (host, local part), so this origin can never answer for +// an account it does not host, and two origins hosting the same local part +// stay two different people. +func (s *Service) handleWebFinger(w http.ResponseWriter, r *http.Request) { + resource := strings.TrimSpace(r.URL.Query().Get("resource")) + if resource == "" { + // Malformed, not a miss: remote resolvers cache 404s as "no such + // account" but read 400s as our bug, so the distinction has to be + // right at the protocol layer. + http.Error(w, "missing resource parameter", http.StatusBadRequest) + return + } + + host := requestHost(r) + actor, err := s.lookupResource(r.Context(), resource, host) + if err != nil { + writeStoreError(w, err) + return + } + if !actor.Enabled { + // Discovery is exactly what disabling removes. + http.Error(w, "resource not found", http.StatusNotFound) + return + } + + // The subject echoes the canonical acct form whichever spelling was + // asked for, so both resolutions answer identically. + writeJSON(w, jrdContentType, ap.WebFingerResponse{ + Subject: "acct:" + actor.LocalPart + "@" + actor.NormalizedOrigin, + Aliases: []string{actor.ActorID}, + // Two rel=self links, activity+json FIRST: resolvers that take the + // first match must land on the AP document, and Lemmy accepts + // either type. + Links: []ap.WebFingerLink{ + {Rel: "self", Type: ap.ContentTypeActivityJSON, Href: actor.ActorID}, + {Rel: "self", Type: ap.ContentTypeLDJSON, Href: actor.ActorID}, + }, + }) +} + +// lookupResource resolves a WebFinger resource to an actor on host. Both +// spellings are accepted — acct:local@host and the actor URL itself (v1 +// precedent: ingest/inbox.go's service-actor webfinger accepts both) — and +// each is bound to the routed Host, which is what stops this origin from +// answering for accounts hosted elsewhere. +func (s *Service) lookupResource(ctx context.Context, resource, host string) (*store.APActor, error) { + if !strings.Contains(resource, "://") { + local, acctHost, err := ap.ParseHandle(resource) + if err != nil { + return nil, err + } + if canonicalHost(acctHost) != host { + return nil, errors.NewNotFoundError("ap_actor", resource) + } + return s.actors.GetByOriginLocalPart(ctx, host, strings.ToLower(local)) + } + + parsed, err := url.Parse(resource) + if err != nil { + return nil, errors.NewValidationError("resource", err.Error()) + } + if canonicalHost(parsed.Host) != host { + return nil, errors.NewNotFoundError("ap_actor", resource) + } + did, ok := strings.CutPrefix(parsed.Path, actorPathPrefix) + if !ok || did == "" { + return nil, errors.NewNotFoundError("ap_actor", resource) + } + actor, err := s.actors.GetByDID(ctx, did) + if err != nil { + return nil, err + } + // The DID is global but this answer must not be: an actor minted on + // another origin does not resolve here. + if actor.NormalizedOrigin != host { + return nil, errors.NewNotFoundError("ap_actor", resource) + } + return actor, nil +} + +// requestHost is the routed authority in the form ap_actors.normalized_origin +// stores it. The scheme's DEFAULT port is noise and is stripped; any other +// port is part of the authority and stays — dev runs the origin on +// localhost:8091, and coves.social:8443 is a different origin from +// coves.social, not a sloppy spelling of it. +func requestHost(r *http.Request) string { + host := strings.ToLower(strings.TrimSpace(r.Host)) + defaultPort := ":80" + if r.TLS != nil { + defaultPort = ":443" + } + return canonicalHost(strings.TrimSuffix(host, defaultPort)) +} + +// canonicalHost lowercases a host and drops the trailing dot of a +// fully-qualified name, which names the same host. +func canonicalHost(host string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") +} + +// actorOrigin recovers the scheme+host an actor was minted under from its +// stored actor_id, so a vanity-origin actor advertises its own inbox rather +// than the configured one. The configured origin is only the fallback for an +// actor_id that cannot be parsed. +func actorOrigin(actor *store.APActor, fallback string) string { + parsed, err := url.Parse(actor.ActorID) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fallback + } + return parsed.Scheme + "://" + parsed.Host +} + +func writeJSON(w http.ResponseWriter, contentType string, doc any) { + w.Header().Set("Content-Type", contentType) + _ = json.NewEncoder(w).Encode(doc) +} + +// writeStoreError maps a store/validation error onto the status a remote +// resolver will read correctly: a miss is cacheable as "no such account", a +// malformed request is the caller's fault, and anything else is ours. +func writeStoreError(w http.ResponseWriter, err error) { + switch { + case errors.IsNotFound(err): + http.Error(w, "resource not found", http.StatusNotFound) + case errors.IsValidation(err): + http.Error(w, "malformed request", http.StatusBadRequest) + default: + http.Error(w, "internal error", http.StatusInternalServerError) + } +} diff --git a/internal/personas/serving_test.go b/internal/personas/serving_test.go new file mode 100644 index 0000000..c9a74cd --- /dev/null +++ b/internal/personas/serving_test.go @@ -0,0 +1,382 @@ +package personas + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/ap" + "tidepool/internal/store" +) + +const ( + vanityHost = "vanity.example" + vanityOrigin = "https://" + vanityHost + // contentTypeJRD is what WebFinger answers with (v1 precedent: + // ingest/inbox.go's service-actor webfinger). + contentTypeJRD = "application/jrd+json" + // contentTypeLDJSON is the second rel=self type Lemmy and Mastodon both + // accept, offered after activity+json. + contentTypeLDJSON = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` +) + +// serveOnHost drives the handler with an explicit Host header — the routing +// input the whole user-origin surface keys on. +func serveOnHost(h http.Handler, host, method, target string, header http.Header) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "https://"+host+target, nil) + req.Host = host + for k, values := range header { + for _, v := range values { + req.Header.Add(k, v) + } + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func actorPath(did string) string { return "/ap/actor/" + did } + +func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var doc map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc), + "response must be a JSON object, got %s", rec.Body.String()) + return doc +} + +// contextIncludes reports whether the AS2 @context (a string or an array) +// names the ActivityStreams namespace. +func contextIncludes(raw any, want string) bool { + switch v := raw.(type) { + case string: + return v == want + case []any: + for _, entry := range v { + if s, ok := entry.(string); ok && s == want { + return true + } + } + } + return false +} + +func webfingerTarget(resource string) string { + return "/.well-known/webfinger?resource=" + url.QueryEscape(resource) +} + +// mintTestActor mints one actor and returns it. +func mintTestActor(t *testing.T, svc *Service, handle string) *store.APActor { + t.Helper() + actor, err := svc.CreateActorForDID(t.Context(), testDID(t), handle) + require.NoError(t, err) + require.NotNil(t, actor) + return actor +} + +// TestServeActorDocument_Shape covers the document a bare actor (no profile +// cached yet) serves. +func TestServeActorDocument_Shape(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + + rec := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID), nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, ap.ContentTypeActivityJSON, rec.Header().Get("Content-Type")) + + doc := decodeJSON(t, rec) + assert.True(t, contextIncludes(doc["@context"], "https://www.w3.org/ns/activitystreams"), + "@context must name the ActivityStreams namespace, got %v", doc["@context"]) + assert.Equal(t, actor.ActorID, doc["id"]) + assert.Equal(t, "Person", doc["type"]) + assert.Equal(t, actor.LocalPart, doc["preferredUsername"]) + + // With an empty profile cache the display name falls back to the local + // part: Lemmy renders `name`, and an empty one shows as a blank user. + assert.Equal(t, actor.LocalPart, doc["name"], + "name falls back to the local part while the profile cache is empty") + assert.NotContains(t, doc, "summary", "an empty summary is omitted, not served blank") + assert.NotContains(t, doc, "icon", "an actor without an avatar publishes no icon") + + publicKey, ok := doc["publicKey"].(map[string]any) + require.True(t, ok, "publicKey must be an object, got %v", doc["publicKey"]) + assert.Equal(t, actor.ActorID+"#main-key", publicKey["id"]) + assert.Equal(t, actor.ActorID, publicKey["owner"], + "owner must equal the document id, which is the URL this was fetched from") + pem, ok := publicKey["publicKeyPem"].(string) + require.True(t, ok) + assert.Equal(t, actor.PublicKeyPEM, pem) + + assert.Equal(t, userOrigin+"/ap/inbox", doc["inbox"]) + endpoints, ok := doc["endpoints"].(map[string]any) + require.True(t, ok, "endpoints must be an object, got %v", doc["endpoints"]) + assert.Equal(t, userOrigin+"/ap/inbox", endpoints["sharedInbox"], + "one shared inbox serves every actor on the origin") + assert.Equal(t, actor.ActorID+"/outbox", doc["outbox"], + "outbox is REQUIRED for Lemmy's Person deserialization") + + published, ok := doc["published"].(string) + require.True(t, ok, "published must be a string, got %v", doc["published"]) + publishedAt, err := time.Parse(time.RFC3339, published) + require.NoError(t, err, "published must be RFC3339, got %q", published) + assert.WithinDuration(t, actor.CreatedAt, publishedAt, time.Second, + "published is the row's created_at") +} + +// TestServeActorDocument_ProfileCache covers the fields that appear once +// task 14's profile sync has filled the cache. icon is an Image OBJECT: +// Lemmy's parser rejects a bare URL string there. +func TestServeActorDocument_ProfileCache(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + + const avatar = "https://cdn.example/alice.png" + require.NoError(t, store.NewAPActors(database).UpdateProfile(t.Context(), actor.DID, + store.APActorProfile{ + DisplayName: "Alice Liddell", + Summary: "posts about tide pools", + AvatarURL: avatar, + })) + + rec := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID), nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + doc := decodeJSON(t, rec) + + assert.Equal(t, "Alice Liddell", doc["name"], "the cached display name wins over the local part") + assert.Equal(t, "posts about tide pools", doc["summary"]) + assert.Equal(t, actor.LocalPart, doc["preferredUsername"], + "the local part is frozen: a profile refresh never moves it") + + icon, ok := doc["icon"].(map[string]any) + require.True(t, ok, "icon must be an Image object, not a bare string; got %v", doc["icon"]) + assert.Equal(t, "Image", icon["type"]) + assert.Equal(t, avatar, icon["url"]) +} + +// TestServeActorDocument_Lookup covers path handling: unknown DIDs 404, and +// a percent-escaped DID resolves the same actor (a DID's colons are legal +// unescaped, so both spellings arrive in the wild). +func TestServeActorDocument_Lookup(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + + unknown := serveOnUserOrigin(svc, http.MethodGet, actorPath(testDID(t)), nil) + assert.Equal(t, http.StatusNotFound, unknown.Code, "an unminted DID has no actor document") + + escaped := serveOnUserOrigin(svc, http.MethodGet, + actorPath(strings.ReplaceAll(actor.DID, ":", "%3A")), nil) + require.Equal(t, http.StatusOK, escaped.Code, + "a percent-escaped DID must resolve the same actor; body=%s", escaped.Body.String()) + assert.Equal(t, actor.ActorID, decodeJSON(t, escaped)["id"], + "the served id is the canonical stored actor_id, never the escaped request spelling") +} + +// TestServeOutbox: the collection Lemmy dereferences after parsing the +// actor. Empty is fine; missing is not. +func TestServeOutbox(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + + rec := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID)+"/outbox", nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, ap.ContentTypeActivityJSON, rec.Header().Get("Content-Type")) + + doc := decodeJSON(t, rec) + assert.True(t, contextIncludes(doc["@context"], "https://www.w3.org/ns/activitystreams"), + "@context must name the ActivityStreams namespace, got %v", doc["@context"]) + assert.Equal(t, actor.ActorID+"/outbox", doc["id"]) + assert.Equal(t, "OrderedCollection", doc["type"]) + assert.EqualValues(t, 0, doc["totalItems"], "no content flows yet (task 13 mints identities only)") +} + +// TestServeDisabledActor pins the split task 17 depends on: disabling +// removes an actor from DISCOVERY, not from the network. Its document must +// keep resolving so already-federated references do not become dangling. +func TestServeDisabledActor(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actors := store.NewAPActors(database) + actor := mintTestActor(t, svc, testHandle) + + require.NoError(t, actors.SetEnabled(t.Context(), actor.DID, false)) + + doc := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID), nil) + assert.Equal(t, http.StatusOK, doc.Code, + "a disabled actor's document stays fetchable (task 17 owns scrub semantics)") + + finger := serveOnUserOrigin(svc, http.MethodGet, + webfingerTarget("acct:"+actor.LocalPart+"@"+userHost), nil) + assert.Equal(t, http.StatusNotFound, finger.Code, + "a disabled actor's local part must not resolve via webfinger") +} + +// TestServePausedActor: delivery_paused is about OUTBOUND delivery. It must +// be invisible to every read surface. +func TestServePausedActor(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actors := store.NewAPActors(database) + actor := mintTestActor(t, svc, testHandle) + + before := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID), nil) + require.Equal(t, http.StatusOK, before.Code, "body=%s", before.Body.String()) + beforeDoc := decodeJSON(t, before) + beforeFinger := serveOnUserOrigin(svc, http.MethodGet, + webfingerTarget("acct:"+actor.LocalPart+"@"+userHost), nil) + require.Equal(t, http.StatusOK, beforeFinger.Code, "body=%s", beforeFinger.Body.String()) + + require.NoError(t, actors.SetPaused(t.Context(), actor.DID, true)) + + after := serveOnUserOrigin(svc, http.MethodGet, actorPath(actor.DID), nil) + require.Equal(t, http.StatusOK, after.Code) + assert.Equal(t, beforeDoc, decodeJSON(t, after), + "pausing delivery must not change the actor document") + + afterFinger := serveOnUserOrigin(svc, http.MethodGet, + webfingerTarget("acct:"+actor.LocalPart+"@"+userHost), nil) + assert.Equal(t, http.StatusOK, afterFinger.Code, "a paused actor still resolves") + assert.JSONEq(t, beforeFinger.Body.String(), afterFinger.Body.String()) +} + +// TestWebFinger is the discovery document Lemmy resolves @alice@coves.social +// through. +func TestWebFinger(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + acct := "acct:" + actor.LocalPart + "@" + userHost + + rec := serveOnUserOrigin(svc, http.MethodGet, webfingerTarget(acct), nil) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + assert.Equal(t, contentTypeJRD, rec.Header().Get("Content-Type")) + + var jrd ap.WebFingerResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &jrd), "body=%s", rec.Body.String()) + assert.Equal(t, acct, jrd.Subject, "the subject echoes the acct form") + assert.Contains(t, jrd.Aliases, actor.ActorID) + + // Two self links, activity+json FIRST: implementations that take the + // first match must land on the AP document, and Lemmy's own resolver + // looks for either type. + var selfLinks []ap.WebFingerLink + for _, link := range jrd.Links { + if link.Rel == "self" { + selfLinks = append(selfLinks, link) + } + } + require.Len(t, selfLinks, 2, "expected two rel=self links, got %+v", jrd.Links) + assert.Equal(t, ap.ContentTypeActivityJSON, selfLinks[0].Type) + assert.Equal(t, contentTypeLDJSON, selfLinks[1].Type) + for i, link := range selfLinks { + assert.Equal(t, actor.ActorID, link.Href, "self link %d must href the actor URL", i) + } + + // The actor URL itself is an accepted resource (v1 precedent: + // ingest/inbox.go's service-actor webfinger accepts both spellings) and + // answers identically. + byURL := serveOnUserOrigin(svc, http.MethodGet, webfingerTarget(actor.ActorID), nil) + require.Equal(t, http.StatusOK, byURL.Code, "body=%s", byURL.Body.String()) + assert.JSONEq(t, rec.Body.String(), byURL.Body.String(), + "resolving by actor URL must answer exactly as resolving by acct") +} + +// TestWebFinger_HostBinding: the resource's authority must be the routed +// Host. Otherwise this origin would answer for accounts it does not host. +func TestWebFinger_HostBinding(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actor := mintTestActor(t, svc, testHandle) + acct := "acct:" + actor.LocalPart + "@" + userHost + + foreign := serveOnUserOrigin(svc, http.MethodGet, + webfingerTarget("acct:"+actor.LocalPart+"@other.example"), nil) + assert.Equal(t, http.StatusNotFound, foreign.Code, + "a resource authority that is not the routed Host must not resolve") + + // Host normalization: the default https port and letter case carry no + // meaning, and a fully-qualified trailing dot is the same name. + for _, host := range []string{userHost + ":443", strings.ToUpper(userHost), userHost + "."} { + rec := serveOnHost(svc, host, http.MethodGet, webfingerTarget(acct), nil) + assert.Equal(t, http.StatusOK, rec.Code, + "Host %q must normalize to %q; body=%s", host, userHost, rec.Body.String()) + } + + // A NON-default port is a different authority, not noise to strip. + odd := serveOnHost(svc, userHost+":8443", http.MethodGet, webfingerTarget(acct), nil) + assert.Equal(t, http.StatusNotFound, odd.Code, + "only the scheme's default port is stripped; %s:8443 is another origin", userHost) +} + +// TestWebFinger_VanityOrigins is the vanity-origin proof: the same local +// part under two origins are two people, and each resolves only under its +// own Host. This is what forces ServeHTTP to consult r.Host rather than the +// configured user origin. +func TestWebFinger_VanityOrigins(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + actors := store.NewAPActors(database) + + native := mintTestActor(t, svc, testHandle) + vanityDID := testDID(t) + vanity, err := actors.Create(t.Context(), store.APActor{ + DID: vanityDID, + Kind: store.ActorTypePerson, + ActorID: vanityOrigin + "/ap/actor/" + vanityDID, + NormalizedOrigin: vanityHost, + LocalPart: native.LocalPart, + RSAKeySealed: []byte{0x01, 0x02, 0x03}, + RSAKeyVersion: 1, + PublicKeyPEM: native.PublicKeyPEM, + }) + require.NoError(t, err, "the same local part under another origin must coexist") + require.NotNil(t, vanity) + + acctNative := "acct:" + native.LocalPart + "@" + userHost + acctVanity := "acct:" + native.LocalPart + "@" + vanityHost + + onNative := serveOnHost(svc, userHost, http.MethodGet, webfingerTarget(acctNative), nil) + require.Equal(t, http.StatusOK, onNative.Code, "body=%s", onNative.Body.String()) + assert.Contains(t, onNative.Body.String(), native.ActorID) + assert.NotContains(t, onNative.Body.String(), vanity.ActorID) + + onVanity := serveOnHost(svc, vanityHost, http.MethodGet, webfingerTarget(acctVanity), nil) + require.Equal(t, http.StatusOK, onVanity.Code, + "a registered actor origin must route; body=%s", onVanity.Body.String()) + assert.Contains(t, onVanity.Body.String(), vanity.ActorID) + assert.NotContains(t, onVanity.Body.String(), native.ActorID) + + // A local part that exists only on the other origin does not leak across. + bob := mintTestActor(t, svc, "bob."+userHost) + crossed := serveOnHost(svc, vanityHost, http.MethodGet, + webfingerTarget("acct:"+bob.LocalPart+"@"+vanityHost), nil) + assert.Equal(t, http.StatusNotFound, crossed.Code, + "bob exists on %s only: %s must not answer for him", userHost, vanityHost) +} + +// TestWebFinger_Errors: a miss is 404, a malformed request is 400 — the +// distinction matters because remote resolvers cache 404s as "no such +// account" but treat 400s as our bug. +func TestWebFinger_Errors(t *testing.T) { + database := personasTestDB(t) + svc, _ := newTestService(t, database) + mintTestActor(t, svc, testHandle) + + unknown := serveOnUserOrigin(svc, http.MethodGet, + webfingerTarget("acct:nobody@"+userHost), nil) + assert.Equal(t, http.StatusNotFound, unknown.Code, "unknown local part") + + missing := serveOnUserOrigin(svc, http.MethodGet, "/.well-known/webfinger", nil) + assert.Equal(t, http.StatusBadRequest, missing.Code, + "a webfinger request without a resource parameter is malformed, not a miss") +} diff --git a/internal/store/ap_actors.go b/internal/store/ap_actors.go new file mode 100644 index 0000000..9e73246 --- /dev/null +++ b/internal/store/ap_actors.go @@ -0,0 +1,220 @@ +package store + +import ( + "context" + "database/sql" + stderrors "errors" + "fmt" + "time" + + "tidepool/internal/errors" +) + +// APActor is a Coves user's ActivityPub identity on a user origin: a Person +// actor keyed by the user's DID, whose RSA signing key is sealed under +// BRIDGE_KEK (task 13). +type APActor struct { + // DID is the Coves user's atproto DID; it is the row's primary key. + DID string + // Kind is person or group. Group is RESERVED for Scope B: no code path + // mints one yet, but the CHECK constraint admits it. + Kind ActorType + // ActorID is the FULL actor URL, origin included (decision 10: every + // derived URL — webfinger href, keyId, signing identity — comes from + // this stored value, never from AP_USER_ORIGIN at read time). + ActorID string + // NormalizedOrigin and LocalPart are the webfinger lookup key + // (lowercased). They are UNIQUE TOGETHER, not globally: vanity origins + // must be able to host the same local part. + NormalizedOrigin string + LocalPart string + // RSAKeySealed is the AP signing key sealed by identity.Custodian's RSA + // surface, AAD-bound to DID. RSAKeyVersion makes rotation definable. + RSAKeySealed []byte + RSAKeyVersion int + // PublicKeyPEM is the actor's published RSA public key (SPKI PEM). The + // PRIVATE half is never stored in the clear. + PublicKeyPEM string + // Enabled gates webfinger resolution (disabled actors' local parts do + // not resolve; their actor documents stay fetchable — task 17 owns + // scrub semantics). EnabledAt/DisabledAt record the last transition. + Enabled bool + EnabledAt *time.Time + DisabledAt *time.Time + // DeliveryPaused is the transient #account state (decision 19): + // delivery stops, identity stays. + DeliveryPaused bool + // Profile cache, refreshed from the appview/PDS (task 14 owns sync). + DisplayName string + Summary string + AvatarURL string + + CreatedAt time.Time + UpdatedAt time.Time +} + +// APActorProfile is the mutable profile cache of an APActor. +type APActorProfile struct { + DisplayName string + Summary string + AvatarURL string +} + +type postgresAPActors struct { + db *sql.DB +} + +// NewAPActors creates the postgres-backed ap_actors repository. +func NewAPActors(db *sql.DB) APActors { + return &postgresAPActors{db: db} +} + +const apActorColumns = ` + did, kind, actor_id, normalized_origin, local_part, + rsa_key_sealed, rsa_key_version, public_key_pem, + enabled, enabled_at, disabled_at, delivery_paused, + display_name, summary, avatar_url, created_at, updated_at` + +func (r *postgresAPActors) Create(ctx context.Context, actor APActor) (*APActor, error) { + // The lifecycle and profile columns are deliberately absent from the + // insert list: federation is default-on (decision 11), so a created + // actor is always enabled and unpaused and takes those values from the + // schema defaults. Reading them off the argument would let a caller + // that merely forgot to set Enabled mint a silently dead actor — the + // zero value of a bool is exactly the dangerous direction here. + // Disabling is an explicit SetEnabled call, never a side effect of + // creation. + query := ` + INSERT INTO ap_actors ( + did, kind, actor_id, normalized_origin, local_part, + rsa_key_sealed, rsa_key_version, public_key_pem + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING` + apActorColumns + + row := r.db.QueryRowContext(ctx, query, + actor.DID, string(actor.Kind), actor.ActorID, + actor.NormalizedOrigin, actor.LocalPart, + actor.RSAKeySealed, actor.RSAKeyVersion, actor.PublicKeyPEM, + ) + stored, err := scanAPActor(row) + if err != nil { + // Mapped from the constraint name rather than pre-checked: a + // SELECT-then-INSERT pre-check races with a concurrent mint. + if constraint, ok := uniqueViolation(err); ok { + switch constraint { + case "ap_actors_pkey": + return nil, errors.NewConflictError("ap_actor", "did", actor.DID) + case "ap_actors_actor_id_key": + return nil, errors.NewConflictError("ap_actor", "actor_id", actor.ActorID) + case "ap_actors_origin_local_part_key": + return nil, errors.NewConflictError("ap_actor", "local_part", actor.LocalPart) + } + } + return nil, fmt.Errorf("create ap_actor %q: %w", actor.DID, err) + } + return stored, nil +} + +func (r *postgresAPActors) GetByDID(ctx context.Context, did string) (*APActor, error) { + query := `SELECT` + apActorColumns + ` FROM ap_actors WHERE did = $1` + actor, err := scanAPActor(r.db.QueryRowContext(ctx, query, did)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("ap_actor", did) + } + return nil, fmt.Errorf("get ap_actor by did %q: %w", did, err) + } + return actor, nil +} + +func (r *postgresAPActors) GetByOriginLocalPart(ctx context.Context, normalizedOrigin, localPart string) (*APActor, error) { + // Both halves of the key are in the WHERE: the lookup is scoped to the + // routed Host, so alice@vanity.example never answers for + // alice@coves.social. + query := `SELECT` + apActorColumns + ` + FROM ap_actors WHERE normalized_origin = $1 AND local_part = $2` + actor, err := scanAPActor(r.db.QueryRowContext(ctx, query, normalizedOrigin, localPart)) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, errors.NewNotFoundError("ap_actor", localPart+"@"+normalizedOrigin) + } + return nil, fmt.Errorf("get ap_actor by origin/local_part %q@%q: %w", localPart, normalizedOrigin, err) + } + return actor, nil +} + +func (r *postgresAPActors) SetEnabled(ctx context.Context, did string, enabled bool) error { + // Both transitions are stamped, and disabled_at is CLEARED on re-enable + // so the column answers "is this actor currently disabled, and since + // when" rather than "was it ever disabled". enabled_at is not cleared on + // disable: the last enable is history worth keeping. + query := ` + UPDATE ap_actors SET + enabled = $2, + enabled_at = CASE WHEN $2 THEN now() ELSE enabled_at END, + disabled_at = CASE WHEN $2 THEN NULL ELSE now() END, + updated_at = now() + WHERE did = $1` + return r.execOne(ctx, "set enabled", did, query, did, enabled) +} + +func (r *postgresAPActors) SetPaused(ctx context.Context, did string, paused bool) error { + // delivery_paused alone: pausing delivery must not disable the actor, + // so the enabled columns are untouched. + query := `UPDATE ap_actors SET delivery_paused = $2, updated_at = now() WHERE did = $1` + return r.execOne(ctx, "set paused", did, query, did, paused) +} + +func (r *postgresAPActors) UpdateProfile(ctx context.Context, did string, profile APActorProfile) error { + // The SET list is the profile cache and nothing else. local_part and + // actor_id are absent on purpose: task 14 reaches this method on handle + // changes, and re-deriving the local part there would break every + // federated mention of the old handle. + query := ` + UPDATE ap_actors SET + display_name = $2, summary = $3, avatar_url = $4, updated_at = now() + WHERE did = $1` + return r.execOne(ctx, "update profile", did, query, + did, profile.DisplayName, profile.Summary, profile.AvatarURL) +} + +// execOne runs a single-row mutator and reports a missed DID as NotFound. +func (r *postgresAPActors) execOne(ctx context.Context, what, did, query string, args ...any) error { + result, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("%s for ap_actor %q: %w", what, did, err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("%s for ap_actor %q: rows affected: %w", what, did, err) + } + if affected == 0 { + return errors.NewNotFoundError("ap_actor", did) + } + return nil +} + +func scanAPActor(row rowScanner) (*APActor, error) { + var actor APActor + var kind string + var enabledAt, disabledAt sql.NullTime + err := row.Scan( + &actor.DID, &kind, &actor.ActorID, + &actor.NormalizedOrigin, &actor.LocalPart, + &actor.RSAKeySealed, &actor.RSAKeyVersion, &actor.PublicKeyPEM, + &actor.Enabled, &enabledAt, &disabledAt, &actor.DeliveryPaused, + &actor.DisplayName, &actor.Summary, &actor.AvatarURL, + &actor.CreatedAt, &actor.UpdatedAt, + ) + if err != nil { + return nil, err + } + actor.Kind = ActorType(kind) + if enabledAt.Valid { + actor.EnabledAt = &enabledAt.Time + } + if disabledAt.Valid { + actor.DisabledAt = &disabledAt.Time + } + return &actor, nil +} diff --git a/internal/store/ap_actors_test.go b/internal/store/ap_actors_test.go new file mode 100644 index 0000000..5c1302a --- /dev/null +++ b/internal/store/ap_actors_test.go @@ -0,0 +1,287 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + stderrors "errors" + "testing" + + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "tidepool/internal/errors" + "tidepool/internal/testutil" +) + +// Fixtures for the coves.social user-origin actors of task 13. +// normalized_origin is the scheme-less lowercase host — exactly what Host +// routing yields at webfinger time — while actor_id carries the full URL. +const ( + apActorOrigin = "coves.social" + apActorVanityOrigin = "vanity.example" + apActorLocalPart = "alice" + apActorThirdDID = "did:plc:z72i7hdynmk6r22z27h6tvur" + apActorPublicKeyPEM = "-----BEGIN PUBLIC KEY-----\nTEST\n-----END PUBLIC KEY-----\n" +) + +func apActorURL(origin, did string) string { return "https://" + origin + "/ap/actor/" + did } + +// apActorsTestDB is testDB for a table that does not exist yet: the truncate +// is best-effort so the harness never fails on the missing table (that is +// what the tests themselves must report). +func apActorsTestDB(t *testing.T) *sql.DB { + t.Helper() + database := testutil.DB(t) + _, _ = database.ExecContext(context.Background(), `TRUNCATE ap_actors RESTART IDENTITY`) + return database +} + +// requireAPActorsTable fails with an actionable message when migration 017 +// has not created the table, so schema-level tests never pass vacuously (a +// missing table errors every statement, including the ones asserting an +// error). +func requireAPActorsTable(t *testing.T, database *sql.DB) { + t.Helper() + var name sql.NullString + err := database.QueryRowContext(context.Background(), + `SELECT to_regclass('public.ap_actors')::text`).Scan(&name) + require.NoError(t, err) + require.True(t, name.Valid, + "migration 017 must create table ap_actors (did PK, kind, actor_id, "+ + "normalized_origin, local_part, rsa_key_sealed, rsa_key_version, "+ + "public_key_pem, enabled, enabled_at, disabled_at, delivery_paused, "+ + "display_name, summary, avatar_url, created_at, updated_at)") +} + +func testAPActor() APActor { + return APActor{ + DID: testDID, + Kind: ActorTypePerson, + ActorID: apActorURL(apActorOrigin, testDID), + NormalizedOrigin: apActorOrigin, + LocalPart: apActorLocalPart, + RSAKeySealed: []byte{0x01, 0xde, 0xad, 0xbe, 0xef}, + RSAKeyVersion: 1, + PublicKeyPEM: apActorPublicKeyPEM, + } +} + +func TestAPActors_CreateGetRoundTrip(t *testing.T) { + database := apActorsTestDB(t) + repo := NewAPActors(database) + ctx := t.Context() + + want := testAPActor() + created, err := repo.Create(ctx, want) + require.NoError(t, err) + require.NotNil(t, created, "Create must return the stored row") + + for _, actor := range []*APActor{created, mustGetByDID(t, repo, testDID)} { + assert.Equal(t, want.DID, actor.DID) + assert.Equal(t, ActorTypePerson, actor.Kind) + assert.Equal(t, want.ActorID, actor.ActorID) + assert.Equal(t, want.NormalizedOrigin, actor.NormalizedOrigin) + assert.Equal(t, want.LocalPart, actor.LocalPart) + assert.True(t, bytes.Equal(want.RSAKeySealed, actor.RSAKeySealed), + "sealed key must round-trip byte for byte") + assert.Equal(t, want.RSAKeyVersion, actor.RSAKeyVersion) + assert.Equal(t, want.PublicKeyPEM, actor.PublicKeyPEM) + + // Creation is always enabled and unpaused: federation is default-on + // (decision 11), and the input's zero-value lifecycle fields must + // not be able to mint a silently-disabled actor. + assert.True(t, actor.Enabled, "a freshly created actor federates") + require.NotNil(t, actor.EnabledAt, "enabled_at is stamped at creation") + assert.Nil(t, actor.DisabledAt) + assert.False(t, actor.DeliveryPaused) + + // Profile cache starts empty; task 14 fills it. + assert.Empty(t, actor.DisplayName) + assert.Empty(t, actor.Summary) + assert.Empty(t, actor.AvatarURL) + + assert.False(t, actor.CreatedAt.IsZero()) + assert.False(t, actor.UpdatedAt.IsZero()) + } + + // The webfinger lookup key. + byLocal, err := repo.GetByOriginLocalPart(ctx, apActorOrigin, apActorLocalPart) + require.NoError(t, err) + require.NotNil(t, byLocal, "GetByOriginLocalPart must find the actor") + assert.Equal(t, testDID, byLocal.DID) + + // Misses. + _, err = repo.GetByDID(ctx, testSecondDID) + assert.True(t, errors.IsNotFound(err), "unknown DID must be IsNotFound, got %v", err) + _, err = repo.GetByOriginLocalPart(ctx, apActorOrigin, "nobody") + assert.True(t, errors.IsNotFound(err), "unknown local part must be IsNotFound, got %v", err) + _, err = repo.GetByOriginLocalPart(ctx, apActorVanityOrigin, apActorLocalPart) + assert.True(t, errors.IsNotFound(err), + "the lookup is scoped to the origin: alice@coves.social must not answer for vanity.example, got %v", err) +} + +func TestAPActors_UniquenessAndVanityOrigins(t *testing.T) { + database := apActorsTestDB(t) + repo := NewAPActors(database) + ctx := t.Context() + + _, err := repo.Create(ctx, testAPActor()) + require.NoError(t, err) + + // (a) The same local part under the SAME origin collides. + sameLocal := testAPActor() + sameLocal.DID = testSecondDID + sameLocal.ActorID = apActorURL(apActorOrigin, testSecondDID) + _, err = repo.Create(ctx, sameLocal) + requireConflict(t, err, "a second alice@coves.social must conflict") + + // (b) ... but the same local part under a DIFFERENT origin coexists + // (decision 10: vanity origins are not a global namespace). + vanity := testAPActor() + vanity.DID = testSecondDID + vanity.ActorID = apActorURL(apActorVanityOrigin, testSecondDID) + vanity.NormalizedOrigin = apActorVanityOrigin + vanityActor, err := repo.Create(ctx, vanity) + require.NoError(t, err, "alice@vanity.example must coexist with alice@coves.social") + require.NotNil(t, vanityActor) + + // Each resolves only under its own origin. + first, err := repo.GetByOriginLocalPart(ctx, apActorOrigin, apActorLocalPart) + require.NoError(t, err) + require.NotNil(t, first) + assert.Equal(t, testDID, first.DID) + second, err := repo.GetByOriginLocalPart(ctx, apActorVanityOrigin, apActorLocalPart) + require.NoError(t, err) + require.NotNil(t, second) + assert.Equal(t, testSecondDID, second.DID) + + // (c) A duplicate actor_id conflicts even with a fresh DID and local part. + dupActorID := testAPActor() + dupActorID.DID = apActorThirdDID + dupActorID.LocalPart = "bob" + _, err = repo.Create(ctx, dupActorID) + requireConflict(t, err, "actor_id is globally unique") + + // (d) A duplicate DID conflicts (the primary key). + dupDID := testAPActor() + dupDID.ActorID = apActorURL(apActorOrigin, apActorThirdDID) + dupDID.LocalPart = "carol" + _, err = repo.Create(ctx, dupDID) + requireConflict(t, err, "one actor per DID") +} + +func TestAPActors_LifecycleAndProfileUpdates(t *testing.T) { + database := apActorsTestDB(t) + repo := NewAPActors(database) + ctx := t.Context() + + created, err := repo.Create(ctx, testAPActor()) + require.NoError(t, err) + require.NotNil(t, created) + require.NotNil(t, created.EnabledAt) + firstEnabledAt := *created.EnabledAt + + // Disable: stamped, not deleted. + require.NoError(t, repo.SetEnabled(ctx, testDID, false)) + disabled := mustGetByDID(t, repo, testDID) + assert.False(t, disabled.Enabled) + require.NotNil(t, disabled.DisabledAt, "disabling stamps disabled_at") + assert.NotNil(t, disabled.EnabledAt, "the previous enable is not erased") + + // Re-enable: clears disabled_at, re-stamps enabled_at. + require.NoError(t, repo.SetEnabled(ctx, testDID, true)) + reEnabled := mustGetByDID(t, repo, testDID) + assert.True(t, reEnabled.Enabled) + assert.Nil(t, reEnabled.DisabledAt, "re-enabling clears disabled_at") + require.NotNil(t, reEnabled.EnabledAt) + assert.False(t, reEnabled.EnabledAt.Before(firstEnabledAt), + "re-enabling re-stamps enabled_at") + + // Pause / unpause (the transient #account state — identity survives). + require.NoError(t, repo.SetPaused(ctx, testDID, true)) + paused := mustGetByDID(t, repo, testDID) + assert.True(t, paused.DeliveryPaused) + assert.True(t, paused.Enabled, "pausing delivery must not disable the actor") + require.NoError(t, repo.SetPaused(ctx, testDID, false)) + assert.False(t, mustGetByDID(t, repo, testDID).DeliveryPaused) + + // Profile refresh: cache fields move, identity fields do not. This is + // the handle-change path (task 14) — the local part is FROZEN. + before := mustGetByDID(t, repo, testDID) + profile := APActorProfile{ + DisplayName: "Alice", + Summary: "posts about tide pools", + AvatarURL: "https://cdn.example/alice.png", + } + require.NoError(t, repo.UpdateProfile(ctx, testDID, profile)) + updated := mustGetByDID(t, repo, testDID) + assert.Equal(t, profile.DisplayName, updated.DisplayName) + assert.Equal(t, profile.Summary, updated.Summary) + assert.Equal(t, profile.AvatarURL, updated.AvatarURL) + assert.True(t, updated.UpdatedAt.After(before.UpdatedAt), "updated_at must advance") + assert.Equal(t, before.LocalPart, updated.LocalPart, + "the local part is frozen at creation: a profile refresh must never re-derive it") + assert.Equal(t, before.ActorID, updated.ActorID) + assert.Equal(t, before.CreatedAt, updated.CreatedAt) + + // Missing actor: every mutator reports it. + assert.True(t, errors.IsNotFound(repo.SetEnabled(ctx, testSecondDID, false)), + "SetEnabled on an unknown DID must be IsNotFound") + assert.True(t, errors.IsNotFound(repo.SetPaused(ctx, testSecondDID, true)), + "SetPaused on an unknown DID must be IsNotFound") + assert.True(t, errors.IsNotFound(repo.UpdateProfile(ctx, testSecondDID, profile)), + "UpdateProfile on an unknown DID must be IsNotFound") +} + +// TestAPActors_KindCheckConstraint pins the CHECK at the DB level: the store +// exposes no API for group actors (RESERVED for Scope B), so the schema is +// the only thing standing between a typo and a garbage actor kind. +func TestAPActors_KindCheckConstraint(t *testing.T) { + database := apActorsTestDB(t) + requireAPActorsTable(t, database) + ctx := t.Context() + + const insert = ` + INSERT INTO ap_actors ( + did, kind, actor_id, normalized_origin, local_part, + rsa_key_sealed, rsa_key_version, public_key_pem + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)` + + _, err := database.ExecContext(ctx, insert, + testDID, "bogus", apActorURL(apActorOrigin, testDID), + apActorOrigin, apActorLocalPart, []byte{0x01}, 1, apActorPublicKeyPEM) + require.Error(t, err, "kind must be constrained to person|group") + var pqErr *pq.Error + require.True(t, stderrors.As(err, &pqErr), "expected a postgres error, got %v", err) + assert.EqualValues(t, "23514", pqErr.Code, + "kind must be rejected by a CHECK constraint (23514), got %s: %v", pqErr.Code, err) + + // group is reserved but legal: Scope B mints them, no code path yet. + _, err = database.ExecContext(ctx, insert, + testSecondDID, string(ActorTypeGroup), apActorURL(apActorOrigin, testSecondDID), + apActorOrigin, "technology", []byte{0x01}, 1, apActorPublicKeyPEM) + require.NoError(t, err, "kind=group must be accepted (reserved for Scope B)") +} + +func mustGetByDID(t *testing.T, repo APActors, did string) *APActor { + t.Helper() + actor, err := repo.GetByDID(context.Background(), did) + require.NoError(t, err) + require.NotNil(t, actor, "GetByDID(%s) must return the stored row", did) + return actor +} + +// requireConflict asserts a uniqueness violation surfaced as the store's +// mapped ConflictError (not a raw pq error leaking through). +func requireConflict(t *testing.T, err error, msg string) { + t.Helper() + require.Error(t, err, msg) + assert.True(t, errors.IsAlreadyExists(err), "%s: want IsAlreadyExists, got %v", msg, err) + var conflict errors.ConflictError + if assert.True(t, stderrors.As(err, &conflict), + "%s: uniqueness must map to a ConflictError, got %T (%v)", msg, err, err) { + assert.NotEmpty(t, conflict.Field, "%s: the conflicting field must be named", msg) + } +} diff --git a/internal/store/interfaces.go b/internal/store/interfaces.go index 473e70b..cba70b8 100644 --- a/internal/store/interfaces.go +++ b/internal/store/interfaces.go @@ -121,6 +121,50 @@ type BridgedActors interface { MarkProfileSynced(ctx context.Context, apActorID string, syncedAt time.Time) error } +// APActors persists the ActivityPub identities Coves users get on the user +// origin (task 13): one Person actor per DID, its sealed RSA key, and the +// webfinger lookup key (normalized_origin, local_part). +// +// The local part is FROZEN at creation: a handle change updates the profile +// cache only, never the local part, so a minted @alice@coves.social keeps +// resolving after the user renames. +type APActors interface { + // Create inserts a new actor and returns the stored row. A created + // actor is always enabled and unpaused (default-on federation, + // decision 11): the lifecycle fields on the argument are ignored, and + // disabling goes through SetEnabled. Uniqueness violations — did, + // actor_id, or (normalized_origin, local_part) — return an error + // satisfying errors.IsAlreadyExists, mapped from the constraint name + // rather than pre-checked (a pre-check races). + Create(ctx context.Context, actor APActor) (*APActor, error) + + // GetByDID returns the actor for a Coves DID. A miss is an error + // satisfying errors.IsNotFound. + GetByDID(ctx context.Context, did string) (*APActor, error) + + // GetByOriginLocalPart returns the actor for a (normalized origin, + // local part) pair — the webfinger lookup, scoped to the routed Host so + // vanity origins hosting the same local part stay distinct. A miss is + // an error satisfying errors.IsNotFound. + GetByOriginLocalPart(ctx context.Context, normalizedOrigin, localPart string) (*APActor, error) + + // SetEnabled toggles federation for an actor: disabling stamps + // disabled_at, re-enabling clears it and re-stamps enabled_at. A + // missing actor is an error satisfying errors.IsNotFound. + SetEnabled(ctx context.Context, did string, enabled bool) error + + // SetPaused toggles delivery_paused (the transient #account state). + // A missing actor is an error satisfying errors.IsNotFound. + SetPaused(ctx context.Context, did string, paused bool) error + + // UpdateProfile refreshes the cached display name, summary, and avatar + // and bumps updated_at. It NEVER touches local_part — the identity + // handler (task 14) reaches this method on handle changes, and the + // frozen local part is what keeps federated mentions resolving. + // A missing actor is an error satisfying errors.IsNotFound. + UpdateProfile(ctx context.Context, did string, profile APActorProfile) error +} + // Communities tracks the AP groups the bridge subscribes to and their // backfill progress. type Communities interface { diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go index 5dd0cc8..dc799ef 100644 --- a/internal/store/migrations_test.go +++ b/internal/store/migrations_test.go @@ -24,7 +24,7 @@ func TestMigrations_UpDownUp(t *testing.T) { err := database.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' - AND table_name IN ('ap_objects', 'bridged_actors', 'communities', 'inbox_events', 'service_keys', + AND table_name IN ('ap_objects', 'ap_actors', 'bridged_actors', 'communities', 'inbox_events', 'service_keys', 'blocks', 'repo_state', 'firehose_events', 'vote_aggregates', 'vote_events') `).Scan(&remaining) require.NoError(t, err) @@ -47,6 +47,12 @@ func TestMigrations_UniqueConstraintNames(t *testing.T) { ctx := context.Background() expected := []string{ + // ap_actors (task 13). The composite name is EXPLICIT: postgres + // would default it to ap_actors_normalized_origin_local_part_key, + // so the migration must name the constraint itself. + "ap_actors_pkey", + "ap_actors_actor_id_key", + "ap_actors_origin_local_part_key", "ap_objects_ap_id_key", "ap_objects_at_uri_key", "bridged_actors_ap_actor_id_key", -- 2.51.2