Security phase 1: sealed remote control + service-folder lockdown + counter nonces

From the 2026-07-26 security audit, the two must-fix findings plus the crypto tidy:

1. Remote-control commands are now SEALED with the profile's audio key (AES-256-GCM,
   ControlSealing in Core). Previously a 2-byte plaintext payload gated only by a
   forgeable source-IP check - anyone who learned an allowed peer's address could
   drive the receiving machine's SYSTEM volume/mute, and muting a blind user's
   machine mutes their screen reader. Now only a password-holder can issue a command;
   a ControlReceiveGuard also bounds clock skew (10 min) and remembers nonces so a
   captured packet can't be replayed to re-toggle mute. Legacy plaintext control from
   pre-5.6 peers is dropped at the receiver (never acted on) - release notes must say
   both ends need 5.6 for remote volume.

2. Cross-user LPE closed: the SYSTEM service trusts app-source.txt to decide what to
   copy+run on self-update, and ProgramData lets any user who pre-created the service
   folder own it (CREATOR OWNER inheritance) and repoint that file. Elevated install
   now records the installing user's SID, takes ownership for Administrators and
   resets the ACL to exactly SYSTEM + Administrators + installing user (takeown +
   icacls /inheritance:r). Re-asserted on every SYSTEM self-update so existing
   installs pick it up; as SYSTEM with no recorded SID it defers rather than lock the
   user out of their no-admin workflow.

3. Audio-path GCM nonces are now counter-based per lane (random 4-byte prefix +
   64-bit counter, fresh sequence with every key rebuild) - unique by arithmetic,
   removing the random-nonce birthday bound on a long-lived key. Wire format
   unchanged; the receiver reads the nonce from the packet as before.

New gate steps: sealed-control auth/replay/stale/wrong-key/plaintext matrix + nonce
discipline; service-folder lockdown args + SID recording garbage-proofing.
Gate 63/63.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-26 23:34:49 +01:00
co-authored by Claude Fable 5
parent 6f91e0ed5e
commit 9a18d18656
8 changed files with 356 additions and 29 deletions
+6 -2
View File
@@ -46,6 +46,7 @@ internal sealed class SenderLane
// cipherScratch holds the per-frame ciphertext (plaintext + 28 bytes overhead); 4096 covers
// the largest single frame (Opus 20 ms or PCM 5 ms) with room to spare.
private AesGcm? cryptoGcm;
private RemSoundCrypto.NonceSequence? cryptoNonces;
private byte[]? cryptoKeyCached;
private readonly byte[] cipherScratch = new byte[4096];
@@ -317,7 +318,7 @@ internal sealed class SenderLane
// Encrypt the whole PCM frame, then split the ciphertext across as many parts as the
// Ethernet payload budget needs (the +28-byte crypto overhead can push a 5 ms frame over
// a single datagram). The receiver reassembles the parts and then decrypts.
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, int24, cipherScratch);
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, cryptoNonces!, int24, cipherScratch);
var maxPart = RemPacket.MaxAudioPayloadBytes;
var totalParts = (byte)((ctLen + maxPart - 1) / maxPart);
pcmFrameId++;
@@ -353,7 +354,7 @@ internal sealed class SenderLane
}
EnsureCrypto();
if (cryptoGcm is null) return; // no password yet → never send audio in the clear
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, opusPlainScratch.AsSpan(0, encLen), cipherScratch);
var ctLen = RemSoundCrypto.EncryptInto(cryptoGcm, cryptoNonces!, opusPlainScratch.AsSpan(0, encLen), cipherScratch);
Interlocked.Increment(ref audioFramesSent);
SendAudio(cipherScratch.AsSpan(0, ctLen));
}
@@ -403,6 +404,9 @@ internal sealed class SenderLane
if (ReferenceEquals(key, cryptoKeyCached)) return;
cryptoGcm?.Dispose();
cryptoGcm = key is null ? null : RemSoundCrypto.CreateGcm(key);
// Fresh nonce sequence with the fresh cipher: new random prefix, counter from zero —
// a rebuilt key never continues an old counter, and an old key never sees a reused one.
cryptoNonces = key is null ? null : new RemSoundCrypto.NonceSequence();
cryptoKeyCached = key;
}