mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add appservice auth mode behind AUTH_MODE; land device grant
Two things, entangled in the working tree because 20d1f92 was mislabeled
(its message claimed "device grant" but it only deleted registration.yaml.example
and left the failed appservice+/sync hybrid in client.go):
1. Land the MAS OAuth 2.0 device grant that was running in prod but never
committed: masauth.go + client.go rewrite. Bot stays a normal user so /sync
works; refresh token persisted in data/mas_auth.json.
2. Add "appservice" as an alternate transport behind AUTH_MODE (default
"masdevice" = unchanged device-grant path, instant rollback):
- internal/bot/session.go: Session abstraction unifying both transports
(OnEventType/Run/Stop); NewSession dispatches on AuthMode.
- internal/bot/appservice.go: as_token auth (MAS-durable, no login/MFA/expiry),
cryptohelper MSC4190 device creation, crypto machine fed from Synapse
transaction pushes (to-device/device-lists/OTK) instead of /sync, inbound
decrypt+redispatch, and lazy per-room StateStore backfill (encryption +
members) so replies in encrypted rooms encrypt to the right recipients.
- main.go: NewSession/sess.OnEventType/sess.Run in place of the /sync loop.
- .env.example: AUTH_MODE, AS_REGISTRATION, AS_LISTEN_HOST/PORT, HOMESERVER_DOMAIN.
The appservice path fixes the earlier hybrid's fatal flaw (Synapse forbids AS
users from /sync) by using the transaction/push model. Bot-side only; Synapse
registration + experimental_features (msc2409/msc3202/msc4190) and E2EE cutover
are the next steps. Build + vet green (CGO=1 -tags goolm).
This commit is contained in:
204
internal/bot/appservice.go
Normal file
204
internal/bot/appservice.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
|
||||
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
|
||||
// the cryptohelper gets these automatically; under the appservice transaction
|
||||
// model we route each one to mach.HandleToDeviceEvent ourselves.
|
||||
var cryptoToDeviceTypes = []event.Type{
|
||||
event.ToDeviceEncrypted,
|
||||
event.ToDeviceRoomKey,
|
||||
event.ToDeviceForwardedRoomKey,
|
||||
event.ToDeviceRoomKeyRequest,
|
||||
event.ToDeviceRoomKeyWithheld,
|
||||
event.ToDeviceOrgMatrixRoomKeyWithheld,
|
||||
event.ToDeviceSecretRequest,
|
||||
event.ToDeviceSecretSend,
|
||||
event.ToDeviceDummy,
|
||||
}
|
||||
|
||||
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
|
||||
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
|
||||
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
|
||||
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
|
||||
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
|
||||
// transaction API instead.
|
||||
func newAppserviceSession(cfg Config) (*Session, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
if cfg.RegistrationPath == "" {
|
||||
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
|
||||
}
|
||||
if cfg.HomeserverDomain == "" {
|
||||
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
|
||||
}
|
||||
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
|
||||
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
|
||||
}
|
||||
// Gate for to-device delivery: handleTransaction only pumps to-device events
|
||||
// when this is set (appservice/http.go). Force it on regardless of the yaml so
|
||||
// E2EE key exchange can't silently break on a missing field.
|
||||
reg.EphemeralEvents = true
|
||||
|
||||
as, err := appservice.CreateFull(appservice.CreateOpts{
|
||||
Registration: reg,
|
||||
HomeserverDomain: cfg.HomeserverDomain,
|
||||
HomeserverURL: cfg.Homeserver,
|
||||
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create appservice: %w", err)
|
||||
}
|
||||
// Surface the HTTP listener + transaction logs (default is a silent Nop).
|
||||
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
|
||||
Level(zerolog.InfoLevel)
|
||||
|
||||
userID := id.UserID(cfg.UserID)
|
||||
if as.BotMXID() != userID {
|
||||
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
|
||||
}
|
||||
|
||||
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
||||
|
||||
// Validate the token + identity before we start listening.
|
||||
whoami, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
|
||||
}
|
||||
if whoami.UserID != userID {
|
||||
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
||||
}
|
||||
slog.Info("appservice token valid", "user_id", whoami.UserID)
|
||||
|
||||
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
|
||||
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
|
||||
// instead of the UIA-gated login. The crypto store persists the device ID, so
|
||||
// restarts reuse it. LoginAs carries only the display name (never calls /login
|
||||
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
|
||||
// handlers — we drive the crypto machine from transactions below instead.
|
||||
ch.MSC4190 = true
|
||||
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
|
||||
if err := ch.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
|
||||
}
|
||||
client.Crypto = ch
|
||||
|
||||
// ---- Event processor: replicate the cryptohelper's /sync wiring against
|
||||
// the appservice transaction channels ----
|
||||
ep := appservice.NewEventProcessor(as)
|
||||
mach := ch.Machine()
|
||||
|
||||
// Crypto plumbing that /sync would otherwise carry:
|
||||
ep.OnOTK(mach.HandleOTKCounts)
|
||||
ep.OnDeviceList(mach.HandleDeviceLists)
|
||||
for _, t := range cryptoToDeviceTypes {
|
||||
ep.On(t, mach.HandleToDeviceEvent)
|
||||
}
|
||||
|
||||
// Keep the client's StateStore current so outgoing sends know which rooms are
|
||||
// encrypted and who to share keys with (no /sync backfill here), and let the
|
||||
// crypto machine track membership for key sharing. PrependHandler so state is
|
||||
// updated before app handlers (auto-join/moderation) run for the same event.
|
||||
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
|
||||
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
||||
})
|
||||
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
||||
mach.HandleMemberEvent(ctx, evt)
|
||||
})
|
||||
|
||||
// Without /sync there is no state backfill, so the client's StateStore starts
|
||||
// empty. Before we hand off a decrypted event (whose reply may need to be
|
||||
// encrypted), resolve the room once: mark it encrypted and populate its member
|
||||
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
|
||||
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
|
||||
// so recipients can't decrypt the bot's replies.
|
||||
var resolved sync.Map // roomID -> struct{}, resolved once
|
||||
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
|
||||
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
|
||||
return
|
||||
}
|
||||
var enc event.EncryptionEventContent
|
||||
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
|
||||
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
|
||||
}
|
||||
if members, err := client.Members(ctx, roomID); err == nil {
|
||||
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
|
||||
} else {
|
||||
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
|
||||
resolved.Delete(roomID) // allow a later event to retry
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
|
||||
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
|
||||
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
|
||||
resolveRoom(ctx, evt.RoomID)
|
||||
decrypted, err := ch.Decrypt(ctx, evt)
|
||||
if err != nil {
|
||||
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
|
||||
return
|
||||
}
|
||||
ep.Dispatch(ctx, decrypted)
|
||||
})
|
||||
|
||||
return &Session{
|
||||
Client: client,
|
||||
mode: "appservice",
|
||||
as: as,
|
||||
ep: ep,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runAppservice starts the transaction dispatchers and the HTTP listener, then
|
||||
// blocks until ctx is cancelled.
|
||||
func (s *Session) runAppservice(ctx context.Context) error {
|
||||
s.ep.Start(ctx)
|
||||
s.as.Ready = true
|
||||
|
||||
errCh := make(chan struct{})
|
||||
go func() {
|
||||
s.as.Start() // blocks in ListenAndServe until Stop()
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
slog.Info("appservice listener started", "address", s.as.Host.Address())
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-errCh:
|
||||
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
@@ -15,133 +16,172 @@ import (
|
||||
// Config holds the bot's startup configuration.
|
||||
type Config struct {
|
||||
Homeserver string
|
||||
UserID string // Bot's full Matrix user ID, e.g. @gogobee:example.com
|
||||
ASToken string // Appservice as_token from registration.yaml
|
||||
UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
|
||||
DataDir string
|
||||
DisplayName string
|
||||
|
||||
// AuthMode selects how the bot connects to Matrix:
|
||||
// "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go).
|
||||
// "appservice" — Matrix appservice: as_token auth + Synapse→bot
|
||||
// transaction push (appservice.go). MAS-durable:
|
||||
// no login, no MFA, no token expiry ever.
|
||||
// The device-grant path is retained as an instant rollback: flip AUTH_MODE
|
||||
// back to masdevice and restart, no rebuild.
|
||||
AuthMode string
|
||||
|
||||
// ---- appservice mode only ----
|
||||
// RegistrationPath points at the appservice registration YAML (id, as_token,
|
||||
// hs_token, sender_localpart, namespaces). Synapse and the bot share it.
|
||||
RegistrationPath string
|
||||
// ListenHost/ListenPort is where the bot's HTTP listener binds to receive
|
||||
// Synapse's transaction pushes (Synapse reaches it via the registration url).
|
||||
ListenHost string
|
||||
ListenPort uint16
|
||||
// HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive
|
||||
// the bot's MXID from sender_localpart.
|
||||
HomeserverDomain string
|
||||
}
|
||||
|
||||
// NewClient creates and configures a mautrix client authenticated as an
|
||||
// application service, with E2EE via the cryptohelper.
|
||||
// NewClient creates and configures a mautrix client authenticated against
|
||||
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
|
||||
// E2EE via the cryptohelper.
|
||||
//
|
||||
// Why appservice instead of password login:
|
||||
// Auth flow (see masauth.go for detail):
|
||||
// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
|
||||
// - If we have a persisted refresh token, refresh it for a fresh access token.
|
||||
// - Otherwise run the device-authorization grant: print a URL + code for a
|
||||
// human to approve ONCE in a browser (as the bot's user), then store the
|
||||
// resulting access + refresh tokens.
|
||||
// - A background goroutine refreshes the access token before it expires and
|
||||
// updates the live client, so the bot runs indefinitely without re-auth.
|
||||
//
|
||||
// Under Matrix Authentication Service (MAS), the legacy password login
|
||||
// (m.login.password) and User-Interactive Auth (UIA) flows are being removed —
|
||||
// auth moves to OAuth2/OIDC. A bot built on password login + password-UIA
|
||||
// cross-signing (the old code) breaks in a MAS world: short-lived tokens with
|
||||
// no refresh, and UIA-gated key uploads that simply fail.
|
||||
//
|
||||
// The appservice model sidesteps MAS entirely. The as_token is a Synapse-level
|
||||
// trust relationship declared in registration.yaml — Synapse validates it
|
||||
// directly, not via MAS — so it never expires and needs no login flow. Device
|
||||
// creation uses MSC4190 (PUT /devices), which replaces the UIA-gated device
|
||||
// dance for appservice users. This is the MAS-durable path.
|
||||
//
|
||||
// The cryptohelper still handles everything E2EE:
|
||||
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing)
|
||||
// - Megolm session sharing / key exchange, Olm device-to-device sessions
|
||||
// - The bot trusts all users' devices by default (appropriate for a bot)
|
||||
// - No interactive emoji/SAS verification needed
|
||||
// The bot stays a normal Matrix user (not an appservice), so /sync works — an
|
||||
// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the
|
||||
// cryptohelper persists device keys, olm/megolm sessions and cross-signing in
|
||||
// its own SQLite store, and the bot trusts all users' devices by default.
|
||||
func NewClient(cfg Config) (*mautrix.Client, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
if cfg.ASToken == "" {
|
||||
return nil, fmt.Errorf("AS_TOKEN is required (appservice registration as_token)")
|
||||
}
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
userID := id.UserID(cfg.UserID)
|
||||
|
||||
// The as_token IS the access token. No login flow, no refresh — Synapse
|
||||
// trusts it for any user in the registration's namespace.
|
||||
client, err := mautrix.NewClient(cfg.Homeserver, userID, cfg.ASToken)
|
||||
// ---- MAS OAuth device-grant authentication ----
|
||||
auth := newMASAuth(cfg.DataDir)
|
||||
auth.load()
|
||||
if err := auth.discover(cfg.Homeserver); err != nil {
|
||||
return nil, fmt.Errorf("MAS discovery: %w", err)
|
||||
}
|
||||
|
||||
if auth.refreshToken != "" {
|
||||
// Returning start: refresh the stored token.
|
||||
if err := auth.refresh(); err != nil {
|
||||
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
|
||||
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
|
||||
}
|
||||
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
|
||||
} else {
|
||||
// First run: register a client and run the interactive device grant.
|
||||
if err := auth.ensureClient(cfg.DisplayName); err != nil {
|
||||
return nil, fmt.Errorf("MAS client registration: %w", err)
|
||||
}
|
||||
if err := auth.deviceFlow(ctx); err != nil {
|
||||
return nil, fmt.Errorf("MAS device authorization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
userID := id.UserID(cfg.UserID)
|
||||
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(auth.deviceID)
|
||||
|
||||
// Assert our own user ID on every request (appservice identity assertion,
|
||||
// ?user_id=). Required so Synapse knows which namespaced user we're acting
|
||||
// as — including on /sync and the MSC4190 device creation below.
|
||||
client.SetAppServiceUserID = true
|
||||
|
||||
// Validate the token + homeserver reachability up front with a clear error.
|
||||
// Validate the token + identity before proceeding.
|
||||
whoami, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
|
||||
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
|
||||
}
|
||||
if whoami.UserID != userID {
|
||||
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
||||
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
||||
}
|
||||
slog.Info("appservice token valid", "user_id", whoami.UserID)
|
||||
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
|
||||
|
||||
// Set up E2EE. The cryptohelper persists all crypto state in its own SQLite
|
||||
// DB (device keys, olm/megolm sessions, cross-signing keys, device trust),
|
||||
// separate from the main app database. The device ID is persisted there too,
|
||||
// so restarts reuse the same device — no device.json needed.
|
||||
// ---- E2EE via cryptohelper ----
|
||||
// The crypto store persists device keys, olm/megolm sessions, cross-signing
|
||||
// and device trust in its own SQLite DB. The stored device ID must match the
|
||||
// one bound to our OAuth token (device: scope); a fresh mas_auth.json is
|
||||
// paired with a fresh crypto.db.
|
||||
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
// MSC4190: create/refresh the bot's device via PUT /devices instead of the
|
||||
// UIA-gated login dance. On first run the crypto store has no device ID, so
|
||||
// a fresh one is generated and persisted; on restart the stored ID is
|
||||
// reused (the PUT is idempotent). LoginAs carries only the display name —
|
||||
// in MSC4190 mode the cryptohelper never calls /login.
|
||||
ch.MSC4190 = true
|
||||
ch.LoginAs = &mautrix.ReqLogin{
|
||||
InitialDeviceDisplayName: cfg.DisplayName,
|
||||
}
|
||||
|
||||
// No LoginAs: we already have a token + device ID; the cryptohelper just
|
||||
// attaches E2EE to the existing session.
|
||||
if err := ch.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
client.Crypto = ch
|
||||
|
||||
// Best-effort cross-signing bootstrap. This makes the bot's device show as
|
||||
// "verified" to users who have verified the bot's master key. It is NOT
|
||||
// required for E2EE to function — the bot already trusts all devices and
|
||||
// shares keys — so any failure is logged and ignored.
|
||||
//
|
||||
// Under MSC3202 (appservice device assertion) Synapse may accept the key
|
||||
// upload without UIA, in which case the callback below is never invoked. If
|
||||
// the homeserver still demands UIA, we have no password credential to
|
||||
// satisfy it (that's the whole point of going MAS-native), so it fails soft.
|
||||
// Best-effort cross-signing bootstrap (makes the bot's device show as
|
||||
// verified to users who trust its master key). Not required for E2EE to
|
||||
// function; under MAS the key upload may be refused, which we ignore.
|
||||
mach := ch.Machine()
|
||||
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} {
|
||||
slog.Warn("cross-signing: homeserver requested UIA, but appservice auth has no password credential to satisfy it — skipping verified-shield bootstrap")
|
||||
if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} {
|
||||
return map[string]interface{}{"session": ui.Session}
|
||||
}, "")
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: key upload skipped (may already exist or UIA required)", "err", err)
|
||||
}, ""); err != nil {
|
||||
slog.Warn("cross-signing: key upload skipped (may already exist or need UIA)", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
|
||||
}
|
||||
|
||||
if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
|
||||
slog.Warn("cross-signing: sign own device failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: own device signed")
|
||||
}
|
||||
|
||||
if err := mach.SignOwnMasterKey(ctx); err != nil {
|
||||
slog.Warn("cross-signing: sign master key failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: master key signed")
|
||||
}
|
||||
|
||||
// ---- Background token refresher ----
|
||||
// Refresh ~60s before expiry and push the new token into the live client so
|
||||
// in-flight /sync and API calls keep authenticating.
|
||||
go refreshLoop(context.Background(), auth, client)
|
||||
|
||||
slog.Info("E2EE initialized",
|
||||
"user_id", client.UserID,
|
||||
"device_id", client.DeviceID,
|
||||
"crypto_store", "sqlite-persistent",
|
||||
"auth", "appservice+msc4190",
|
||||
"auth", "mas-oauth-device-grant",
|
||||
)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// refreshLoop keeps the client's access token fresh for the life of the process.
|
||||
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
|
||||
for {
|
||||
wait := time.Until(auth.expiry()) - 60*time.Second
|
||||
if wait < 10*time.Second {
|
||||
wait = 10 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if err := auth.refresh(); err != nil {
|
||||
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
client.AccessToken = auth.token()
|
||||
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
375
internal/bot/masauth.go
Normal file
375
internal/bot/masauth.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant
|
||||
// authentication for the bot and keeps a valid access token available.
|
||||
//
|
||||
// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A
|
||||
// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0
|
||||
// Device Authorization Grant (RFC 8628): on first run the bot prints a URL +
|
||||
// user code, a human approves the login as the bot's user ONCE in a browser,
|
||||
// and MAS returns an access token + refresh token. Thereafter the bot refreshes
|
||||
// silently forever — no password, no expiry surprises, and the bot stays a
|
||||
// normal Matrix user so /sync keeps working (unlike an appservice user, which
|
||||
// Synapse forbids from /sync).
|
||||
//
|
||||
// The refresh token is rotated by MAS on every refresh, so we persist the new
|
||||
// one each time.
|
||||
type masAuth struct {
|
||||
http *http.Client
|
||||
storePath string
|
||||
scope string
|
||||
|
||||
// discovered OAuth endpoints
|
||||
deviceEndpoint string
|
||||
tokenEndpoint string
|
||||
registrationEndpoint string
|
||||
|
||||
mu sync.RWMutex
|
||||
clientID string
|
||||
deviceID string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// masStore is the on-disk persisted state (data/mas_auth.json).
|
||||
type masStore struct {
|
||||
ClientID string `json:"client_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*"
|
||||
|
||||
func newMASAuth(dataDir string) *masAuth {
|
||||
return &masAuth{
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
storePath: filepath.Join(dataDir, "mas_auth.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *masAuth) load() {
|
||||
data, err := os.ReadFile(m.storePath)
|
||||
if err != nil {
|
||||
return // fresh install
|
||||
}
|
||||
var s masStore
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
slog.Warn("mas_auth: corrupt store, ignoring", "err", err)
|
||||
return
|
||||
}
|
||||
m.clientID = s.ClientID
|
||||
m.deviceID = s.DeviceID
|
||||
m.accessToken = s.AccessToken
|
||||
m.refreshToken = s.RefreshToken
|
||||
m.expiresAt = s.ExpiresAt
|
||||
}
|
||||
|
||||
func (m *masAuth) save() {
|
||||
m.mu.RLock()
|
||||
s := masStore{
|
||||
ClientID: m.clientID,
|
||||
DeviceID: m.deviceID,
|
||||
AccessToken: m.accessToken,
|
||||
RefreshToken: m.refreshToken,
|
||||
ExpiresAt: m.expiresAt,
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
slog.Error("mas_auth: marshal failed", "err", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(m.storePath, data, 0o600); err != nil {
|
||||
slog.Error("mas_auth: write failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *masAuth) token() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.accessToken
|
||||
}
|
||||
|
||||
// discover resolves the MAS OAuth endpoints from the homeserver's well-known
|
||||
// document and the issuer's OIDC discovery document.
|
||||
func (m *masAuth) discover(homeserver string) error {
|
||||
var wk struct {
|
||||
Auth struct {
|
||||
Issuer string `json:"issuer"`
|
||||
} `json:"org.matrix.msc2965.authentication"`
|
||||
}
|
||||
if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil {
|
||||
return fmt.Errorf("fetch well-known: %w", err)
|
||||
}
|
||||
if wk.Auth.Issuer == "" {
|
||||
return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?")
|
||||
}
|
||||
var oidc struct {
|
||||
DeviceEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
}
|
||||
if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil {
|
||||
return fmt.Errorf("fetch OIDC discovery: %w", err)
|
||||
}
|
||||
if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" {
|
||||
return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer)
|
||||
}
|
||||
m.deviceEndpoint = oidc.DeviceEndpoint
|
||||
m.tokenEndpoint = oidc.TokenEndpoint
|
||||
m.registrationEndpoint = oidc.RegistrationEndpoint
|
||||
slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClient registers a public OAuth client via dynamic registration if we
|
||||
// don't already have a client_id persisted.
|
||||
func (m *masAuth) ensureClient(displayName string) error {
|
||||
if m.clientID != "" {
|
||||
return nil
|
||||
}
|
||||
if m.registrationEndpoint == "" {
|
||||
return fmt.Errorf("no registration endpoint discovered")
|
||||
}
|
||||
body := map[string]any{
|
||||
"client_name": displayName + " (bot)",
|
||||
"application_type": "native",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
|
||||
"response_types": []string{},
|
||||
"client_uri": "https://github.com/prosolis/gogobee",
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("client registration request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
var out struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" {
|
||||
return fmt.Errorf("client registration: no client_id in response: %s", string(rb))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.clientID = out.ClientID
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceFlow runs the interactive device-authorization grant. It blocks until
|
||||
// the user approves the login (or the code expires).
|
||||
func (m *masAuth) deviceFlow(ctx context.Context) error {
|
||||
if m.deviceID == "" {
|
||||
m.deviceID = randomDeviceID()
|
||||
}
|
||||
scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID
|
||||
|
||||
form := url.Values{"client_id": {m.clientID}, "scope": {scope}}
|
||||
var da struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
if err := m.postForm(m.deviceEndpoint, form, &da); err != nil {
|
||||
return fmt.Errorf("device authorization request: %w", err)
|
||||
}
|
||||
|
||||
// Surface the approval prompt prominently — the operator must act on it.
|
||||
uri := da.VerificationURI
|
||||
if da.VerificationURIComplete != "" {
|
||||
uri = da.VerificationURIComplete
|
||||
}
|
||||
banner := fmt.Sprintf(`
|
||||
|
||||
================= TwinBee needs you to authorize its login =================
|
||||
Open this URL in a browser (logged in as the bot's Matrix user):
|
||||
%s
|
||||
and enter code: %s
|
||||
(expires in %d minutes)
|
||||
============================================================================
|
||||
|
||||
`, uri, da.UserCode, da.ExpiresIn/60)
|
||||
fmt.Print(banner)
|
||||
slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn)
|
||||
|
||||
interval := da.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(interval) * time.Second):
|
||||
}
|
||||
pending, err := m.pollToken(url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"device_code": {da.DeviceCode},
|
||||
"client_id": {m.clientID},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pending {
|
||||
slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60)
|
||||
}
|
||||
|
||||
// pollToken hits the token endpoint. Returns pending=true if the user hasn't
|
||||
// approved yet (keep polling); on success it stores the tokens.
|
||||
func (m *masAuth) pollToken(form url.Values) (pending bool, err error) {
|
||||
resp, err := m.http.PostForm(m.tokenEndpoint, form)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 200 {
|
||||
return false, m.storeTokenResponse(rb)
|
||||
}
|
||||
var oerr struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rb, &oerr)
|
||||
switch oerr.Error {
|
||||
case "authorization_pending":
|
||||
return true, nil
|
||||
case "slow_down":
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
}
|
||||
|
||||
// refresh exchanges the stored refresh token for a fresh access token. MAS
|
||||
// rotates the refresh token, so we persist the new one.
|
||||
func (m *masAuth) refresh() error {
|
||||
m.mu.RLock()
|
||||
rt, cid := m.refreshToken, m.clientID
|
||||
m.mu.RUnlock()
|
||||
if rt == "" {
|
||||
return fmt.Errorf("no refresh token")
|
||||
}
|
||||
resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {rt},
|
||||
"client_id": {cid},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
return m.storeTokenResponse(rb)
|
||||
}
|
||||
|
||||
func (m *masAuth) storeTokenResponse(rb []byte) error {
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(rb, &tr); err != nil {
|
||||
return fmt.Errorf("parse token response: %w", err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return fmt.Errorf("token response missing access_token: %s", string(rb))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.accessToken = tr.AccessToken
|
||||
if tr.RefreshToken != "" {
|
||||
m.refreshToken = tr.RefreshToken
|
||||
}
|
||||
ttl := tr.ExpiresIn
|
||||
if ttl <= 0 {
|
||||
ttl = 300
|
||||
}
|
||||
m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *masAuth) expiry() time.Time {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.expiresAt
|
||||
}
|
||||
|
||||
// getJSON GETs a URL and decodes JSON.
|
||||
func (m *masAuth) getJSON(u string, out any) error {
|
||||
resp, err := m.http.Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// postForm POSTs a form and decodes a JSON success body (200).
|
||||
func (m *masAuth) postForm(u string, form url.Values, out any) error {
|
||||
resp, err := m.http.PostForm(u, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb))
|
||||
}
|
||||
return json.Unmarshal(rb, out)
|
||||
}
|
||||
|
||||
// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters.
|
||||
func randomDeviceID() string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
b := make([]byte, 10)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// rand.Read essentially never fails; fall back to a fixed prefix.
|
||||
return "GOGOBEEBOT"
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return "GB" + string(b[:8])
|
||||
}
|
||||
122
internal/bot/session.go
Normal file
122
internal/bot/session.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/event"
|
||||
)
|
||||
|
||||
// Session is the bot's live Matrix connection plus its event source. It hides
|
||||
// the difference between the two transports the bot can run under:
|
||||
//
|
||||
// - masdevice: a normal Matrix user authenticated via the MAS OAuth device
|
||||
// grant, receiving events over /sync (mautrix DefaultSyncer).
|
||||
// - appservice: a Synapse appservice authenticated by its as_token, receiving
|
||||
// events pushed over the appservice transaction API (mautrix EventProcessor).
|
||||
//
|
||||
// Callers register the same three handlers (message/member/reaction) via
|
||||
// OnEventType and call Run regardless of mode; the plugin layer never knows which
|
||||
// transport is in use.
|
||||
type Session struct {
|
||||
Client *mautrix.Client
|
||||
mode string
|
||||
|
||||
// masdevice
|
||||
syncer *mautrix.DefaultSyncer
|
||||
|
||||
// appservice
|
||||
as *appservice.AppService
|
||||
ep *appservice.EventProcessor
|
||||
}
|
||||
|
||||
// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On.
|
||||
type EventHandler = func(ctx context.Context, evt *event.Event)
|
||||
|
||||
// NewSession builds a Session for the configured auth mode. Default and
|
||||
// "masdevice" use the MAS device grant; "appservice" uses the transaction model.
|
||||
func NewSession(cfg Config) (*Session, error) {
|
||||
switch cfg.AuthMode {
|
||||
case "appservice":
|
||||
return newAppserviceSession(cfg)
|
||||
case "", "masdevice":
|
||||
return newMASDeviceSession(cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode)
|
||||
}
|
||||
}
|
||||
|
||||
// newMASDeviceSession wraps the existing device-grant client (NewClient) and its
|
||||
// DefaultSyncer. This is the retained rollback path — unchanged behaviour.
|
||||
func newMASDeviceSession(cfg Config) (*Session, error) {
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{
|
||||
Client: client,
|
||||
mode: "masdevice",
|
||||
syncer: client.Syncer.(*mautrix.DefaultSyncer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnEventType registers a handler for the given event type against whichever
|
||||
// event source backs this session.
|
||||
func (s *Session) OnEventType(evtType event.Type, fn EventHandler) {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
s.ep.On(evtType, fn)
|
||||
default:
|
||||
s.syncer.OnEventType(evtType, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks, delivering events to registered handlers, until ctx is cancelled.
|
||||
func (s *Session) Run(ctx context.Context) error {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
return s.runAppservice(ctx)
|
||||
default:
|
||||
return s.runSync(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop halts the event source. Safe to call once.
|
||||
func (s *Session) Stop() {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
if s.ep != nil {
|
||||
s.ep.Stop()
|
||||
}
|
||||
if s.as != nil {
|
||||
s.as.Stop()
|
||||
}
|
||||
default:
|
||||
s.Client.StopSync()
|
||||
}
|
||||
}
|
||||
|
||||
// runSync is the device-grant /sync loop (moved verbatim from main.go): restart
|
||||
// on transient failure, exit on context cancel.
|
||||
func (s *Session) runSync(ctx context.Context) error {
|
||||
for {
|
||||
err := s.Client.SyncWithContext(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil // shutdown requested
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("sync stopped, restarting in 5s", "err", err)
|
||||
} else {
|
||||
slog.Warn("sync returned without error, restarting in 5s")
|
||||
}
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user