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.
This commit is contained in:
prosolis
2026-07-09 18:53:23 -07:00
parent ba7b20dfe5
commit 1adcd05dc7
5 changed files with 212 additions and 30 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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")
}
}