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
+18 -19
View File
@@ -1,32 +1,31 @@
# RemSound v5.5
# RemSound v5.6
A close-hang fix and a keyboard-accessibility sweep.
**IMPORTANT: everyone must update.** This version strengthens the encryption maths, so a 5.6 machine **cannot exchange audio with any older RemSound** — until both sides are on 5.6, you'll hear nothing between them. Update every machine you connect with, including any running the background service (it updates itself from the app). Remote volume commands also need both ends on 5.6.
## Fixed: a hang on close when UPnP is on
## Stronger passwords, enforced
With the "open my router's port automatically" (UPnP) option turned on, closing RemSound could **hang instead of shutting**. On the way out RemSound asks the router to close the port again, and that request waits for the router to answer — a slow or fussy router (or a double-NAT setup) left the window stuck.
The profile password is what protects your audio, so from this version RemSound refuses to stream on one that's easy to guess. Passwords must be at least 8 characters and not a common word — if yours is shorter, RemSound tells you the moment you try to stream and walks you through choosing a better one. Three unrelated words with a number, like `kettle9tiger42moon`, is easy to type and very hard to guess. Remember to set the **same** new password on every machine you connect with.
Closing now **never waits on the router**: the tidy-up runs in the background with a short time limit, so RemSound shuts straight away and lets the router expire the port on its own if it's being slow. (Under the hood the app also logs each router step now, so if anything ever does stall we can see exactly which call was slow.)
## Signed updates
## Keyboard shortcuts on every dialog
Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us — so even if the download page were ever tampered with, a fake update could not install itself on your machine. (This release ships the checking code; it protects every release from here on.)
Every button in every pop-up dialog now has an **Alt-key shortcut** — including the "RemSound is already running" dialog, which previously had none at all. So you can always pick an option straight from the keyboard rather than tabbing to it. (Where a dialog's Cancel would clash with another shortcut, Cancel stays on Escape, as before.)
## Remote volume, now password-locked
## Compatibility
The remote volume and mute commands are sealed with your profile password, so only someone who knows it can adjust your machine — nobody on the network can fake a command and mute your screen reader. A captured command can't be replayed later either.
Nothing about the over-the-network format changed, so **v5.5 talks to v3.3 through v5.4** with no trouble — you don't have to update both ends at once. (Everyone still needs **v3.3 or newer**, where end-to-end encryption came in.)
## Set the machine's volume when the service starts
## Install
In the service's **Additional options** you can have an unattended machine unmute itself and set its Windows volume to a level you choose — on the first start after each boot, or on every service start.
1. Download `RemSound-v5.5.zip` from this release.
2. Close RemSound.
3. Extract the zip **over your existing RemSound folder**, overwriting program files when prompted. The zip is program files only — it won't touch your settings, profiles, logs or recordings.
4. Run `RemSound.exe`.
## Updates on your schedule
## Upgrading
In Preferences you can restrict automatic updates to a daily time range — say 01:00 to 06:00 — so an update never closes RemSound and kills your sound mid-session. Found outside the range, it quietly waits and installs the moment the range opens. The manual "Check for updates now" button is never restricted.
**From v3.6 or newer:** Help → Check for updates installs v5.5 with the in-app updater — and if it can't finish, it puts your old version back exactly as it was.
## Also in this release
**From v1.9v3.5:** Check for updates works, but uses your current version's older updater for this one hop. If auto-update has been failing on your machine, install by hand using the steps above.
**v1.8 and earlier:** the auto-updater in those versions can't install updates — install by hand using the steps above.
- The app releases its high-priority and keep-awake settings when you're not actually streaming — kinder to laptops left idling in the tray.
- Diagnostic logs cap their own size on long sessions, and old crash reports are tidied automatically.
- The remembered-applications list explains itself when empty (apps join it the moment you tick them).
- The public relay gained anti-abuse protections; a later relay update will require 5.6, which answers its address checks automatically.
- A large amount of security hardening from a full audit: service-folder lockdown, replay protection, counter-based encryption nonces, and more.
+16 -2
View File
@@ -204,6 +204,7 @@ if ($bad.Count -gt 0) {
# 4. Zip it. Keep dist/ to a single artefact — drop any prior versioned zip.
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
Get-ChildItem -Path $distDir -Filter 'RemSound-v*.zip' -ErrorAction SilentlyContinue | Remove-Item -Force
Get-ChildItem -Path $distDir -Filter 'RemSound-v*.zip.sig' -ErrorAction SilentlyContinue | Remove-Item -Force
Compress-Archive -Path (Join-Path $staging '*') -DestinationPath $zipPath -CompressionLevel Optimal -Force
# 5. SAFETY CHECK again, on the finished zip itself — belt and braces.
@@ -225,10 +226,23 @@ if ($leaked.Count -gt 0) {
}
Remove-Item $staging -Recurse -Force
# 6. SIGN the zip (2026-07-27). The updater REFUSES any release without a valid signature, so an
# unsigned zip would be rejected by every 5.6+ install - failing the pipeline here is the kind
# failure. --sign-update signs with the private key (outside the repo) and self-checks against
# the public key embedded in this very build, so a key/embed mismatch also stops the release.
$sigPath = "$zipPath.sig"
& (Join-Path $repo 'publish\RemSound.exe') --sign-update $zipPath | Write-Host
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $sigPath)) {
Write-Host "RELEASE ABORTED - could not sign the zip (see message above). Nothing published." -ForegroundColor Red
exit 1
}
Write-Host "Signed: $sigPath" -ForegroundColor Green
$size = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
Write-Host ""
Write-Host "OK - clean release zip verified: $zipPath ($size MB, $entryCount entries)" -ForegroundColor Green
Write-Host " No logs / profiles / recordings / config present." -ForegroundColor Green
Write-Host ""
Write-Host "Next:" -ForegroundColor Cyan
Write-Host " gh release create $Tag `"$zipPath`" --title `"RemSound $Tag`" --notes-file RELEASE_NOTES.md"
Write-Host "Next (the .sig asset MUST ship with the zip - updaters refuse a release without it):" -ForegroundColor Cyan
Write-Host " gh release create $Tag `"$zipPath`" `"$sigPath`" --title `"RemSound $Tag`" --notes-file RELEASE_NOTES.md"
+3 -1
View File
@@ -466,7 +466,7 @@ ul, ol { padding-left: 1.4em; }
<table>
<tr><th>List</th><th>What it holds</th></tr>
<tr><td><strong>Currently active applications (Alt+8)</strong></td><td>Every program making sound right now. Tick one and only that program's audio is captured and sent &mdash; its own private stream, separate from everything else on the machine. Tick several to send several. A program you've ticked that isn't running at the moment still shows here marked <em>(not running)</em>, so you can always find it and untick it; it starts being sent again the instant it reopens.</td></tr>
<tr><td><strong>Remembered applications (Alt+9)</strong></td><td>Your saved &ldquo;apps I send&rdquo; address book &mdash; shared across all your profiles, like the remembered peers list. Tick a program here and it moves up to the active list the moment it's running (and is captured from its very first sound). Untick a program in either list and it drops back here. Press <strong>Delete</strong> on an entry to forget it, just like the remembered peers list.</td></tr>
<tr><td><strong>Remembered applications (Alt+9)</strong></td><td>Your saved &ldquo;apps I send&rdquo; address book &mdash; shared across all your profiles, like the remembered peers list. A program joins this book the moment you first <em>tick</em> it in either list &mdash; that's the only way in, so after clearing the book it simply refills as you tick apps again. Tick a program here and it moves up to the active list the moment it's running (and is captured from its very first sound). Untick a program in either list and it drops back here. Press <strong>Delete</strong> on an entry to forget it, just like the remembered peers list.</td></tr>
</table>
<p>Because sending is by program <em>name</em>, your choice survives that program being closed and reopened, or even the computer restarting. There is deliberately no &ldquo;send everything&rdquo; option in applications mode &mdash; if you want the whole machine's sound, that's what <em>Send whole audio devices</em> is for.</p>
@@ -742,6 +742,8 @@ Audient USB Audio ASIO Driver &mdash; Pair 3 (channels 5/6): Loop-back 1 (L) / L
<p>If you try to start sending or receiving on a profile that has no password yet, RemSound asks you to set one first (and offers to remember it on the profile so you don't type it again next time). Audio can't flow without a password &mdash; encryption is always on, there's no &ldquo;off&rdquo; switch.</p>
<p><strong>Passwords must be reasonably strong (new in 5.6).</strong> The password is the only thing protecting your audio from someone who records your network traffic, so RemSound now refuses to stream on one that's easy to guess: at least 8 characters, and not a famously common password. If your existing password doesn't meet the rule, RemSound tells you the moment you try to stream and walks you through picking a better one &mdash; three unrelated words with a number, like <code>kettle9tiger42moon</code>, is easy to type and remember and very hard to guess. Set the <em>same</em> new password on every machine you connect with. One honest note: the password is stored in the profile file in a recoverable form (so profiles can sync between your own machines) &mdash; anyone who can read your profiles folder can read the passwords, so treat that folder accordingly.</p>
<h3>When passwords don't match</h3>
<p>If you connect to someone whose password is different from yours, RemSound shows a clear message &mdash; <em>&ldquo;You and [name] have different passwords, so no audio will pass between you&rdquo;</em> &mdash; so you know exactly what to fix. If the other person is on an older version of RemSound that can't encrypt, you'll be told they need to update.</p>
+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):
+16
View File
@@ -20,6 +20,22 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v5.6
IMPORTANT: everyone must update. This version strengthens the encryption maths, so a 5.6 machine cannot exchange audio with any older RemSound until both sides are on 5.6, you'll hear nothing between them. Update every machine you connect with, including any running the background service (it updates itself from the app). Remote volume commands also need both ends on 5.6.
Stronger passwords, enforced. The profile password is what protects your audio, so from this version RemSound refuses to stream on one that's easy to guess. Passwords must be at least 8 characters and not a common word if yours is shorter, RemSound tells you the moment you try to stream and walks you through choosing a better one. Three unrelated words with a number, like kettle9tiger42moon, is easy to type and very hard to guess. Remember to set the SAME new password on every machine you connect with.
Signed updates. Every release is now digitally signed, and the updater refuses anything that isn't genuinely from us so even if our download page were ever tampered with, a fake update could not install itself on your machine.
Remote volume, now password-locked. The remote volume and mute commands are sealed with your profile password, so only someone who knows it can adjust your machine nobody on the network can fake a command and mute your screen reader.
Set the machine's volume when the service starts. In the service's Additional options you can have an unattended machine unmute itself and set its Windows volume to a level you choose on the first start after each boot, or on every service start.
Updates on your schedule. In Preferences you can now restrict automatic updates to a time range say 1am to 6am so an update never closes RemSound and kills your sound mid-session. Found outside the range, it quietly waits and installs the moment the range opens.
Plus: the app now releases its high-priority and keep-awake settings when you're not actually streaming (kinder to laptops), diagnostic logs cap their own size on long sessions, the remembered-applications list explains itself when empty, and a raft of security hardening under the hood.
RemSound v5.5
A close-hang fix, and keyboard shortcuts everywhere.
+48
View File
@@ -94,6 +94,10 @@ internal static class CommandLine
return WithConsole(() => SetLogging(ValueAfter(args, raw)));
case "--close": case "--quit":
return WithConsole(CloseRunning);
case "--sign-update":
// Publish-pipeline verb (build-release.ps1): sign a release zip with the private
// key so the updater's signature enforcement accepts it. Not a user command.
return WithConsole(() => SignUpdate(ValueAfter(args, raw)));
}
}
@@ -117,6 +121,50 @@ internal static class CommandLine
return null;
}
/// <summary>Where the release-signing PRIVATE key lives on the publisher's machine (chosen by
/// Ed, 2026-07-27). Overridable via REMSOUND_SIGNING_KEY for a future move. The key is never
/// in the repo or a release; the matching public key is embedded (UpdateSignature).</summary>
private static string SigningKeyPath =>
Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY")
?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem";
/// <summary>--sign-update &lt;zip&gt;: write &lt;zip&gt;.sig (base64 ECDSA P-256 / SHA-256 over the
/// zip bytes) and self-check it against the EMBEDDED public key before reporting success — so a
/// key/embed mismatch fails the publish pipeline loudly instead of shipping a release every
/// updater would refuse.</summary>
private static int SignUpdate(string? zipPath)
{
if (string.IsNullOrWhiteSpace(zipPath) || !File.Exists(zipPath))
{
Console.WriteLine($"sign-update: zip not found: [{zipPath}]");
return 2;
}
if (!File.Exists(SigningKeyPath))
{
Console.WriteLine($"sign-update: signing key not found at [{SigningKeyPath}] (set REMSOUND_SIGNING_KEY to override)");
return 3;
}
try
{
var bytes = File.ReadAllBytes(zipPath);
var signature = UpdateSignature.SignWithKey(bytes, File.ReadAllText(SigningKeyPath));
if (!UpdateSignature.Verify(bytes, signature))
{
Console.WriteLine("sign-update: FAILED self-check — the private key does not match the public key embedded in this build. Update UpdateSignature.PublicKeyPem or restore the right key file.");
return 4;
}
var sigPath = zipPath + UpdateSignature.SignatureAssetSuffix;
File.WriteAllText(sigPath, signature);
Console.WriteLine($"sign-update: OK — wrote {sigPath} (verified against the embedded public key)");
return 0;
}
catch (Exception ex)
{
Console.WriteLine($"sign-update: FAILED — {ex.GetType().Name}: {ex.Message}");
return 5;
}
}
// ---------------- console plumbing ----------------
/// <summary>Attach to the calling terminal (when launched from one), point Console.Out at the
+70 -6
View File
@@ -5733,6 +5733,15 @@ public sealed partial class MainForm : Form
// sees the Control packet, parses it, and fires this delegate. We marshal back
// onto the UI thread to mutate volumeBar / mute state.
receiver.OnRemoteControlReceived = HandleRemoteControlPacket;
// Relay address-proof (2026-07-27): echo the relay's cookie back verbatim so it can
// verify this address really receives — the proof that keeps us forwardable once the
// relay enforces. Echo-to-source is self-limiting (one small reply per challenge,
// never larger than what arrived), so answering unconditionally is safe.
receiver.OnAddrCheckReceived = (packet, length, remote) =>
{
try { sender.SendVia(packet, length, remote); }
catch (Exception ex) { logFile.Event($"addr-check echo to {remote} failed: {ex.GetType().Name}: {ex.Message}"); }
};
heartbeatService.Start();
}
catch (Exception ex)
@@ -8279,6 +8288,31 @@ public sealed partial class MainForm : Form
// Key + fingerprint always together, through the one shared rule (same as the service).
(currentAudioKey, currentAudioFingerprint) = RemSoundCrypto.ForPlainPassword(currentProfilePassword);
lastDerivedPassword = currentProfilePassword;
// Since 5.6 that rule also refuses a WEAK password (key comes back null), so a profile
// that auto-connects at startup with an old guessable password must not just sit
// silently dead — say why, once, and point at the fix. The interactive tick path has
// its own guided flow (EnsureStreamingPassword); this catches every other route in.
if (!string.IsNullOrEmpty(currentProfilePassword) && currentAudioKey is null && !weakPasswordExplained)
{
weakPasswordExplained = true;
logFile.Event("audio crypto: profile password fails the 5.6 strength rule — no audio until it's changed");
var advice = PasswordStrength.Critique(currentProfilePassword) ?? "";
BeginInvoke(() =>
{
var page = new TaskDialogPage
{
Caption = "Password needs strengthening",
Heading = "No audio until this profile's password is stronger",
Text = "From this version, RemSound refuses to stream on a password that's easy to guess. "
+ advice + " Change it via the File menu, “Change this profile's password” — on every machine that uses it.",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page));
});
}
}
sender.AudioKey = currentAudioKey;
sender.AudioFingerprint = currentAudioFingerprint;
@@ -8286,20 +8320,45 @@ public sealed partial class MainForm : Form
receiver.AudioFingerprint = currentAudioFingerprint;
}
// One-shot flag for the weak-password explanation above — the dialog must not re-fire on every
// profile reapply within a session (the log line still records each derivation refusal).
private bool weakPasswordExplained;
/// <summary>The "you need a password before any audio can flow" gate. Called when the user
/// ticks Send my audio or Receive audio. If the active profile has no password, prompt for
/// one; if they give one, set it (and offer to save it to the profile); if they cancel,
/// un-tick the box. Returns true if streaming may proceed.</summary>
/// one. Since 5.6 (Ed, 2026-07-27) an EXISTING password that fails the strength rule is gated
/// the same way: the user is told why, in plain words, and audio waits until a stronger one is
/// set — grandfathering weak passwords forever would have made the derivation-cost raise
/// theatre, and everyone is already coordinating a password-compatible update in this release.
/// If they give an acceptable password, set it (and offer to save it to the profile); if they
/// cancel, un-tick the box. Returns true if streaming may proceed.</summary>
private bool EnsureStreamingPassword(AccessibleCheckBox box)
{
if (!box.Checked) return true; // turning OFF never needs a password
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
var weakAdvice = PasswordStrength.Critique(currentProfilePassword ?? "");
if (!string.IsNullOrEmpty(currentProfilePassword) && weakAdvice is null) return true; // have one and it passes
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true);
if (weakAdvice is not null && !string.IsNullOrEmpty(currentProfilePassword))
{
// Tell the user WHY the password prompt is about to appear with their old password in
// it — a bare dialog would read as a bug to someone whose password worked yesterday.
var page = new TaskDialogPage
{
Caption = "Password needs strengthening",
Heading = "Your profile password is too easy to guess",
Text = $"From this version, audio won't flow until the password is stronger. {weakAdvice}",
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page));
}
var entered = ProfilePasswordDialog.Show(label, currentProfilePassword ?? "", requireNonEmpty: true, requireStrong: true);
if (string.IsNullOrEmpty(entered))
{
// No password → can't stream. Put the box back without re-firing this gate.
// No acceptable password → can't stream. Put the box back without re-firing this gate.
suppressStreamingPasswordGate = true;
try { box.Checked = false; }
finally { suppressStreamingPasswordGate = false; } // a throw must not disable the gate for good
@@ -9593,7 +9652,12 @@ public sealed partial class MainForm : Form
{
if (list.Items.Count == 0)
{
var emptyText = $"No {itemKind}s available.";
// The remembered-apps list teaches its own lifecycle when empty (Ed, 2026-07-27: after
// clearing it he had no way to know how entries come back — they arrive when you TICK
// an app, so the empty state says exactly that, right where the question arises).
var emptyText = itemKind == "remembered application"
? "No remembered application. Tick an application in the list above and it will be remembered here."
: $"No {itemKind}s available.";
statusLabel.Text = emptyText;
list.AccessibleDescription = emptyText;
return;
+30 -4
View File
@@ -13,9 +13,9 @@ namespace RemSound.App;
/// </summary>
internal static class ProfilePasswordDialog
{
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
{
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty);
var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty, requireStrong);
using (dialog)
{
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
@@ -30,7 +30,7 @@ internal static class ProfilePasswordDialog
/// <summary>Construction split from ShowDialog so the accessibility audit can inspect the real
/// dialog (inline-built dialogs used to be invisible to the audit).</summary>
internal static (Form Dialog, TextBox Input) Build(string profileTitle, string currentPassword, bool requireNonEmpty = false)
internal static (Form Dialog, TextBox Input) Build(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false)
{
var dialog = new Form
{
@@ -74,7 +74,8 @@ internal static class ProfilePasswordDialog
// entered nothing, pressed OK", which used to silently leave audio dead.
void TryAccept()
{
if (requireNonEmpty && textBox.Text.Trim().Length == 0)
var entered = textBox.Text.Trim();
if (requireNonEmpty && entered.Length == 0)
{
var page = new TaskDialogPage
{
@@ -91,6 +92,31 @@ internal static class ProfilePasswordDialog
textBox.SelectAll();
return;
}
// Strength gate (2026-07-27, with the derivation-cost raise). Normally NEW or CHANGED
// passwords only — re-accepting the existing password unchanged passes, so an old weak
// password never traps the user inside a casual visit to this dialog. requireStrong is
// the STREAMING gate's mode: there the whole point is that the current password failed
// the rule, so the unchanged exemption is off and a stronger one must be entered before
// audio can flow (Ed, 2026-07-27). The critique text says exactly what to do instead.
if (entered.Length > 0
&& (requireStrong || !string.Equals(entered, currentPassword.Trim(), StringComparison.Ordinal))
&& RemSound.Core.PasswordStrength.Critique(entered) is { } advice)
{
var page = new TaskDialogPage
{
Caption = "Choose a stronger password",
Heading = "That password is too easy to guess",
Text = advice,
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(dialog, page);
textBox.Focus();
textBox.SelectAll();
return;
}
dialog.DialogResult = DialogResult.OK;
dialog.Close();
}
@@ -78,7 +78,38 @@ internal static class ProfilePasswordManagerDialog
rows.Add((title, current, box));
}
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
var okButton = new Button { Text = "&OK", AutoSize = true };
// OK validates by hand (no auto-close DialogResult): every CHANGED, non-empty entry passes
// the same strength gate as the single-password dialog — one rule at every door. Unchanged
// entries always pass (an old weak password is grandfathered until the day it's changed).
okButton.Click += (_, _) =>
{
foreach (var (title, original, box) in rows)
{
var entered = box.Text.Trim();
if (entered.Length > 0
&& !string.Equals(entered, original, StringComparison.Ordinal)
&& PasswordStrength.Critique(entered) is { } advice)
{
var page = new TaskDialogPage
{
Caption = "Choose a stronger password",
Heading = $"The new password for “{title}” is too easy to guess",
Text = advice,
Icon = TaskDialogIcon.Warning,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
TaskDialog.ShowDialog(dialog, page);
box.Focus();
box.SelectAll();
return;
}
}
dialog.DialogResult = DialogResult.OK;
dialog.Close();
};
var cancelButton = new Button { Text = "&Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
var buttons = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(8) };
buttons.Controls.Add(okButton);
+1 -1
View File
@@ -18,7 +18,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>5.5</Version>
<Version>5.6</Version>
</PropertyGroup>
<ItemGroup>
+30 -2
View File
@@ -115,13 +115,18 @@ internal sealed class RemSoundUpdater
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
return new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'.");
}
// The detached signature over the zip (2026-07-27 release signing — see UpdateSignature).
// Recorded here, ENFORCED at install time: a release without a valid signature is refused.
var sigAsset = release.Assets?.FirstOrDefault(a =>
string.Equals(a.Name, expectedAsset + UpdateSignature.SignatureAssetSuffix, StringComparison.OrdinalIgnoreCase));
return new UpdateAvailable(new UpdateInfo(
Tag: release.TagName,
Version: latest,
DownloadUrl: asset.BrowserDownloadUrl,
ReleaseNotes: release.Body ?? "",
ReleaseUrl: release.HtmlUrl ?? ""));
ReleaseUrl: release.HtmlUrl ?? "",
SignatureUrl: sigAsset?.BrowserDownloadUrl));
}
catch (Exception ex)
{
@@ -212,6 +217,28 @@ internal sealed class RemSoundUpdater
await src.CopyToAsync(dst, token).ConfigureAwait(false);
}
// Signature enforcement (2026-07-27): the zip must carry a valid signature by the
// embedded release key, or it is NOT installed — this is what stops a compromised
// release stream (e.g. a hijacked GitHub account) from silently shipping code to
// every user. Missing signature = refused too: every genuine release from 5.6 on is
// signed by build-release.ps1, so "no .sig asset" is itself a red flag, not a legacy
// case (older releases are BELOW this version and the updater never downgrades).
if (string.IsNullOrEmpty(info.SignatureUrl))
{
Log?.Invoke("updater: REFUSED — release has no signature file; a genuine RemSound release always ships one. Install left untouched.");
TryDeleteDirectory(stageRoot);
return false;
}
var signatureBase64 = await http.GetStringAsync(info.SignatureUrl, token).ConfigureAwait(false);
var zipBytes = await File.ReadAllBytesAsync(zipPath, token).ConfigureAwait(false);
if (!UpdateSignature.Verify(zipBytes, signatureBase64))
{
Log?.Invoke("updater: REFUSED — the release signature does not verify (tampered download or not signed by the RemSound release key). Install left untouched.");
TryDeleteDirectory(stageRoot);
return false;
}
Log?.Invoke("updater: release signature verified");
Log?.Invoke($"updater: extracting to {appDir}");
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true);
@@ -388,7 +415,8 @@ internal sealed record UpdateInfo(
Version Version,
string DownloadUrl,
string ReleaseNotes,
string ReleaseUrl);
string ReleaseUrl,
string? SignatureUrl = null);
/// <summary>Discriminated result of an update check. Replaces the v3.1.x-and-earlier
/// "UpdateInfo?" return type, which conflated "no newer version available" with "couldn't
+93 -1
View File
@@ -117,6 +117,9 @@ internal static class SelfTest
RunStep(results, "Long-run hygiene (log rotation, crash-report cap, priority-mode scope)", LongRunHygiene);
RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume);
RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow);
RunStep(results, "Release signing (verify, tamper, key-embed match)", ReleaseSigning);
RunStep(results, "Password strength rules (gate + derivation refusal)", PasswordRules);
RunStep(results, "Relay address-proof echo (AddrCheck round-trip)", RelayAddrCheckEcho);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -1541,7 +1544,7 @@ internal static class SelfTest
};
profile.SelectedWasapiSendOutputs.Add("fake-device-id"); // a source so ApplyProfile proceeds
profile.SelectedConnectedPeers.Add("127.0.0.1:47999");
const string pw = "hunter2";
const string pw = "hunter2horse42stable"; // must pass the 5.6 strength rule or ForPlainPassword refuses it
profile.Password = RemSoundCrypto.Obfuscate(pw);
using var host = new ServiceSendHost(() => profile);
@@ -2376,6 +2379,95 @@ internal static class SelfTest
return "sealed + replay/stale/wrong-key/plaintext all rejected; skew tolerated; nonces counter-based";
}
/// <summary>Release signing (2026-07-27): the updater refuses any release zip whose detached
/// signature is missing or wrong. Mechanics proven with an ephemeral keypair (round-trip,
/// tamper, wrong key); the embedded public key must parse; and when the REAL private key is
/// present on this machine (the publisher's), a signature it produces must verify against the
/// embedded key — the mismatch that would make every updater refuse a genuine release.</summary>
private static string? ReleaseSigning()
{
var data = new byte[4096];
new Random(42).NextBytes(data);
using var ephemeral = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
var ephemeralPriv = ephemeral.ExportECPrivateKeyPem();
var ephemeralPub = ephemeral.ExportSubjectPublicKeyInfoPem();
var sig = UpdateSignature.SignWithKey(data, ephemeralPriv);
Check(UpdateSignature.VerifyWithKey(data, sig, ephemeralPub), "a genuine signature must verify");
var tampered = (byte[])data.Clone();
tampered[100] ^= 0x01;
Check(!UpdateSignature.VerifyWithKey(tampered, sig, ephemeralPub), "one flipped byte in the zip must fail verification");
Check(!UpdateSignature.Verify(data, sig), "a signature by a DIFFERENT key must fail against the embedded release key");
Check(!UpdateSignature.VerifyWithKey(data, "not-base64!!", ephemeralPub), "garbage signature text must fail, not throw");
using (var embedded = System.Security.Cryptography.ECDsa.Create())
{
embedded.ImportFromPem(UpdateSignature.PublicKeyPem); // throws (= test fails) if the constant is mangled
}
var realKeyPath = Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY") ?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem";
if (!File.Exists(realKeyPath))
return "mechanics proven with ephemeral key; embedded key parses (publisher key not on this machine — embed-match check skipped)";
var realSig = UpdateSignature.SignWithKey(data, File.ReadAllText(realKeyPath));
Check(UpdateSignature.Verify(data, realSig),
"the on-disk private key MUST match the embedded public key — a mismatch ships a release every updater refuses");
return "round-trip + tamper + wrong-key + garbage all correct; on-disk private key matches the embedded public key";
}
/// <summary>The 5.6 password rules: the strength critique (what the dialogs enforce and
/// explain) and the derivation choke-point refusing weak passwords outright, so NO path —
/// tick, startup auto-connect, headless service — streams on a guessable password.</summary>
private static string? PasswordRules()
{
Check(PasswordStrength.Critique("") is null, "empty is not critiqued here (clearing has its own gate)");
Check(PasswordStrength.Critique("Games") is not null, "a 5-character password must be rejected (the exact case that prompted this)");
Check(PasswordStrength.Critique("Password1") is not null, "a world's-most-common password must be rejected regardless of case");
Check(PasswordStrength.Critique("kettle9tiger42moon") is null, "a three-words-and-numbers passphrase must pass");
Check(PasswordStrength.Critique("hunter2horse42stable") is null, "the test-suite passphrase must pass");
var advice = PasswordStrength.Critique("short") ?? "";
Check(advice.Contains("at least 8", StringComparison.OrdinalIgnoreCase) && advice.Contains("kettle9tiger42moon"),
"the critique must say the rule AND give a concrete example to copy the shape of");
var (weakKey, weakFp) = RemSoundCrypto.ForPlainPassword("Games");
Check(weakKey is null && weakFp is null, "the shared derivation rule must refuse a weak password — no key, no audio, on every path");
var (goodKey, goodFp) = RemSoundCrypto.ForPlainPassword("kettle9tiger42moon");
Check(goodKey is { Length: 32 } && goodFp is { Length: 8 }, "a strong password must derive the full key + fingerprint");
return "weak + common refused with concrete advice; derivation choke-point refuses weak on every path";
}
/// <summary>The relay address-proof (2026-07-27): an AddrCheck cookie arriving at the receiver
/// must be handed up VERBATIM for the app to echo — that echo is what proves our address to
/// the relay once it enforces. Also pins the wire type value the relay and client agreed on.</summary>
private static string? RelayAddrCheckEcho()
{
Check((byte)RemPacketType.AddrCheck == 10, "AddrCheck must stay type 10 — the relay builds this byte");
using var receiver = new RemSound.Receiver.AudioReceiver();
try { receiver.Start(FreeUdpPort()); }
catch (Exception ex) { return Skip($"could not start receiver: {ex.Message}"); }
receiver.SetOutputDevices(Array.Empty<string>());
byte[]? echoed = null;
IPEndPoint? echoedTo = null;
receiver.OnAddrCheckReceived = (packet, length, remote) => { echoed = packet.AsSpan(0, length).ToArray(); echoedTo = remote; };
// A relay-shaped challenge: v1 header, type AddrCheck, 16-byte cookie.
var cookie = new byte[16];
new Random(7).NextBytes(cookie);
var challenge = new byte[RemPacket.HeaderSize + cookie.Length];
RemPacket.WriteHeader(challenge, RemPacketType.AddrCheck, 1, 1);
cookie.CopyTo(challenge, RemPacket.HeaderSize);
var relayEp = new IPEndPoint(IPAddress.Parse("203.0.113.5"), 47830);
receiver.InjectExternalPacket(challenge, challenge.Length, relayEp);
Check(echoed is not null && echoed.AsSpan().SequenceEqual(challenge),
"the challenge must reach the app hook VERBATIM (the echo must carry the exact cookie back)");
Check(Equals(echoedTo, relayEp), "the hook must carry the source address (that's where the echo goes)");
return "type pinned at 10; challenge handed up verbatim with its source, ready to echo";
}
/// <summary>The "only install updates within this time range" gate (2026-07-26 feature):
/// same-day windows, past-midnight wraparound, boundary semantics (start in, end out),
/// the empty-selection rule, the deferred-retry arithmetic, and the list text format.</summary>
+5
View File
@@ -329,6 +329,11 @@ public sealed class ServiceSendHost : IDisposable
// verifies the fingerprint before accepting a stream; a key alone gets silently rejected).
var plainPassword = string.IsNullOrEmpty(profile.Password) ? "" : RemSoundCrypto.Deobfuscate(profile.Password);
(sender.AudioKey, sender.AudioFingerprint) = RemSoundCrypto.ForPlainPassword(plainPassword);
// Since 5.6 the shared rule also refuses a WEAK password (key null → nothing sent). The
// headless service can't pop a dialog, so the log must carry the why — otherwise this
// reads as the old "no password set" and sends someone hunting the wrong bug.
if (sender.AudioKey is null && plainPassword.Length > 0)
log?.Invoke("service: the service profile's password fails the 5.6 strength rule — no audio until it's changed (Service menu, Configure service profile, Set service profile password)");
// The service's audio transport is FIXED to the known-good live-jamming config, regardless of
// what the profile carries (the config dialog no longer exposes these — Ed, 2026-07-17).
// The numbers live in ServiceAudioDefaults, shared with the dialog that writes the profile.
+48
View File
@@ -0,0 +1,48 @@
namespace RemSound.Core;
/// <summary>
/// The gate for NEW profile passwords (2026-07-27, alongside the PBKDF2 raise). The password is the
/// ONLY thing standing between a captured stream and an offline guessing rig — the fingerprint
/// travels in cleartext, so a short or common password falls in seconds no matter how slow we make
/// the derivation. Deliberately simple and predictable (no scoring meter — a screen-reader user
/// gets one clear rule and one concrete suggestion): at least <see cref="MinLength"/> characters
/// and not an infamous password. Existing saved passwords are grandfathered — the gate fires only
/// when a password is being SET or CHANGED, so nobody's working setup breaks; they meet the rule
/// the next time they choose to change it.
/// </summary>
public static class PasswordStrength
{
public const int MinLength = 8;
// The classics that appear at the top of every breached-password list. Not a dictionary —
// just the entries so common that allowing them makes the length rule meaningless.
private static readonly string[] CommonPasswords =
{
"password", "password1", "12345678", "123456789", "1234567890", "qwertyui", "qwerty123",
"11111111", "iloveyou", "sunshine", "letmein1", "trustno1", "remsound",
};
/// <summary>Null when the password is acceptable; otherwise ONE plain-English paragraph
/// telling the user exactly what to do instead. Empty input returns null — clearing a
/// password is a separate, deliberate act with its own gate.</summary>
public static string? Critique(string password)
{
if (string.IsNullOrEmpty(password)) return null;
if (password.Length < MinLength)
{
return $"This password is too short to protect your audio — anyone who records your stream can try millions of guesses against it. "
+ $"Use at least {MinLength} characters; longer is stronger. Three unrelated words with a number — like kettle9tiger42moon — "
+ "is easy to type and remember, and very hard to guess. Remember: every machine you connect with must be given the same new password.";
}
foreach (var common in CommonPasswords)
{
if (string.Equals(password, common, StringComparison.OrdinalIgnoreCase))
{
return "That password is one of the most commonly guessed passwords in the world, so it offers almost no protection. "
+ "Pick something personal and longer — three unrelated words with a number, like kettle9tiger42moon, works well. "
+ "Remember: every machine you connect with must be given the same new password.";
}
}
return null;
}
}
+9
View File
@@ -17,6 +17,15 @@ public enum RemPacketType : byte
/// Old peers see "unknown packet type" and silently drop, so adding this is wire-safe.
/// </summary>
Control = 5,
// 6-9 are the relay's lobby types (hello / roster / full / bye) — relay-side, not modelled here.
/// <summary>Relay address-proof challenge (2026-07-27): the relay sends a random cookie to a
/// newly seen client address and only counts that address as VERIFIED once the same packet
/// comes back from it. A forged source address can never echo, which kills the reflection
/// attack (registering a victim's spoofed address so the relay bounces audio at them). The
/// client's only job is to echo the packet verbatim to wherever it came from; pre-5.6 clients
/// drop it as an unknown type, which the relay's watch-only mode tolerates until the
/// enforcement flip.</summary>
AddrCheck = 10,
}
public enum HeartbeatKind : byte
+19 -8
View File
@@ -52,9 +52,14 @@ public static class RemSoundCrypto
private const int NonceBytes = 12; // AES-GCM standard nonce
private const int TagBytes = 16; // AES-GCM auth tag
// PBKDF2 cost. High enough to make brute-forcing a captured fingerprint expensive, low
// enough not to stall a connect on older (Win7-era) hardware. Run once per password, cached.
private const int Pbkdf2Iterations = 100_000;
// PBKDF2 cost. Raised 100k → 600k for v5.6 (2026-07-27, per the security audit — 100k was
// well below current OWASP guidance and the fingerprint travels in cleartext, so offline
// guessing cost is the whole defence). BREAKING: both peers must derive the SAME key from
// the same password, so a 5.6 machine cannot exchange audio with a pre-5.6 machine AT ALL —
// the release notes lead with "everyone must update". Runs once per password and is cached
// (never per packet); ~a few hundred ms even on old hardware, felt only when a password is
// set or a profile loads.
private const int Pbkdf2Iterations = 600_000;
// Fixed salts. A per-connection random salt would be stronger, but both peers must derive
// the SAME key from the SAME password with no key-exchange round, so the salt has to be
@@ -67,12 +72,18 @@ public static class RemSoundCrypto
Encoding.UTF8.GetBytes("RemSound-profile-password-scramble-v1");
/// <summary>The one rule for turning a PLAIN password into the audio credentials: null/empty →
/// (null, null) → no audio flows (encryption is mandatory); otherwise the key AND the fingerprint,
/// always together — the peer verifies the fingerprint before accepting a stream, so a key without
/// its fingerprint gets the audio silently rejected at the far end (a divergence that already bit
/// the service once). The app and the service both derive through THIS.</summary>
/// (null, null) → no audio flows (encryption is mandatory); since 5.6 a password that fails
/// <see cref="PasswordStrength.Critique"/> ALSO yields (null, null) — enforced here, at the single
/// choke-point the app AND the service both derive through, so no path (tick, startup auto-connect,
/// profile switch, headless service) can stream on a guessable password (Ed, 2026-07-27; the UI
/// explains and walks the user to a stronger one). Otherwise the key AND the fingerprint, always
/// together — the peer verifies the fingerprint before accepting a stream, so a key without its
/// fingerprint gets the audio silently rejected at the far end (a divergence that already bit
/// the service once).</summary>
public static (byte[]? Key, byte[]? Fingerprint) ForPlainPassword(string? plainPassword) =>
string.IsNullOrEmpty(plainPassword) ? (null, null) : (DeriveKey(plainPassword), Fingerprint(plainPassword));
string.IsNullOrEmpty(plainPassword) || PasswordStrength.Critique(plainPassword) is not null
? (null, null)
: (DeriveKey(plainPassword), Fingerprint(plainPassword));
/// <summary>Derive the 256-bit AES key for a password. Cache the result; never call per packet.</summary>
public static byte[] DeriveKey(string? password) =>
+61
View File
@@ -0,0 +1,61 @@
using System.Security.Cryptography;
namespace RemSound.Core;
/// <summary>
/// Release-zip signature verification (2026-07-27, per the security audit: the updater previously
/// trusted whatever the GitHub release stream served — a compromised account could ship code to
/// every user silently). Every release zip is now signed at publish time (build-release.ps1) with
/// a private ECDSA P-256 key that lives ONLY on the publisher's machine; this class holds the
/// matching public key and the updater REFUSES any update whose signature is missing or does not
/// verify. Manual downloads from the release page are unaffected — this gates the automatic path.
/// The signature travels as a release asset named "&lt;zip-name&gt;.sig" (base64 of an ECDSA
/// SHA-256 signature over the raw zip bytes).
/// </summary>
public static class UpdateSignature
{
/// <summary>The RemSound release-signing PUBLIC key (SubjectPublicKeyInfo PEM). The private
/// half is NOT in the repo — it lives only with the publisher. Replacing this constant means
/// shipping a release signed by BOTH keys' owner, i.e. only the publisher can rotate it.</summary>
public const string PublicKeyPem = """
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENrzZmey3cvNxNyd6t55QQThTb3Zj
xR34nJr7egPq4f1Ff1IL5qA46nstniKZ3Zl6k+vcLWRr1oXzzdHvbIidcw==
-----END PUBLIC KEY-----
""";
/// <summary>Suffix of the signature asset on a GitHub release: the zip asset's name + this.</summary>
public const string SignatureAssetSuffix = ".sig";
/// <summary>True when <paramref name="signatureBase64"/> is a valid signature over
/// <paramref name="data"/> by the embedded release key. Never throws — malformed base64,
/// wrong length, wrong key all simply return false.</summary>
public static bool Verify(byte[] data, string signatureBase64) =>
VerifyWithKey(data, signatureBase64, PublicKeyPem);
/// <summary>Verification against an explicit public key — the seam the self-test uses to prove
/// the mechanics (round-trip, tamper, wrong-key) with an ephemeral throwaway keypair.</summary>
public static bool VerifyWithKey(byte[] data, string signatureBase64, string publicKeyPem)
{
try
{
var signature = Convert.FromBase64String(signatureBase64.Trim());
using var ec = ECDsa.Create();
ec.ImportFromPem(publicKeyPem);
return ec.VerifyData(data, signature, HashAlgorithmName.SHA256);
}
catch
{
return false;
}
}
/// <summary>Sign data with a private-key PEM — used by the publish pipeline (via --sign-update)
/// and the self-test's if-the-key-is-present embed-matches-key check. Returns base64.</summary>
public static string SignWithKey(byte[] data, string privateKeyPem)
{
using var ec = ECDsa.Create();
ec.ImportFromPem(privateKeyPem);
return Convert.ToBase64String(ec.SignData(data, HashAlgorithmName.SHA256));
}
}
+10
View File
@@ -961,6 +961,10 @@ public sealed class AudioReceiver : IDisposable
/// socket on either end any more.</summary>
public Action<byte[], int, IPEndPoint>? OnHeartbeatReceived { get; set; }
/// <summary>Hook for relay address-proof cookies (AddrCheck, 2026-07-27). The App echoes the
/// packet back to its source via the sender's socket — same single-port model as heartbeats.</summary>
public Action<byte[], int, IPEndPoint>? OnAddrCheckReceived { get; set; }
/// <summary>Hook for Control packets that arrive on the audio receiver's socket. Since 5.6 the
/// payload is SEALED with the profile's audio key (ControlSealing), so this hands the RAW payload
/// up — the App authenticates it (key + replay guard), validates the source against the
@@ -1000,6 +1004,12 @@ public sealed class AudioReceiver : IDisposable
// otherwise heartbeats are dropped and peer health stays "unreachable".
OnHeartbeatReceived?.Invoke(packet, length, remote);
break;
case RemPacketType.AddrCheck:
// Relay address-proof cookie (2026-07-27): hand the raw packet up so the app can
// echo it back to the relay verbatim — proving this address really receives, which
// is what unlocks relay forwarding once the relay enforces. No parsing needed here.
OnAddrCheckReceived?.Invoke(packet, length, remote);
break;
case RemPacketType.Control:
// Remote-control message (volume up/down, mute toggle). Since 5.6 the payload is
// SEALED with the profile's audio key (ControlSealing) — the receiver stays