Skip to content

feat(paths): Choose the negotiation path from the device's capability reply - #80

Draft
kb1ibt wants to merge 12 commits into
flip-dots:mainfrom
kb1ibt:pr/paths
Draft

kb1ibt wants to merge 12 commits into
flip-dots:mainfrom
kb1ibt:pr/paths

Conversation

@kb1ibt

@kb1ibt kb1ibt commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Last of four stacked PRs replacing #70; based on the negotiation PR. The device's capability reply (x803) now picks the key-establishment path, so a device the library doesn't speak fails fast with an error naming what it declared, and the older AES-only modules (#63) get their own path.

  • Path registry (SolixBLE/protocols/session.py, PATHS = (EcdhPath, LegacyAesPath)): each path has matches(announcement, outer); the first that matches runs the rest of the negotiation. A path can depend on the outer, so legacy AES is offered only after a plain 0001 and is never reached by stepping down from 4001.
  • ECDH matches a3 & 0x44 with an auth method in a5 (every current build), on either outer.
  • Legacy AES matches a1 & 0x02 on the plain outer, e.g. the older a1 02 · a3 04 · no a5 reply in F3800 (A1790) fails encryption negotiation — device expects a newer key exchange (a5=02, 4022/4822/4023/4823) instead of 0021/0821 #63. 0005 states AES (a5 02); after 0805 00 the client keys CBC on its client id's first 16 bytes with the device serial's first 16 bytes as IV and sends 4022 (clock); 4822 returns 00 a1 10 <16-byte key>, which becomes the session key with the same IV; then 4023 binds the client (a2 client id, a3 serial), as the app sends it.
  • Rejections: no matching path, an x805 status other than 00, an x821 without the device key, or an x822 without a key raise UnsupportedNegotiation (now exported), and connect() returns False at once, logging the declared values (base_method, encrypt_method, auth_method, mtu) and the stage. Before, such a device was answered with the ECDH steps regardless and ran into the negotiation timeout.
  • Docs: protocols.rst "Paths" (the x803 table and how to add a path).

Legacy AES: the opening, the 0005, the initial key and IV, and the 4822 layout match the Anker app's own legacy sessions with an older Prime module, decrypted end to end. The switch to the 4822 key (IV unchanged) is how the device firmware installs it, and atc1441's Anker_Prime_BLE_hacking tool rekeys the same way; I have no device on that firmware, so the path after 4822 is untested on my own hardware. #63 F3800 owners: could you try F3800(ble_device) on this branch and post the debug log? The tests use synthetic values only.

Also: EcdhPath and LegacyAesPath share record_identity and send_clock from protocols/base.py; tests/helpers.py gains feed_negotiation.

Suite: 479 passed (base: 468). mypy --strict: 202 errors, unchanged from the base.

kb1ibt and others added 4 commits October 7, 2026 16:32
A device holds a negotiated session per connection (SolixBLE.protocols): an outer protocol (PlainOuter: 0001 in clear, CBC session; EncryptedOuter: 4001 under the static GCM key, GCM session) and the ECDH path after the shared opening. The path sends a fresh P-256 key every negotiation, states its x005 method as the app does per outer, and sends x022 with the UTC offset as int32 seconds west and the POSIX time zone.

The outer comes from the advertisement's capability byte when given, else the outer that last authorized on this instance, else the model's default. A device that drops the link before answering a plain 0001 refuses it: connect() reopens once with the encrypted outer and never steps down. Encrypted sessions authorize at 4827 00 or the first decryptable session push; 4827 on 030101 reaches the same handler, and a 09 status and its window are recorded on device.announcement. The opening re-send and deadline sit behind _negotiation_should_restart() / _negotiation_deadline().

PrimeDevice keeps its UUID, the encrypted default and _post_authorize. Prime commands use the base's typed fe 05 03 trailer, and 420a carries the region (set_region(), else the host locale's, else GB) typed 02 and this client's identifier as the owner, as the app sends it. The changed test frames are derived from the recorded sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A device that refuses the plain 0001 can drop the link before the write returns, and the write then raises BleakError out of connect(). If the link is down by then, the error is the refusal: the negotiation sees a dropped link and connect() reopens with 4001, as when the drop lands after the write. Seen on an A1783.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Keys is a construct Struct over the shared secret (key, IV, 12-byte GCM nonce); the static key parses the key and nonce the same way.
- The announcement is a construct Container with the same fields; device.announcement keeps its shape.
- x803, x827 and the x005, x003 and x022 values parse and build through Int8ul, Int16ul and Int32sl; the client's a3 and MTU ceiling are named constants.
- install_session_keys leaves the keys alone for an empty secret, as the no-packets case expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The x803 reply picks the key-establishment path from a registry (PATHS = EcdhPath, LegacyAesPath); each path's matches(announcement, outer) decides, so legacy AES is offered only after a plain 0001 and never by stepping down from 4001.

ECDH matches a3 & 0x44 with an auth method in a5, on either outer. Legacy AES (flip-dots#63) matches a1 & 0x02 on the plain outer: 0005 states AES, CBC keyed on the client id and serial carries 4022, the key in 4822 becomes the session key with the same IV, and 4023 binds the client.

No matching path, a rejected x805, or an x821/x822 without a key raises UnsupportedNegotiation (now exported); connect() returns False at once, logging the declared values and the stage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
matches() no longer requires the plain outer: the A91B2 bench test
(a91b2-3.log, 2026-10-08) confirmed a device with x803 a5 00 accepts
a legacy method choice in x005 on the encrypted outer too, approved
the same way by x805. The bootstrap key differs by outer (the static
key on the encrypted one, the client id and serial on the plain one),
and the encrypted outer can only produce GCM on its own, so the
bootstrap exchange needs a cipher independent of the outer's normal
one. Path gets a new encrypt_override hook for this instead of
teaching session.py about a specific path's internals; the default
never overrides, so EcdhPath and session.py stay unchanged. x005 now
goes through the outer's own x005_tags() instead of hardcoding the
plain-outer shape, so a6 (the device's auth method, echoed back) is
no longer dropped on the encrypted outer.

NEGOTIATION_NONCE is corrected to its full 16-byte form (GCM already
only reads the first 12 via Keys.nonce); ANKER_DEFAULT_ACCOUNT_1 is
added as the documented no-a2 bootstrap default, not yet reachable
from this path since the library always sends its own client id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kb1ibt and others added 7 commits October 9, 2026 23:37
A command reply was matched on its literal pattern and cmd, so get_status_update() listening for c840 missed the same reply sent unfragmented (4840), in the clear (0840), or under another composer, and timed out.
Futures are now keyed by the pattern's channel and the cmd's message type, as session messages are routed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the flip-dots#79 diagrams in their path-aware form, both paths' reply tables, and a note on how the A91B2 answers each outer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kb1ibt

kb1ibt commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Correction to the Legacy AES paragraph above: the path after 4822 has now been tested on hardware.

Since this was opened, LegacyAesPath runs on either outer (3cda650), so the body's "on the plain outer" / "offered only after a plain 0001" no longer hold. On the encrypted outer the path bootstraps on the static negotiation key and IV instead of the client id and serial.

I ran it end to end on a Prime Charging Station 240W (A91B2). The library connects that device with ECDH on the plain outer, as its capability byte (00) says, but its 0803 (a1 02 · a3 44 · a5 00) also offers AES, and on the encrypted outer it accepts it:

  • 4005 states AES (a5 02); 4805 00 approves it.
  • 4022 (clock), CBC-sealed under the static key and IV, gets 4822 00 a1 10 <16-byte key>.
  • The session then runs GCM under that key with the static nonce.

test_legacy_aes_path_on_the_encrypted_outer checks the real 4022/4822 pair from that run, so the tests are no longer synthetic-only.

Still untested on hardware: the plain-outer leg after 4822 (CBC under the 4822 key, serial as IV), which is the #63 case. #63 F3800 owners: the request above still stands.


And the best part: the A91B2 test I mentioned above was done via #81 , so it not only demonstrated that the CLI works, but that I was able to try alternate methods of connecting that are not the library standard.

This branch has not been deployed

No deployments
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.

2 participants