Skip to content

Migrate aiosendspin adapters to the real encrypted Noise handshake - #112

Merged
chrisuthe merged 3 commits into
mainfrom
pr3-aiosendspin-encrypted-handshake
Sep 4, 2026
Merged

Migrate aiosendspin adapters to the real encrypted Noise handshake#112
chrisuthe merged 3 commits into
mainfrom
pr3-aiosendspin-encrypted-handshake

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

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=True triggers aiosendspin's legacy transition mode: a single unencrypted client/hello answered with LegacyServerHelloMessage. This entirely bypasses the spec's required handshake sequence (client/initserver/initnoise/handshake ×2 → server/helloclient/helloserver/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

  • New src/conformance/adapters/_aiosendspin_pairing.py: derives a deterministic Identity and 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 of Identity.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 passes client_id into 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's flag package errors on unrecognized flags and this arg is now passed to every server role.
  • aiosendspin_client.py: writes ready.json before 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 scripts
  • python -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-v1 still 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 for server-initiated-protocol-baseline-v1 needs 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.

chrisuthe and others added 2 commits September 4, 2026 13:40
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
@chrisuthe
chrisuthe force-pushed the pr3-aiosendspin-encrypted-handshake branch from c34c00c to da51264 Compare September 4, 2026 19:35
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 <223556219+Copilot@users.noreply.github.com>
@chrisuthe
chrisuthe merged commit 9baf93d into main Sep 4, 2026
2 checks passed
@chrisuthe
chrisuthe deleted the pr3-aiosendspin-encrypted-handshake branch September 4, 2026 20:33
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>

* 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 <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant