v5.6 batch: signed releases + stronger passwords enforced + relay address-proof

The everyone-must-update release. Four coordinated changes, each from the security
discussion Ed approved 2026-07-27, plus the remembered-apps polish:

1. SIGNED RELEASES. build-release.ps1 now signs the release zip (ECDSA P-256 /
   SHA-256, --sign-update verb) with a private key that lives ONLY at Ed's chosen
   location outside the repo; the matching public key is embedded (UpdateSignature)
   and the updater REFUSES any release whose .sig asset is missing or does not
   verify - a compromised GitHub account can no longer ship code to users. The
   signing verb self-checks against the embedded key so a key/embed mismatch fails
   the pipeline, and the gate proves the on-disk key matches the embed when present.

2. STRONGER PASSWORDS, ENFORCED (BREAKING). PBKDF2 raised 100k -> 600k (both peers
   must derive the same key, so 5.6 cannot stream with pre-5.6 AT ALL - release
   notes lead with it). New PasswordStrength rule (>= 8 chars, not an infamous
   password) enforced at EVERY door: both password dialogs block weak NEW entries
   with concrete plain-English advice; the streaming gate walks an existing weak
   password through strengthening; and ForPlainPassword - the single derivation
   choke-point shared with the service - refuses weak outright, so no path streams
   on a guessable password. Headless service logs the why. Per Ed: painful once,
   and this coordinated-update release is the cheapest moment it will ever have.

3. RELAY ADDRESS-PROOF (watch-only). The relay sends every new client address a
   random cookie and marks it verified when echoed - a forged source address can
   never echo, killing the reflection attack. 5.6 clients echo automatically
   (AddrCheck type 10, verbatim, self-limiting); the relay ships watch-only
   (logs would-blocks) until the fleet updates, then one flag (--require-addr-check)
   enforces. Per-IP entry cap (4) enforced immediately. Relay changes are committed
   but NOT deployed to the Pi - they ride the v5.6 release moment.

4. Remembered-apps empty state teaches its lifecycle + manual sentence; About/
   release notes written; version bumped to 5.6.

New gate steps: signing round-trip/tamper/wrong-key/embed-match; password rules incl.
the exact "Games" case; AddrCheck verbatim echo. Gate 69/69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-27 08:19:20 +01:00
co-authored by Claude Fable 5
parent 9574d9d08b
commit 6c53fe54d1
18 changed files with 658 additions and 51 deletions
+149 -6
View File
@@ -71,9 +71,22 @@ TYPE_LOBBY_HELLO = 6
TYPE_LOBBY_ROSTER = 7
TYPE_LOBBY_FULL = 8
TYPE_LOBBY_BYE = 9
# Address-proof challenge (2026-07-27): a random cookie sent to every newly seen client address;
# the client echoes the packet back verbatim, proving the address actually RECEIVES — a forged
# (spoofed) source address can never echo. This is what stops the reflection attack (register a
# victim's spoofed address, then have the relay bounce audio at them). 5.6+ clients echo it;
# older clients drop it as an unknown type, so enforcement (--require-addr-check) stays OFF
# until the fleet has updated — watch-only mode logs who WOULD have been blocked meanwhile.
TYPE_ADDR_CHECK = 10
ADDR_CHECK_COOKIE_LEN = 16
ADDR_CHECK_RESEND_SECONDS = 2.0
V2_FORWARDABLE_TYPES = {
TYPE_FORMAT, TYPE_AUDIO, TYPE_KEEPALIVE, TYPE_HEARTBEAT, TYPE_CONTROL,
}
# Cap on how many lobby/pair entries one source IP may hold at once. Legitimate households behind
# one NAT show a handful of machines (distinct ports, same IP); a lobby-occupation attacker shows
# ten. Enforced immediately — it breaks no working setup.
MAX_ENTRIES_PER_IP = 4
# A zero UUID identifies the server in outbound v2 packets that we originate
# (LobbyRoster, LobbyFull, LobbyBye-from-server). Clients can recognise this
@@ -88,6 +101,11 @@ class PeerSlot:
last_seen: float
rx_packets: int = 0
tx_packets: int = 0
# Address-proof state (see TYPE_ADDR_CHECK).
verified: bool = False
cookie: bytes = b""
cookie_sent: float = 0.0
would_block_logged: bool = False
@dataclass
@@ -98,6 +116,11 @@ class ClientEntry:
last_seen: float
rx_packets: int = 0
tx_packets: int = 0
# Address-proof state (see TYPE_ADDR_CHECK).
verified: bool = False
cookie: bytes = b""
cookie_sent: float = 0.0
would_block_logged: bool = False
@dataclass
@@ -108,6 +131,10 @@ class RelayStats:
rejected_bad_header: int = 0
pair_changes: int = 0 # v1 slot joins/leaves/replacements
lobby_changes: int = 0 # v2 joins/leaves/expiries
addr_checks_verified: int = 0 # cookies echoed back correctly
blocked_unverified: int = 0 # forwards withheld (enforce mode only)
would_block_unverified: int = 0 # forwards that WOULD be withheld (watch-only)
rejected_ip_cap: int = 0 # admissions refused by MAX_ENTRIES_PER_IP
def setup_logger(log_path: str) -> logging.Logger:
@@ -174,10 +201,16 @@ def _encode_lobby_name(name: str) -> bytes:
class Relay:
"""Dispatcher that owns both the v1 pair state and the v2 lobby state."""
def __init__(self, sock: socket.socket, log: logging.Logger, max_clients: int):
def __init__(self, sock: socket.socket, log: logging.Logger, max_clients: int,
require_addr_check: bool = False):
self.sock = sock
self.log = log
self.max_clients = max_clients
# Enforcement switch for the address-proof: False = watch-only (log who WOULD be blocked,
# forward anyway — safe while pre-5.6 clients that can't echo are still around); True =
# withhold all forwarded traffic from unverified addresses. Flipped by --require-addr-check
# in a later server release once the 5.6 auto-update has rolled through.
self.require_addr_check = require_addr_check
# v1 state
self.v1_peers: list[PeerSlot] = []
# v2 state
@@ -188,6 +221,62 @@ class Relay:
self.stats = RelayStats()
self.last_stats_log = time.monotonic()
# ------- address-proof (shared by v1 + v2) -----------------------------
def _addr_check_packet(self, cookie: bytes) -> bytes:
"""A v1-framed AddrCheck: 12-byte RemSound header + the cookie. v1 framing on purpose —
every client (v1 pair or v2 lobby) parses it, and the echo comes back the same way."""
header = bytearray(V1_HEADER_LEN)
header[0:4] = MAGIC
header[4] = V1_VERSION
header[5] = TYPE_ADDR_CHECK
return bytes(header) + cookie
def _send_addr_check(self, entry, addr: tuple[str, int], now: float) -> None:
"""Issue (or re-issue) the cookie challenge for an entry. Throttled; keeps the same cookie
until verified so a slow echo still matches."""
if entry.verified or (now - entry.cookie_sent) < ADDR_CHECK_RESEND_SECONDS:
return
if not entry.cookie:
entry.cookie = os.urandom(ADDR_CHECK_COOKIE_LEN)
entry.cookie_sent = now
try:
self.sock.sendto(self._addr_check_packet(entry.cookie), addr)
except OSError as e:
self.log.warning("event=addr_check_send_failed to=%s err=%s", _fmt_addr(addr), e)
def _try_verify(self, entry, data: bytes, addr: tuple[str, int], header_len: int) -> None:
"""An AddrCheck came back from a registered endpoint — verify its cookie. The echo may
arrive v1-framed (as sent) even from a v2 client, so callers pass their header length."""
cookie = data[header_len:header_len + ADDR_CHECK_COOKIE_LEN]
if entry.cookie and cookie == entry.cookie and not entry.verified:
entry.verified = True
self.stats.addr_checks_verified += 1
self.log.info("event=addr_verified addr=%s", _fmt_addr(addr))
def _ip_at_cap(self, ip: str) -> bool:
"""True when this source IP already holds MAX_ENTRIES_PER_IP lobby/pair entries."""
count = sum(1 for p in self.v1_peers if p.addr[0] == ip)
count += sum(1 for e in self.v2_clients.values() if e.addr[0] == ip)
return count >= MAX_ENTRIES_PER_IP
def _may_forward_to(self, entry, proto: str) -> bool:
"""The enforcement point: may forwarded traffic be delivered to this entry's address?
Watch-only mode always says yes but logs (once per entry) who WOULD have been blocked."""
if entry.verified:
return True
if self.require_addr_check:
self.stats.blocked_unverified += 1
return False
self.stats.would_block_unverified += 1
if not entry.would_block_logged:
entry.would_block_logged = True
self.log.info(
"event=would_block_unverified proto=%s addr=%s (watch-only; enforcement would withhold traffic)",
proto, _fmt_addr(entry.addr),
)
return True
# ------- v1 (pairwise) -------------------------------------------------
def _v1_find_slot(self, addr: tuple[str, int]) -> Optional[int]:
@@ -243,12 +332,29 @@ class Relay:
return -1
def _handle_v1(self, data: bytes, addr: tuple[str, int]) -> None:
if parse_header_v1(data) is None:
parsed = parse_header_v1(data)
if parsed is None:
self.stats.rejected_bad_header += 1
return
pkt_type = parsed[0]
now = time.monotonic()
if pkt_type == TYPE_ADDR_CHECK:
# A cookie coming home. Echoes come back v1-framed regardless of the client's protocol
# (clients echo our framing verbatim), so match by ADDRESS across BOTH protocol states
# — and never ADMIT anyone off one: an AddrCheck is proof, not a join request.
for e in self.v2_clients.values():
if e.addr == addr:
self._try_verify(e, data, addr, V1_HEADER_LEN)
return
found = self._v1_find_slot(addr)
if found is not None:
self._try_verify(self.v1_peers[found], data, addr, V1_HEADER_LEN)
return
idx = self._v1_find_slot(addr)
if idx is None:
if self._ip_at_cap(addr[0]):
self.stats.rejected_ip_cap += 1
return
self._v1_expire_idle(now)
idx = self._v1_admit_or_replace(addr, now)
if idx < 0:
@@ -257,8 +363,11 @@ class Relay:
peer = self.v1_peers[idx]
peer.last_seen = now
peer.rx_packets += 1
self._send_addr_check(peer, addr, now)
if len(self.v1_peers) == 2:
other = self.v1_peers[1 - idx]
if not self._may_forward_to(other, "v1"):
return
try:
self.sock.sendto(data, other.addr)
other.tx_packets += 1
@@ -298,6 +407,10 @@ class Relay:
return
packet = self._v2_build_roster_packet()
for entry in self.v2_clients.values():
# Under enforcement even the roster stays away from unverified addresses — it's
# relay-originated traffic too, and it grows with the lobby. (Watch-only: send.)
if self.require_addr_check and not entry.verified:
continue
try:
self.sock.sendto(packet, entry.addr)
except OSError as e:
@@ -370,6 +483,12 @@ class Relay:
from_registered_endpoint = entry is not None and entry.addr == addr
if entry is None:
# Admit attempt.
if self._ip_at_cap(addr[0]):
self.stats.rejected_ip_cap += 1
self.log.warning(
"event=join_rejected reason=ip_cap client_id=%s addr=%s", client_id, _fmt_addr(addr),
)
return
if len(self.v2_clients) >= self.max_clients:
self._v2_send_lobby_full(client_id, addr)
return
@@ -382,15 +501,28 @@ class Relay:
self.stats.lobby_changes += 1
self.v2_roster_dirty = True
else:
# Refresh endpoint (handles NAT rebind) and last-seen.
# Refresh endpoint (handles NAT rebind) and last-seen. A MOVED endpoint must re-prove
# itself — the new address hasn't echoed anything yet, and "rebind" is also exactly
# what a spoofed takeover of a known client_id looks like.
if entry.addr != addr:
self.log.info(
"event=client_endpoint_update client_id=%s old=%s new=%s",
client_id, _fmt_addr(entry.addr), _fmt_addr(addr),
)
entry.addr = addr
entry.verified = False
entry.cookie = b""
entry.cookie_sent = 0.0
entry.would_block_logged = False
entry.last_seen = now
entry.rx_packets += 1
if pkt_type == TYPE_ADDR_CHECK:
# The cookie coming home (the client echoes our v1-framed challenge, so it can land in
# the v2 handler only if the client wrapped it v2 — accept both framings). Never forward.
header_len = V2_HEADER_LEN if len(data) >= V2_HEADER_LEN + ADDR_CHECK_COOKIE_LEN else V1_HEADER_LEN
self._try_verify(entry, data, addr, header_len)
return
self._send_addr_check(entry, addr, now)
# Type-specific handling.
if pkt_type == TYPE_LOBBY_HELLO:
@@ -424,10 +556,12 @@ class Relay:
# Unknown / server-originated type from a client. Ignore quietly.
return
# Fan-out forwarding to every OTHER client.
# Fan-out forwarding to every OTHER client (verified addresses only, once enforcing).
for other_id, other in self.v2_clients.items():
if other_id == client_id:
continue
if not self._may_forward_to(other, "v2"):
continue
try:
self.sock.sendto(data, other.addr)
other.tx_packets += 1
@@ -506,6 +640,14 @@ def main() -> int:
help=f"v2 lobby capacity (default {DEFAULT_MAX_CLIENTS}, "
"overridable via REMSOUND_MAX_CLIENTS env var)",
)
parser.add_argument(
"--require-addr-check",
action="store_true",
default=os.environ.get("REMSOUND_REQUIRE_ADDR_CHECK", "") == "1",
help="enforce the address-proof cookie: forwarded traffic is withheld from addresses that "
"have not echoed their cookie (default off = watch-only, which only logs). Flip on "
"once the 5.6+ client rollout is complete - pre-5.6 clients cannot echo.",
)
args = parser.parse_args()
if args.max_clients < 2:
sys.stderr.write("remsound-relay: --max-clients must be >= 2\n")
@@ -513,8 +655,9 @@ def main() -> int:
log = setup_logger(args.log_path)
log.info(
"event=startup version_supported=v1,v2 listen=%s:%d max_clients=%d",
"event=startup version_supported=v1,v2 listen=%s:%d max_clients=%d addr_check=%s",
args.host, args.port, args.max_clients,
"ENFORCED" if args.require_addr_check else "watch-only",
)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -525,7 +668,7 @@ def main() -> int:
log.error("event=bind_failed err=%s", e)
return 1
relay = Relay(sock, log, args.max_clients)
relay = Relay(sock, log, args.max_clients, require_addr_check=args.require_addr_check)
stop_flag = {"stop": False}
def _stop_signal(_signum, _frame):