Dynamic Pairing Code: CPace Responder and the Pairing Flow
Dynamic Pairing Code: CPace Responder and the Pairing Flow
Date: 2026-09-04
Status: Accepted
Authoritative protocol spec: https://github.com/sendspin/spec (pairing.md)
PAKE reference: draft-irtf-cfrg-cpace-21
Goal
Add dynamic_pairing_code to SendSpinDroid: the client derives a per-session
six-digit code bound to the Noise handshake, displays it, and authenticates the
pairing with a CPace PAKE round. Pairing PSK stays exactly as it is.
Why this is an addition, not a replacement
pairing.md is explicit: “clients must implement Pairing PSK and may
additionally offer at most one pairing-code method”. Pairing PSK is the one
mandatory client method, so removing it would move us from conformant to
non-conformant.
What we are missing is a SHOULD: “Clients with a usable out-channel (display,
speaker, etc.) should offer dynamic_pairing_code”. SendSpinDroid has a
display, so it should offer it. “At most one” means offering Dynamic
permanently forecloses Static – which is the right trade, since Static is
“intended for devices without one” and is “vulnerable to MITM if the pairing
code is disclosed”.
Decisions
D1. Emission format: digits only. formats: ["digits"]. The operator
reads six digits off the screen and types them into the server. A text box
exists in every server UI; a QR scanner may not. Dropping qr_code also drops
the version-1 pairing-token encoding entirely.
D2. Out-channels: display only. Never speaker. Advertising a speaker
channel obliges us to accept a server-supplied digit audio pack – ten
clips, per-clip decode validation, a 2-second per-clip limit, a max_bytes
total, and a “pack incomplete” protocol error – a whole subsystem for a device
with a perfectly good screen.
D3. Responder role only. The server is CPace initiator (role A); we are always responder (role B). Implementing role A would be untestable code that exists to be wrong.
D4. Build to the current spec, not to what Music Assistant speaks today. See “The interop gap” below. This is a deliberate, informed choice to be correct now and interoperable later, consistent with the reasoning in PR #238 that rejected keeping a second wire format alive.
D5. Follow the spec on the failure counter where it diverges from the 9.1.1 reference. See “Known divergence”.
The interop gap
The pairing protocol changed incompatibly between aiosendspin 9.x and 10.x. Music Assistant ships 9.1.x.
| aiosendspin 9.1.1 (what MA ships) | aiosendspin 10.x / current spec | |
|---|---|---|
| Method id | PairMethod.DYNAMIC_PIN |
dynamic_pairing_code |
| The code | operator-chosen PIN, negotiated length via min_pin_length, max(client_min, server_min) |
derived, fixed six digits |
| Binding | none | commit_B / nonce_A / wrapped_nonce_B commit-and-reveal |
| Descriptor | min_pin_length |
formats, out_channels |
| PAKE | CPace-X25519-SHA512 + MCF | CPace-X25519-SHA512 + MCF |
MA’s provider (music_assistant/providers/sendspin/helpers.py) calls
PairMethod.DYNAMIC_PIN and replace(descriptor, min_pin_length=...); neither
the enum member nor the field exists in current aiosendspin.
Consequence, stated plainly so it is not later mistaken for a defect: on delivery, this feature will not pair with Music Assistant. Pairing with MA continues to use Pairing PSK, which is unaffected. Dynamic pairing becomes usable with MA when MA moves to aiosendspin 10.x.
This does not make the work unverifiable. ci/conformance/dev_server.py is
already a local aiosendspin server built for exactly this situation – so that
Phase 1 could be verified “against a spec-compliant peer instead of Music
Assistant’s compatibility shim”. Pinned to aiosendspin 10.x it becomes the
acceptance peer for end-to-end pairing.
The PAKE core is identical across both revisions, so none of the cryptographic work is at risk from this gap.
Architecture
Two pure layers, separately verifiable, plus wiring.
:shared crypto/cpace/
Elligator2.kt RFC 9380 map_to_curve_elligator2 on X25519Field
CPaceX25519.kt generator string, scalar_mult_vfy, ISK, MCF tags
CPaceResponder.kt start / derive / verify / tag / isk (role B)
:shared pairing/
DynamicPairingCodeFlow.kt event -> action state machine
PairingCode.kt commit, digest, six-digit derive
PairingWindow.kt window lifetime + failure counter policy
:shared protocol/message/ pair-pending/init/auth/confirm build+parse
:app protocol/ handler wiring: messages <-> flow
:app ui/ code display in the PAIRING admission state
The split exists so the crypto can be tested against published vectors in isolation. Folded into the state machine, every cryptographic assertion would have to be smuggled through protocol sequencing.
No new dependency. BouncyCastle 1.80 already ships for the Noise layer and
provides X25519Field (constant-time field arithmetic, which Elligator2
needs), X25519.scalarMult for arbitrary base points, SHA-512 and HMAC.
The flow
DynamicPairingCodeFlow mirrors the existing PairingPskFlow: a pure
onEvent(event): List<PairingAction> with no I/O, so timeouts, aborts and
sequence violations are testable without a socket.
| Event | Actions |
|---|---|
PairingActivation(method, format, index, pskCategory) |
escalated and no window: SendPairPending(index). Otherwise generate nonce_B, SendPairInit(index, commit_B), start attempt timeout |
WindowOpened |
SendPairInit(index, commit_B), start attempt timeout |
ServerPairInit(nonce_A) |
derive six digits; IncrementFailureCounter; EmitPairingCode; CPace start(prs = code ASCII, sid) |
ServerPairAuth(Ya) |
SendPairAuth(Yb), then derive(Ya) |
ServerPairConfirm(Ta) |
verify fails: SendPairAbort(pairing_code_mismatch). Verify passes: ResetFailureCounter, then SendPairConfirm(Tb, wrapped_nonce_B) and SendPairFinalize(wrapped_psk) together |
ServerPairFinalize |
PersistRecord(psk, serverId), StopEmittingCode |
AttemptTimeout |
SendPairAbort(attempt_timeout) |
NonPairingActivation, PairAbortReceived, ConnectionClosed |
discard all state, StopEmittingCode, persist nothing |
client/pair-confirm and client/pair-finalize are emitted as one action list:
the spec sends them “back-to-back, no server response awaited”.
Protocol errors – malformed or missing field, a share of the wrong length
or encoding a low-order point, a commitment that does not open, a value that
fails to decrypt – close the WebSocket with no application-level message and
persist nothing. This is a distinct action from SendPairAbort; conflating the
two would report the failure reason to an unauthenticated peer.
Policy
sid is "sendspin-pair-pake-v1" || h || counter, counter a big-endian
uint32 of the pairing activations since the last Noise handshake – the same
value sent as pairing_index. Both derive from one field so they cannot drift.
Wrapping, per field: K_wrap = SHA-256(label || sid || ISK) with labels
sendspin-pair-psk-wrap-v1 and sendspin-pair-nonce-wrap-v1, then the
connection’s negotiated AEAD, a 12-byte zero nonce, empty associated data, 48
bytes out. The zero nonce is safe only because each key is per-field and used
exactly once; that reasoning belongs in the code, because a bare zero nonce is
otherwise the kind of thing a later reader “fixes” into a vulnerability.
Failure counter: one counter for the method, persisted across reboots, not
partitioned by server or address. Increments when code emission starts, at most
once per attempt. Resets when server_kc verifies, whether or not the attempt
finalizes. At 5 the method escalates: every subsequent attempt is
gesture-gated until a reset de-escalates it. Escalation is not an error state
and the method stays offered.
The gesture is an in-app “Allow pairing” button. The spec permits “any equivalent implementation-defined action” and asks that gestures be “deliberate and hard to induce remotely”; on a tablet with no spare hardware buttons, an explicit on-device tap is the honest equivalent and still requires physical presence. Window lifetime five minutes, closing silently on expiry.
The cryptographic core
DSI = "CPace255" s_in_bytes = 128 (SHA-512)
gen_str = lv_cat(DSI, PRS, zero_bytes(len_zpad), CI, sid)
len_zpad = MAX(0, 128 - len(prepend_len(PRS)) - len(prepend_len(DSI)) - 1)
u = decodeUCoordinate(SHA-512(gen_str)[0..32], 255)
(g, v) = map_to_curve_elligator2(u) -- RFC 9380, v discarded
Yb = X25519(yb, g)
K = scalar_mult_vfy(yb, Ya) -- low-order -> all-zero, rejected
ISK = SHA-512(lv_cat("CPace255_ISK", sid, K) || lv_cat(Ya, ADa) || lv_cat(Yb, ADb))
mac_key = SHA-512("CPaceMac" || sid || ISK)
Ta / Tb = HMAC-SHA-512(mac_key, lv_cat(Ya, ADa) / lv_cat(Yb, ADb))
CI is empty, ADa is "server", ADb is "client".
The draft leaves the MAC algorithm open; SendSpin pins HMAC-SHA-512 with
64-byte tags, which is what makes Ta/Tb constructible from the draft rather
than only from a reference implementation.
Verification
| Layer | Verified against |
|---|---|
| Elligator2 | RFC 9380 published vectors |
generator string, Yb, K, ISK |
draft-21 appendix B.1.1 - B.1.6 |
| low-order point rejection | draft-21 appendix B.1.10 |
Ta / Tb MCF tags |
draft section 10.4.5-6 formula, cross-checked against the Python cpace package as an oracle |
| code derive, commit, wrapping | vectors computed from aiosendspin |
| flow, timeouts, sequencing | pure state-machine unit tests, no I/O |
| end to end | ci/conformance/dev_server.py pinned to aiosendspin 10.x, real pairing from the tablet |
The oracle step matters: install cpace, drive the responder path with fixed
inputs, dump (PRS, sid, Ya, yb) -> (Yb, ISK, Ta, Tb) as JSON, and pin the
Kotlin against it. It is the one construction with no official vectors, and a
lv_cat mistake changes every downstream byte, so the oracle catches prefix
errors immediately.
Vector tests live in :shared as static, deterministic tests with no Android
and no network, matching how the Noise layer is already tested.
Risks
- No Music Assistant interop on delivery. By construction, not by defect. Acceptance is against the dev server.
lv_catuses LEB128 length prefixes, not fixed-width. A fixed-width implementation is self-consistent, passes every test it writes for itself, and fails only against a real peer. The B.1 vectors are the guard.- Low-order point rejection must cover low-order points on the curve and on the twist, mapping both to the all-zero 32 bytes and rejecting. This is the check that stops a peer forcing a known shared secret.
- Failure-counter divergence from the 9.1.1 reference; see below.
pairing_indexbase (0- or 1-based) must be pinned against the reference rather than assumed. An off-by-one breaks every tag while looking entirely reasonable.- Elligator2 must be constant-time, since its input derives from the
pairing code. This is why it is built on
X25519Fieldrather thanBigInteger.
Known divergence
pairing.md states the failure counter “increments when the client starts
emitting the pairing code, at most once per attempt. No other event
increments it”, and resets when server_kc verification succeeds. The 9.1.1
reference instead calls record_pairing_code_failure() on a server_kc
mismatch.
We follow the spec (D5). The practical difference is the escalation rate: under
the spec, five attempts escalate even if each got as far as a successful
server_kc; under the reference, five failures do.
Adjacent cleanup
ServerActivate.pinLength and PairAbortReason.PIN_LENGTH_UNACCEPTABLE are
fossils of the pre-10.x variable-length PIN. pin_length appears nowhere in
the current spec; the field is parsed and never read, and the abort reason is
reserved for a task the spec change deleted.
They are removed as part of this work. They sit in the code this feature touches and describe a mechanism that no longer exists, so leaving them invites someone to wire the code length to a server-supplied value – which is exactly the vulnerability the derived, handshake-bound code was designed to remove.
Out of scope
static_pairing_code– foreclosed by “at most one” once Dynamic is offered.qr_codeemission and the version-1 pairing token (D1).- The digit audio pack and the
speakerout-channel (D2). - CPace initiator (role A) (D3).
- Music Assistant interop, until MA moves to aiosendspin 10.x.
- The missing
management.md: our code citesmanagement.md#record-modeand implements against it, but no such file exists in the spec repository. Worth its own investigation; unrelated to this work.
Verification criteria
- Elligator2, generator,
Yb,K,ISKand low-order rejection match the published vectors. TaandTbmatch the Pythoncpaceoracle byte for byte.- Code derivation, commitment and both wrappings match vectors computed from
aiosendspin. - The flow state machine covers: happy path,
server_kcmismatch, attempt timeout, server-cancelling activation, abort in both directions, connection drop mid-attempt, and every sequence violation as a protocol error. - Escalation: five attempts gate the sixth on the gesture; a successful
server_kcde-escalates. client/helloadvertisesdynamic_pairing_codewithout_channels: ["display"]andformats: ["digits"], and drops it when the method is disabled.- End to end against
ci/conformance/dev_server.pyon aiosendspin 10.x: the tablet displays a six-digit code, the operator enters it, the pairing record persists, and the server’s re-handshake to the new long-term PSK succeeds. - Pairing PSK continues to work unchanged.
Device acceptance record (2026-09-04)
Run against a Relndoo T901 tablet and a local aiosendspin server pinned to
commit 90feb19 (see “The interop gap” – 10.0.0 is still an unreleased draft).
Result: pairing completed end to end.
CODE READ FROM DEVICE: 481010
PAIRING SUCCEEDED
POST-PAIR STATE: paired=True
security=ConnectionSecurity(psk_category=LONG_TERM, trust_level=USER)
roles=['player@v1', 'controller@v1', 'metadata@v1', 'artwork@v1']
psk_category=LONG_TERM is the load-bearing part: the connection was promoted
off the Sentinel PSK to the newly agreed long-term PSK, so the server’s in-band
re-handshake completed without dropping the WebSocket. trust_level moved from
none to user, and a connection that began with zero roles ended
playback-capable across all four.
Also verified on device: the opt-in gate (with the setting off, client/hello
carries only pairing_psk), the method_not_supported abort when a server
selects a method we do not offer, and both admission notices –
AWAITING_APPROVAL and PAIRING with the six digits grouped 481-010.
Two defects found only on device
Both were invisible to 1,405 unit tests and five code reviews, and both lived in a state TRANSITION rather than in any state:
D1 - time sync collided with pairing. The guard was
if (first && !pairing) { sendPlayerStateUpdate(); startTimeSync() }, which only
withholds on a FIRST activation. The dynamic flow activates with empty
activities first (client starts time sync), then enters pairing while the burst
is still running, so client/time arrived while the server awaited
client/pair-auth. Note the spec does NOT explicitly forbid this: its “Sequence
violations” rule covers only pairing messages out of sequence, and it marks
server/unpair as “valid at any time regardless of the current activities”
while saying no such thing about client/time. The fix rests on “Pairing and
playback are mutually exclusive on a connection” plus the fact that every real
server rejects it – recorded so a later reader does not mistake it for quoted
spec text.
D2 - pairing_index was wrong twice. pairing.md defines it as “the number
of pairing server/activate messages received since the last Noise handshake”.
It was reset only in resetForRehandshake(), never on a fresh handshake, and
the SendSpin instance is reused across reconnects – so it accumulated while
each new server connection counted from 1, producing “pairing_index is ahead of
the server’s count”. It also counted only dynamic_pairing_code activations,
so a pairing_psk activation followed by a dynamic one would have left us
LOWER than the server, which the spec says is silently discarded – the attempt
would simply never have started.