From 1adcd05dc760786e69122b6aabf1adac4ff2d891 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:53:23 -0700 Subject: [PATCH] cross-signing: persist the bot's identity instead of reminting it every start 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. --- .env.example | 15 ++++ internal/bot/appservice.go | 20 +---- internal/bot/client.go | 14 +-- internal/bot/crosssigning.go | 137 ++++++++++++++++++++++++++++++ internal/bot/crosssigning_test.go | 56 ++++++++++++ 5 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 internal/bot/crosssigning.go create mode 100644 internal/bot/crosssigning_test.go diff --git a/.env.example b/.env.example index 9f64144..4d16344 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,21 @@ AS_LISTEN_HOST= # bind address for the transaction listener (e.g. 0. AS_LISTEN_PORT= # bind port; Synapse's registration url must reach host:port HOMESERVER_DOMAIN= # server_name, e.g. example.com (derives the bot MXID from sender_localpart) +# ---- Cross-signing (optional; both auth modes) ---- +# Controls only whether the bot shows as VERIFIED in clients — E2EE works without it. +# Normally you set NEITHER of these. On its first start the bot mints a cross-signing +# identity and persists the recovery key itself to DATA_DIR/cross_signing.json (0600); +# every later start reuses that identity untouched, so users verify the bot once, ever. +# +# Set to 1 for exactly ONE start to throw away the published identity and mint a fresh +# one (the bot stores the new key itself). Every user must then verify the bot again. +# Needed only if the identity was lost, or to adopt one the bot can't re-sign. +CROSS_SIGNING_REGENERATE= +# +# Imports an existing recovery key into DATA_DIR/cross_signing.json on next start. +# Only useful to adopt an identity created before the bot persisted its own key. +CROSS_SIGNING_RECOVERY_KEY= + # Which rooms the bot posts scheduled content to (comma-separated room IDs) BROADCAST_ROOMS=!roomid:example.com diff --git a/internal/bot/appservice.go b/internal/bot/appservice.go index cf23d94..8095a69 100644 --- a/internal/bot/appservice.go +++ b/internal/bot/appservice.go @@ -137,23 +137,9 @@ func newAppserviceSession(cfg Config) (*Session, error) { mach := ch.Machine() // Best-effort cross-signing bootstrap so the bot's device shows as verified to - // users who trust its master key. Mirrors the masdevice path (client.go); the - // appservice path historically omitted it, so a fresh crypto.db has no - // cross-signing identity and the device shows unverified. Best-effort: under MAS - // the key upload may be refused, which we log and ignore — E2EE still functions. - if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} { - return map[string]interface{}{"session": ui.Session} - }, ""); 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) - } - if err := mach.SignOwnMasterKey(ctx); err != nil { - slog.Warn("cross-signing: sign master key failed", "err", err) - } + // users who trust its master key. Best-effort: under MAS the key upload may be + // refused, which we log and ignore — E2EE still functions without it. + bootstrapCrossSigning(ctx, mach, cfg.DataDir) // Crypto plumbing that /sync would otherwise carry: ep.OnOTK(mach.HandleOTKCounts) diff --git a/internal/bot/client.go b/internal/bot/client.go index be1d9cf..ff146cb 100644 --- a/internal/bot/client.go +++ b/internal/bot/client.go @@ -131,19 +131,7 @@ func NewClient(cfg Config) (*mautrix.Client, error) { // 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() - if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} { - return map[string]interface{}{"session": ui.Session} - }, ""); 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) - } - if err := mach.SignOwnMasterKey(ctx); err != nil { - slog.Warn("cross-signing: sign master key failed", "err", err) - } + bootstrapCrossSigning(ctx, mach, cfg.DataDir) // ---- Background token refresher ---- // Refresh ~60s before expiry and push the new token into the live client so diff --git a/internal/bot/crosssigning.go b/internal/bot/crosssigning.go new file mode 100644 index 0000000..4cd4899 --- /dev/null +++ b/internal/bot/crosssigning.go @@ -0,0 +1,137 @@ +package bot + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" +) + +// crossSigningFile is the on-disk store for the bot's cross-signing recovery key +// (data/cross_signing.json), alongside mas_auth.json and crypto.db. The key +// decrypts the private cross-signing keys the bot parks in SSSS, so it is the +// only thing that lets a rebuilt crypto.db re-sign the bot's device instead of +// minting a whole new identity and forcing everyone to re-verify. +// +// It is deliberately NOT logged: the screen wrapper pipes the bot's output +// through tee, which truncates the log on every restart, so a key that only ever +// exists in a log line is a key you lose on the next boot. +const crossSigningFile = "cross_signing.json" + +type crossSigningStore struct { + RecoveryKey string `json:"recovery_key"` +} + +func crossSigningPath(dataDir string) string { + return filepath.Join(dataDir, crossSigningFile) +} + +// loadRecoveryKey returns the stored key, or "" when there is none. +func loadRecoveryKey(dataDir string) string { + data, err := os.ReadFile(crossSigningPath(dataDir)) + if err != nil { + return "" // fresh install + } + var s crossSigningStore + if err := json.Unmarshal(data, &s); err != nil { + slog.Warn("cross-signing: corrupt recovery-key store, ignoring", "path", crossSigningPath(dataDir), "err", err) + return "" + } + return s.RecoveryKey +} + +func saveRecoveryKey(dataDir, key string) { + data, err := json.MarshalIndent(crossSigningStore{RecoveryKey: key}, "", " ") + if err != nil { + slog.Error("cross-signing: marshal recovery key failed", "err", err) + return + } + path := crossSigningPath(dataDir) + if err := os.WriteFile(path, data, 0o600); err != nil { + slog.Error("cross-signing: could not persist recovery key; a crypto.db rebuild will need a re-verify", "path", path, "err", err) + return + } + slog.Info("cross-signing: recovery key persisted", "path", path) +} + +// uiaSession answers a user-interactive-auth challenge with just the session ID. +// Under MAS the key upload is either allowed outright or refused; there is no +// password to offer. +func uiaSession(ui *mautrix.RespUserInteractive) interface{} { + return map[string]interface{}{"session": ui.Session} +} + +// bootstrapCrossSigning establishes the bot's cross-signing identity, which is +// what lets clients show it as verified rather than as an unknown device. E2EE +// works without it; only the verification badge depends on it. +// +// It must never mint a second identity by accident. mautrix's +// GenerateAndUploadCrossSigningKeys is unconditional: every call generates a fresh +// master/self/user-signing trio and overwrites the published one. Calling it on +// each start reminted the bot's identity every boot, which is why clients kept +// asking users to re-verify. So generate only when there is no identity to keep, +// or when the operator explicitly asks for a reset. +// +// The private keys live server-side in SSSS, never in crypto.db, and mach.Load +// does not restore them. A bot that keeps its crypto.db stays signed from its +// first signing and needs nothing here. A bot whose crypto.db was rebuilt has a +// brand-new device that only the recovery key can re-sign. +// +// Set CROSS_SIGNING_REGENERATE=1 to deliberately reset the identity (costs one +// re-verify per user, and stores the fresh key). CROSS_SIGNING_RECOVERY_KEY +// imports an existing key into the store, for adopting an identity created before +// the bot persisted its own. +func bootstrapCrossSigning(ctx context.Context, mach *crypto.OlmMachine, dataDir string) { + stored := loadRecoveryKey(dataDir) + if envKey := os.Getenv("CROSS_SIGNING_RECOVERY_KEY"); envKey != "" && envKey != stored { + saveRecoveryKey(dataDir, envKey) + stored = envKey + } + + hasKeys, isVerified, err := mach.GetOwnVerificationStatus(ctx) + if err != nil { + slog.Warn("cross-signing: could not determine verification status, leaving identity alone", "err", err) + return + } + regenerate := os.Getenv("CROSS_SIGNING_REGENERATE") != "" + + switch { + case regenerate || !hasKeys: + if regenerate && hasKeys { + slog.Warn("cross-signing: CROSS_SIGNING_REGENERATE set — replacing the published identity; every user must verify the bot once more. Unset it after this start.") + } + key, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, uiaSession, "") + if err != nil { + slog.Warn("cross-signing: key upload failed, bot will show unverified", "err", err) + return + } + saveRecoveryKey(dataDir, key) + if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil { + slog.Warn("cross-signing: sign own device failed", "err", err) + } + if err := mach.SignOwnMasterKey(ctx); err != nil { + slog.Warn("cross-signing: sign master key failed", "err", err) + } + slog.Info("cross-signing: identity created and device signed") + + case isVerified: + slog.Info("cross-signing: identity already published and this device is signed") + + case stored != "": + // Pulls the private keys back out of SSSS, then signs this device and the + // master key with them. + if err := mach.VerifyWithRecoveryKey(ctx, stored); err != nil { + slog.Warn("cross-signing: recovery-key restore failed, bot will show unverified", "err", err) + return + } + slog.Info("cross-signing: device re-signed from the stored recovery key") + + default: + slog.Warn("cross-signing: this device is unsigned and no recovery key is stored, so the bot will show unverified (E2EE still works). Set CROSS_SIGNING_REGENERATE=1 once to mint a fresh identity.", + "store", crossSigningPath(dataDir)) + } +} diff --git a/internal/bot/crosssigning_test.go b/internal/bot/crosssigning_test.go new file mode 100644 index 0000000..6e7d0c7 --- /dev/null +++ b/internal/bot/crosssigning_test.go @@ -0,0 +1,56 @@ +package bot + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRecoveryKeyRoundTrip(t *testing.T) { + dir := t.TempDir() + if got := loadRecoveryKey(dir); got != "" { + t.Fatalf("fresh install returned %q, want empty", got) + } + + const key = "EsTd o49d mMLt 3Uf9 Gjn9 x5fv YE9H wF6n aadC q2D8 Fv7j rQ4c" + saveRecoveryKey(dir, key) + if got := loadRecoveryKey(dir); got != key { + t.Fatalf("loadRecoveryKey() = %q, want %q", got, key) + } +} + +// The key is crypto material sitting next to crypto.db; it must not be readable +// by other users on the host. +func TestRecoveryKeyFileIsPrivate(t *testing.T) { + dir := t.TempDir() + saveRecoveryKey(dir, "some-key") + info, err := os.Stat(filepath.Join(dir, crossSigningFile)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("recovery key file mode = %o, want 600", perm) + } +} + +// A corrupt store must degrade to "no key" rather than panicking or returning +// garbage that would be fed to VerifyWithRecoveryKey. +func TestRecoveryKeyCorruptStore(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, crossSigningFile), []byte("{not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if got := loadRecoveryKey(dir); got != "" { + t.Fatalf("corrupt store returned %q, want empty", got) + } +} + +// Overwriting an adopted key must not leave trailing bytes from the longer value. +func TestRecoveryKeyOverwrite(t *testing.T) { + dir := t.TempDir() + saveRecoveryKey(dir, "a-much-longer-original-recovery-key-value") + saveRecoveryKey(dir, "short") + if got := loadRecoveryKey(dir); got != "short" { + t.Fatalf("loadRecoveryKey() = %q, want %q", got, "short") + } +}