fix(ws): hard-deny inbox:* / typing:* when authX is empty

The WS topic-auth check had a soft-fail fallback: if the authenticated
identity had no registered X25519 public key (authX == ""), the
topic-ownership check was skipped and the client could subscribe to
any inbox:* or typing:* topic. Exploit: register an Ed25519 identity
without an X25519 key, subscribe to the victim's inbox topic, receive
their envelope notifications.

Now both topics hard-require a registered X25519. Clients must call
REGISTER_KEY (publishing X25519) before subscribing. The scope is
narrow — only identities that haven't completed REGISTER_KEY yet could
have exploited this — but a hard fail is still correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 17:55:11 +03:00
parent 8082dd0bf7
commit 15d0ed306b

View File

@@ -521,13 +521,17 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" { if authed == "" {
return fmt.Errorf("inbox:* requires auth") return fmt.Errorf("inbox:* requires auth")
} }
// If we have an x25519 mapping, enforce it; otherwise accept // Hard-require a registered X25519 identity — otherwise an
// (best-effort — identity may not be registered yet). // Ed25519-only identity could subscribe to ANY inbox topic by
if authX != "" { // design (authX == "" skipped the equality check). Fixed: we
want := strings.TrimPrefix(topic, "inbox:") // now refuse the subscription until the client publishes an
if want != authX { // X25519 key via REGISTER_KEY.
return fmt.Errorf("inbox:* only for your own x25519") if authX == "" {
} return fmt.Errorf("inbox:* requires a registered X25519 identity (send REGISTER_KEY first)")
}
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
} }
return nil return nil
} }
@@ -536,11 +540,12 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" { if authed == "" {
return fmt.Errorf("typing:* requires auth") return fmt.Errorf("typing:* requires auth")
} }
if authX != "" { if authX == "" {
want := strings.TrimPrefix(topic, "typing:") return fmt.Errorf("typing:* requires a registered X25519 identity")
if want != authX { }
return fmt.Errorf("typing:* only for your own x25519") want := strings.TrimPrefix(topic, "typing:")
} if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
} }
return nil return nil
} }