diff --git a/internal/bot/appservice.go b/internal/bot/appservice.go index 889026b..471919e 100644 --- a/internal/bot/appservice.go +++ b/internal/bot/appservice.go @@ -110,7 +110,11 @@ func newAppserviceSession(cfg Config) (*Session, error) { // use, since there is no /sync to backfill it. Must be installed before the // first BotClient() call: makeClient copies as.StateStore into the client, and // caches the client. - store := newLazyStateStore(as.StateStore) + inner, ok := as.StateStore.(innerStateStore) + if !ok { + return nil, fmt.Errorf("appservice state store %T does not implement crypto.StateStore", as.StateStore) + } + store := newLazyStateStore(inner) as.StateStore = store client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion diff --git a/internal/bot/statestore.go b/internal/bot/statestore.go index d5694b5..a76dcf2 100644 --- a/internal/bot/statestore.go +++ b/internal/bot/statestore.go @@ -8,6 +8,7 @@ import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -30,7 +31,12 @@ import ( // error, which aborts the send. A failed message is recoverable; a plaintext one // that has already landed in an encrypted room is not. type lazyStateStore struct { - appservice.StateStore + // Embeds both interfaces the store is consumed through: the appservice + // dispatches state updates through the first, and the crypto machine type- + // asserts the client's store to the second (cryptohelper.NewCryptoHelper + // rejects a store that fails the assertion). Embedding only the appservice + // interface silently drops crypto's extra methods from the concrete type. + innerStateStore client *mautrix.Client @@ -42,9 +48,22 @@ type lazyStateStore struct { encryptionResolved map[id.RoomID]bool } -func newLazyStateStore(inner appservice.StateStore) *lazyStateStore { +// innerStateStore is the full method set the wrapped store must provide. +type innerStateStore interface { + appservice.StateStore + crypto.StateStore +} + +// The wrapper is consumed through both, and losing either one is a startup +// failure rather than a build failure without these. +var ( + _ appservice.StateStore = (*lazyStateStore)(nil) + _ crypto.StateStore = (*lazyStateStore)(nil) +) + +func newLazyStateStore(inner innerStateStore) *lazyStateStore { return &lazyStateStore{ - StateStore: inner, + innerStateStore: inner, locks: make(map[id.RoomID]*sync.Mutex), encryptionResolved: make(map[id.RoomID]bool), } @@ -90,7 +109,7 @@ func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (boo switch { case err == nil: if enc.Algorithm != "" { - if err := s.StateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil { + if err := s.innerStateStore.SetEncryptionEvent(ctx, roomID, &enc); err != nil { return false, fmt.Errorf("cache encryption state for %s: %w", roomID, err) } } @@ -103,14 +122,24 @@ func (s *lazyStateStore) IsEncrypted(ctx context.Context, roomID id.RoomID) (boo s.markEncryptionResolved(roomID) } } - return s.StateStore.IsEncrypted(ctx, roomID) + return s.innerStateStore.IsEncrypted(ctx, roomID) +} + +// GetEncryptionEvent is how the crypto machine reads a room's megolm settings +// (session rotation period, etc). It needs the same resolution as IsEncrypted, +// since an unresolved room would otherwise report no encryption event. +func (s *lazyStateStore) GetEncryptionEvent(ctx context.Context, roomID id.RoomID) (*event.EncryptionEventContent, error) { + if _, err := s.IsEncrypted(ctx, roomID); err != nil { + return nil, err + } + return s.innerStateStore.GetEncryptionEvent(ctx, roomID) } // GetRoomJoinedOrInvitedMembers backs megolm session sharing. Without a sync // there is no member backfill, so an unfetched room would report zero members // and the bot would encrypt to an audience of nobody. func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) { - fetched, err := s.StateStore.HasFetchedMembers(ctx, roomID) + fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID) if err != nil { return nil, fmt.Errorf("check member cache for %s: %w", roomID, err) } @@ -118,7 +147,7 @@ func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, room unlock := s.lockRoom(roomID) defer unlock() - if fetched, err := s.StateStore.HasFetchedMembers(ctx, roomID); err != nil { + if fetched, err := s.innerStateStore.HasFetchedMembers(ctx, roomID); err != nil { return nil, fmt.Errorf("check member cache for %s: %w", roomID, err) } else if !fetched { members, err := s.client.Members(ctx, roomID) @@ -126,10 +155,10 @@ func (s *lazyStateStore) GetRoomJoinedOrInvitedMembers(ctx context.Context, room return nil, fmt.Errorf("fetch members of %s: %w", roomID, err) } // No membership filter, so this also marks the room as fetched. - if err := s.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil { + if err := s.innerStateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk); err != nil { return nil, fmt.Errorf("cache members of %s: %w", roomID, err) } } } - return s.StateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) + return s.innerStateStore.GetRoomJoinedOrInvitedMembers(ctx, roomID) } diff --git a/internal/bot/statestore_test.go b/internal/bot/statestore_test.go index 144c9d0..21e8acc 100644 --- a/internal/bot/statestore_test.go +++ b/internal/bot/statestore_test.go @@ -4,13 +4,14 @@ import ( "context" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "sync/atomic" "testing" "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/id" ) @@ -53,11 +54,26 @@ func (f *fakeHS) start(t *testing.T) *mautrix.Client { func newTestStore(t *testing.T, hs *fakeHS) *lazyStateStore { t.Helper() - store := newLazyStateStore(mautrix.NewMemoryStateStore().(appservice.StateStore)) + store := newLazyStateStore(mautrix.NewMemoryStateStore().(innerStateStore)) store.client = hs.start(t) return store } +// The store is handed to the crypto helper, which type-asserts it and refuses to +// start if it comes up short. That assertion is the whole bot's startup path, so +// pin it here rather than discovering it as a crash loop in prod. +func TestStoreSatisfiesCryptoHelper(t *testing.T) { + store := newTestStore(t, &fakeHS{}) + + client := store.client + client.StateStore = store + + dir := t.TempDir() + if _, err := cryptohelper.NewCryptoHelper(client, []byte("test"), filepath.Join(dir, "crypto.db")); err != nil { + t.Fatalf("crypto helper rejected the state store, so the bot would not start: %v", err) + } +} + const roomID = id.RoomID("!room:example.org") // The regression: a room configured as encrypted before the process started. No