mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Both session paths called GenerateAndUploadCrossSigningKeys unconditionally on every start. That published a fresh cross-signing identity each time the bot restarted, so every user's client saw the bot's verification break and had to re-verify it. The freshly-minted recovery key was only ever logged, never kept, so the identity couldn't be recovered either. bootstrapCrossSigning now mints once, persists the recovery key to DATA_DIR/cross_signing.json (0600), and on later starts reuses the stored identity untouched. Two escape hatches, both normally unset: CROSS_SIGNING_REGENERATE=1 forces exactly one remint (every user must then re-verify), and CROSS_SIGNING_RECOVERY_KEY imports an identity created before the bot persisted its own key. Shared by the appservice and masdevice paths, which had drifted into two copies of the same block.
176 lines
6.3 KiB
Go
176 lines
6.3 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Config holds the bot's startup configuration.
|
|
type Config struct {
|
|
Homeserver string
|
|
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 against
|
|
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
|
|
// E2EE via the cryptohelper.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.UserID == "" {
|
|
return nil, fmt.Errorf("BOT_USER_ID is required")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// ---- 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)
|
|
|
|
// Validate the token + identity before proceeding.
|
|
whoami, err := client.Whoami(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
|
|
}
|
|
if whoami.UserID != userID {
|
|
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
|
}
|
|
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
|
|
|
|
// ---- 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)
|
|
}
|
|
// 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: %w", err)
|
|
}
|
|
client.Crypto = ch
|
|
|
|
// 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()
|
|
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
|
|
|
|
// ---- 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", "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))
|
|
}
|
|
}
|