Repository navigation
Conversation
1c4e493 to
b28f166
Compare
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>
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>
|
Correction to the Legacy AES paragraph above: the path after Since this was opened, 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 (
Still untested on hardware: the plain-outer leg after 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. |
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.SolixBLE/protocols/session.py,PATHS = (EcdhPath, LegacyAesPath)): each path hasmatches(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 plain0001and is never reached by stepping down from4001.a3 & 0x44with an auth method ina5(every current build), on either outer.a1 & 0x02on the plain outer, e.g. the oldera1 02·a3 04· noa5reply in F3800 (A1790) fails encryption negotiation — device expects a newer key exchange (a5=02,4022/4822/4023/4823) instead of0021/0821#63.0005states AES (a5 02); after0805 00the client keys CBC on its client id's first 16 bytes with the device serial's first 16 bytes as IV and sends4022(clock);4822returns00 a1 10 <16-byte key>, which becomes the session key with the same IV; then4023binds the client (a2client id,a3serial), as the app sends it.x805status other than00, anx821without the device key, or anx822without a key raiseUnsupportedNegotiation(now exported), andconnect()returnsFalseat 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.protocols.rst"Paths" (thex803table and how to add a path).Legacy AES: the opening, the
0005, the initial key and IV, and the4822layout match the Anker app's own legacy sessions with an older Prime module, decrypted end to end. The switch to the4822key (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 after4822is untested on my own hardware. #63 F3800 owners: could you tryF3800(ble_device)on this branch and post the debug log? The tests use synthetic values only.Also:
EcdhPathandLegacyAesPathsharerecord_identityandsend_clockfromprotocols/base.py;tests/helpers.pygainsfeed_negotiation.Suite: 479 passed (base: 468). mypy
--strict: 202 errors, unchanged from the base.