Compare commits

...

2 Commits

Author SHA1 Message Date
prosolis
7b76f9ed23 Fix Matrix E2EE: stop double-login and make cross-signing bootstrap reachable
Two bugs prevented Pete's device from ever cross-signing itself:

1. Double login / split-brain: New() did a manual mx.Login() AND set
   ch.LoginAs, so cryptohelper.Init() logged in a second time on a fresh
   crypto store, minting a separate device. device.json recorded one device
   while the olm account belonged to another, and every cold start leaked an
   orphan device. Pete's outgoing events were attributed to a device whose
   keys no client could verify. Removed ch.LoginAs (auth is already handled by
   the manual login + isTokenValid re-login path).

2. Unreachable bootstrap: the reset gate used
   IsDeviceTrusted(mach.OwnIdentity()), but OwnIdentity() hard-codes
   Trust=TrustStateVerified, so it always returns true and the bootstrap/reset
   branch was never entered. Replaced with GetOwnVerificationStatus(), which
   actually checks whether the current device key is signed by our
   self-signing key.
2026-06-05 11:35:29 -07:00
prosolis
0b94ce7fe5 Add Finance channel (web-only) with markets/macro feeds
New web-only category mirroring the EU channel: shows in the web UI but
does not post to Matrix. Adds the channel entry, emerald (money green)
theme utilities, and two macro/finance feeds (Naked Capitalism, Wolf
Street) to the example config.
2026-06-01 17:05:16 -07:00
5 changed files with 55 additions and 18 deletions

View File

@@ -157,3 +157,19 @@ tier = 1
poll_interval_minutes = 60
direct_route = "music"
enabled = true
[[sources]]
name = "Naked Capitalism"
feed_url = "https://www.nakedcapitalism.com/feed"
tier = 1
poll_interval_minutes = 60
direct_route = "finance"
enabled = true
[[sources]]
name = "Wolf Street"
feed_url = "https://wolfstreet.com/feed/"
tier = 1
poll_interval_minutes = 60
direct_route = "finance"
enabled = true

View File

@@ -13,8 +13,8 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"path/filepath"
"strings"
"sync"
"time"
@@ -142,30 +142,45 @@ func New(cfg config.MatrixConfig) (*Client, error) {
"room", evt.RoomID, "event_id", evt.ID, "sender", evt.Sender, "err", err)
}
// LoginAs enables the cryptohelper to re-login if the token expires
ch.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{
Type: mautrix.IdentifierTypeUser,
User: cfg.UserID,
},
Password: cfg.Password,
InitialDeviceDisplayName: cfg.DisplayName,
}
// IMPORTANT: do NOT set ch.LoginAs here. We already performed an explicit
// password login above (or restored a saved token), so mx is fully
// authenticated against device.json's device. When LoginAs is set AND the
// crypto store is fresh, cryptohelper.Init() performs a SECOND login
// (StoreCredentials=true), minting a separate device and pinning the olm
// account to it — while device.json still records the first device. That
// split-brain is exactly the "device never verifies" bug: the device the
// token authenticates as is not the device the crypto account belongs to,
// and each cold start leaks another orphan device. Token-expiry recovery is
// already handled by the isTokenValid() check + re-login path above.
if err := ch.Init(context.Background()); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
mx.Crypto = ch
// Bootstrap cross-signing only if not already set up.
// Ensure our CURRENT device is cross-signed. We bootstrap (generate + upload
// fresh cross-signing keys, then self-sign) whenever either no keys exist yet,
// or keys exist on the server but our device is not signed by them. The latter
// is exactly the post-wipe state: when crypto.db is deleted the private
// self-signing key is lost (it is never persisted locally without an SSSS
// passphrase, and we discard the random recovery key), so the lingering server
// master key is useless to us and must be replaced.
//
// CRITICAL: do NOT gate this on IsDeviceTrusted(mach.OwnIdentity()).
// OwnIdentity() hard-codes Trust=id.TrustStateVerified, and ResolveTrustContext
// short-circuits on that, so the call is a tautology that ALWAYS returns true —
// it makes this branch unreachable and the device never gets signed.
// GetOwnVerificationStatus does a real check: is our own device key signed by
// our self-signing key?
mach := ch.Machine()
existingKeys, err := mach.GetOwnCrossSigningPublicKeys(context.Background())
hasKeys, deviceCrossSigned, err := mach.GetOwnVerificationStatus(context.Background())
if err != nil {
slog.Warn("cross-signing: failed to fetch existing keys", "err", err)
slog.Warn("cross-signing: failed to check verification status", "err", err)
}
if existingKeys == nil || existingKeys.MasterKey == "" {
if !hasKeys || !deviceCrossSigned {
if hasKeys {
slog.Warn("cross-signing: keys exist but current device is not cross-signed, resetting cross-signing")
}
_, _, err = mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{
"type": mautrix.AuthTypePassword,
@@ -195,7 +210,7 @@ func New(cfg config.MatrixConfig) (*Client, error) {
slog.Info("cross-signing: master key signed")
}
} else {
slog.Info("cross-signing: already configured, skipping bootstrap")
slog.Info("cross-signing: already configured and current device cross-signed, skipping bootstrap")
}
slog.Info("E2EE initialized",

View File

@@ -39,6 +39,7 @@ var channels = []Channel{
{Slug: "anime", Title: "Anime", Theme: "anime", Emoji: "🌸", Blurb: "Series, studios, and the wider world of anime and manga."},
{Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."},
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.

View File

@@ -77,6 +77,7 @@ html[data-phase="night"] {
.bg-theme-anime { background-color: #ec5e8a; }
.bg-theme-foss { background-color: #d97706; }
.bg-theme-kids { background-color: #14b8a6; }
.bg-theme-finance { background-color: #10b981; }
.text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; }
@@ -86,6 +87,7 @@ html[data-phase="night"] {
.text-theme-anime { color: #c33a6a; }
.text-theme-foss { color: #b8530a; }
.text-theme-kids { color: #0f766e; }
.text-theme-finance { color: #059669; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
@@ -95,6 +97,7 @@ html[data-phase="night"] {
.decoration-theme-anime { text-decoration-color: #ec5e8a; }
.decoration-theme-foss { text-decoration-color: #d97706; }
.decoration-theme-kids { text-decoration-color: #14b8a6; }
.decoration-theme-finance { text-decoration-color: #10b981; }
.border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; }
@@ -104,6 +107,7 @@ html[data-phase="night"] {
.border-theme-anime { border-color: #ec5e8a; }
.border-theme-foss { border-color: #d97706; }
.border-theme-kids { border-color: #14b8a6; }
.border-theme-finance { border-color: #10b981; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@@ -114,6 +118,7 @@ html[data-phase="night"] {
.glow-theme-anime { box-shadow: 0 0 0 2px rgba(236,94,138,0.35), 0 0 24px 4px rgba(236,94,138,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-foss { box-shadow: 0 0 0 2px rgba(217,119,6,0.35), 0 0 24px 4px rgba(217,119,6,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }

File diff suppressed because one or more lines are too long