Migrate aiosendspin adapters to the real encrypted Noise handshake - #112
Merged
Conversation
Introduce a protocol-evidence-based verification mode for conformance scenarios so we can assert on Sendspin protocol compliance (hello negotiation, role capability advertisement, stream setup handshake, etc.) rather than only on rendered audio/media correctness. - src/conformance/protocol.py: defines protocol assertion IDs and protocol_evidence_failure() to check adapter summaries against them. - src/conformance/models.py: ScenarioSpec gains verification_mode support for 'protocol' and a protocol_assertions field. - src/conformance/scenarios.py: registers the new baseline protocol scenario. - src/conformance/runner.py: wires the 'protocol' verification_mode into _compare_summaries(). - README.md / adapters/README.md: document the protocol-evidence contract adapters must uphold. Co-authored-by: Copilot <[email protected]>
The aiosendspin adapters previously used allow_unencrypted=True, which triggers the SDK's legacy transition path: a single unencrypted client/hello answered with LegacyServerHelloMessage. This entirely skips the spec-required handshake sequence (client/init -> server/init -> noise/handshake x2 -> server/hello -> client/hello -> server/activate), so conformance could never honestly assert on handshake ordering or activate/support semantics. - Add _aiosendspin_pairing.py: derives a deterministic Identity + a deterministic long-term PSK from (server_id, client_id) so the server and client adapter processes can independently construct matching pairing records without any out-of-band pairing exchange. This forces the real long-term-paired encrypted path (PskCategory.LONG_TERM / TrustLevel.USER) instead of the legacy unencrypted path or the more complex unpaired/sentinel-PSK path. - aiosendspin_server.py / aiosendspin_client.py: replace Identity.generate() + in-memory pairing stores with make_server_identity_and_store()/make_client_identity_and_store(). Add --client-id to the server adapter (previously client-only). - runner.py: pass client_id to both the server and client role args, since both processes now need it to derive the shared PSK. - sendspin-go/server/main.go: accept (and ignore) --client-id so the Go server adapter, which uses a strict flag parser, doesn't fail on the now-universal --client-id argument passed to all server roles. - aiosendspin_client.py: write ready.json before returning an error for unsupported scenarios, so the harness fails fast instead of timing out waiting for a ready file that will never appear. Verified with the aiosendspin<->aiosendspin smoke matrix: all previously-passing scenarios (client-initiated-pcm, server-initiated- pcm/pcm-24bit/flac/metadata/artwork/controller) still pass end-to-end with the real encrypted handshake. server-initiated-protocol-baseline-v1 still fails as expected, since evidence-trace emission is not yet implemented (tracked in #111); it now fails fast with an honest error instead of timing out. Co-authored-by: Copilot <[email protected]>
chrisuthe
added a commit
that referenced
this pull request
Sep 4, 2026
PR #112 removed aiosendspin's allow_unencrypted=True fallback so the matrix exercises the real encrypted Noise handshake. That correctly surfaced as CI regressions: sendspin-cpp, sendspin-go, sendspin-dotnet, sendspin-js, sendspin-rs, and SendspinKit clients are all plaintext-only and have no Noise handshake implementation, so every aiosendspin-server pairing with them now fails outright instead of limping along over the non-spec legacy path. This is expected and correct: those clients haven't caught up to the spec's mandatory encryption yet. Rather than let that coverage silently disappear, add a dedicated `server-initiated-legacy-unencrypted` scenario that keeps testing the plaintext path explicitly: - RoleSpec gains `supports_legacy_unencrypted`; ScenarioSpec gains `requires_legacy_unencrypted`. Capability filtering treats this the same as the existing request-format/codec/role-family checks. - aiosendspin server is the only implementation whose server actually has a legacy unencrypted mode (`allow_unencrypted`), so it's the only server marked `supports_legacy_unencrypted=True`. Every other implementation's *client* is plaintext-only, so their clients are marked instead. aiosendspin's own client always runs the real Noise handshake, so it is deliberately not marked. - `aiosendspin_server.py` gains `--allow-unencrypted` (default false), wired only for this scenario via `runner.py`'s `role_args()` (not as a scenario-wide `extra_cli_args`, since that would also hand every *client* adapter a flag their CLI parsers don't know about and, for the strict ones like Go, would fail to parse). - aiosendspin_client.py additionally accepts this scenario id in its three existing PCM-family scenario_id switches (player support setup, audio listener registration, summary population) since the client side of this scenario needs the same PCM verification as server-initiated-pcm. Once every tracked implementation ships the real handshake, this scenario (and supports_legacy_unencrypted) should be deleted outright rather than kept as permanent scope. Co-authored-by: Copilot <[email protected]>
chrisuthe
force-pushed
the
pr3-aiosendspin-encrypted-handshake
branch
from
September 4, 2026 19:35
c34c00c to
da51264
Compare
PR #112 removed aiosendspin's allow_unencrypted=True fallback so the matrix exercises the real encrypted Noise handshake. That correctly surfaced as CI regressions: sendspin-cpp, sendspin-go, sendspin-dotnet, sendspin-js, sendspin-rs, and SendspinKit clients are all plaintext-only and have no Noise handshake implementation, so every aiosendspin-server pairing with them now fails outright instead of limping along over the non-spec legacy path. This is expected and correct: those clients haven't caught up to the spec's mandatory encryption yet. Rather than let that coverage silently disappear, add a dedicated `server-initiated-legacy-unencrypted` scenario that keeps testing the plaintext path explicitly: - RoleSpec gains `supports_legacy_unencrypted`; ScenarioSpec gains `requires_legacy_unencrypted`. Capability filtering treats this the same as the existing request-format/codec/role-family checks. - aiosendspin server is the only implementation whose server actually has a legacy unencrypted mode (`allow_unencrypted`), so it's the only server marked `supports_legacy_unencrypted=True`. Every other implementation's *client* is plaintext-only, so their clients are marked instead. aiosendspin's own client always runs the real Noise handshake, so it is deliberately not marked. - `aiosendspin_server.py` gains `--allow-unencrypted` (default false), wired only for this scenario via `runner.py`'s `role_args()` (not as a scenario-wide `extra_cli_args`, since that would also hand every *client* adapter a flag their CLI parsers don't know about and, for the strict ones like Go, would fail to parse). - aiosendspin_client.py additionally accepts this scenario id in its three existing PCM-family scenario_id switches (player support setup, audio listener registration, summary population) since the client side of this scenario needs the same PCM verification as server-initiated-pcm. Once every tracked implementation ships the real handshake, this scenario (and supports_legacy_unencrypted) should be deleted outright rather than kept as permanent scope. Co-authored-by: Copilot <[email protected]>
chrisuthe
added a commit
that referenced
this pull request
Sep 4, 2026
…114) The macOS publish workflow has been failing to produce a report at all for the last two runs: a single case's summary comparison raised an unhandled KeyError ('audio') deep in _compare_audio_summaries(), which propagated straight out of asyncio.gather() in run_matrix() and killed run_all.py before the report/Pages-publish steps ever ran. This is why recently-added scenarios (e.g. server-initiated-legacy-unencrypted from #112) appeared to be "missing" from the published site -- the whole report generation had silently stopped succeeding, not just that one scenario's row. - runner.py: wrap _compare_summaries() in run_case() so any exception it raises is turned into a failed case with a descriptive reason instead of crashing the entire matrix run. A bug in one scenario's comparison logic should now only fail that one case. - _compare_audio_summaries(): guard against a missing/malformed "audio" section in either summary with an explicit, readable failure reason instead of a raw KeyError, since that's the concrete bug that triggered this. Verified locally: python -m compileall src scripts passes, and a full aiosendspin -> aiosendspin matrix run (including the new server-initiated-legacy-unencrypted and server-initiated-protocol-baseline-v1 scenarios) still produces the same pass/fail results as before, plus report generation still succeeds end-to-end. Co-authored-by: Copilot <[email protected]>
chrisuthe
added a commit
that referenced
this pull request
Sep 4, 2026
…patch (#115) * Prevent one case's comparison bug from crashing the whole matrix run The macOS publish workflow has been failing to produce a report at all for the last two runs: a single case's summary comparison raised an unhandled KeyError ('audio') deep in _compare_audio_summaries(), which propagated straight out of asyncio.gather() in run_matrix() and killed run_all.py before the report/Pages-publish steps ever ran. This is why recently-added scenarios (e.g. server-initiated-legacy-unencrypted from #112) appeared to be "missing" from the published site -- the whole report generation had silently stopped succeeding, not just that one scenario's row. - runner.py: wrap _compare_summaries() in run_case() so any exception it raises is turned into a failed case with a descriptive reason instead of crashing the entire matrix run. A bug in one scenario's comparison logic should now only fail that one case. - _compare_audio_summaries(): guard against a missing/malformed "audio" section in either summary with an explicit, readable failure reason instead of a raw KeyError, since that's the concrete bug that triggered this. Verified locally: python -m compileall src scripts passes, and a full aiosendspin -> aiosendspin matrix run (including the new server-initiated-legacy-unencrypted and server-initiated-protocol-baseline-v1 scenarios) still produces the same pass/fail results as before, plus report generation still succeeds end-to-end. Co-authored-by: Copilot <[email protected]> * Recognize server-initiated-legacy-unencrypted as a player scenario in existing client adapters The client side of this scenario is identical to server-initiated-pcm (same PCM audio, same role behavior) - only the server differs, by skipping the Noise handshake. Rather than requiring new scenario-specific client logic, each client adapter's existing player-scenario dispatch just needed to also match this scenario_id. - sendspin-go: add to IsPlayerScenario - sendspin-cpp: add to is_player_scenario - sendspin-js: add to PLAYER_SCENARIOS --------- Co-authored-by: Copilot <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Migrates the aiosendspin server/client adapters off the legacy unencrypted handshake path (
allow_unencrypted=True) onto the real encrypted, long-term-paired Noise handshake, so future protocol-evidence assertions (handshake ordering, activate/support semantics) can be honestly captured against the real spec-required exchange instead of a shortcut path that skips it entirely.Why
allow_unencrypted=Truetriggers aiosendspin's legacy transition mode: a single unencryptedclient/helloanswered withLegacyServerHelloMessage. This entirely bypasses the spec's required handshake sequence (client/init→server/init→noise/handshake×2 →server/hello→client/hello→server/activate). Conformance can't assert CORE-001 (handshake order/frame types) or CORE-003 (activate with support objects) against a path that never actually runs that handshake.How
src/conformance/adapters/_aiosendspin_pairing.py: derives a deterministicIdentityand a deterministic long-term PSK from(server_id, client_id), so the server and client adapter processes can each independently construct matching pairing records without any out-of-band pairing exchange. This forces the real long-term-paired encrypted path (PskCategory.LONG_TERM/TrustLevel.USER), which activates all negotiated roles immediately — simpler than the sentinel/unpaired-access flow, and closer to what a real paired device does.aiosendspin_server.py/aiosendspin_client.py: use the new helper instead ofIdentity.generate()+ ephemeral in-memory pairing stores. The server adapter now also accepts--client-id(previously client-only), since both processes need it to derive the same PSK.runner.py:role_args()now passesclient_idinto both the server and client role's CLI args (previously client-only).adapters/sendspin-go/server/main.go: accepts (and currently ignores)--client-id, since Go'sflagpackage errors on unrecognized flags and this arg is now passed to every server role.aiosendspin_client.py: writesready.jsonbefore returning an error for an unsupported scenario, so the harness fails fast with an honest error instead of timing out waiting for a ready file that will never be written.Verification
python -m compileall src scriptspython -m conformance.cli run --from aiosendspin --to aiosendspin: all previously-passing scenarios (client-initiated-pcm, server-initiated-pcm/pcm-24bit/flac/metadata/artwork/controller) still pass end-to-end with the real encrypted handshake.server-initiated-protocol-baseline-v1still fails — expected, since evidence-trace emission isn't implemented yet (tracked in aiosendspin SDK needs for conformance protocol-evidence tracing #111) — but now fails fast with a clear error instead of timing out.Scope note
This is deliberately scoped to the handshake migration only. Actually populating
summary["protocol"]with CORE-00x/PLAYER-001 evidence forserver-initiated-protocol-baseline-v1needs a wire-level tracing hook from aiosendspin that doesn't exist yet (there's no external seam to observe the hello/activate exchange, since it completes inside the SDK before adapter code gets a connection handle). That's tracked in #111 as a running list of aiosendspin SDK asks. Follow-up PR will wire in real evidence once that hook (or an accepted interim approach) exists.