fix(python): declare off1 address to relay (Internet transport parity with Swift) - #404
Merged
Merged
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
kivtxs
force-pushed
the
fix/python-internet-declare-address
branch
from
August 21, 2026 20:05
2eeca96 to
e9a90d4
Compare
Member
Author
|
I have read the CLA Document and I hereby sign the CLA |
bahdotsh
force-pushed
the
fix/python-internet-declare-address
branch
from
August 22, 2026 20:10
36ee072 to
8da0a69
Compare
The Python Internet transport authenticated the WebSocket connection but never
answered the relay's address-routing challenge, so a Python node's off1 address
was never bound on the relay. The relay could therefore not route messages
addressed to that node, which breaks addressed sends and service discovery over
the Internet transport. The Swift transport (AddressDeclarationPolicy +
InternetManager) already performs this handshake, so this was a Python/Swift
parity gap.
On `Authenticated`, when the relay advertises the `address_routing_v1`
capability and includes an `address_challenge`, sign the domain-separated proof
("offline-relay-addr-v1" || u32be(len(account)) || account || challenge) with the
node identity key and reply with `DeclareAddress`, matching the Swift
implementation and the relay's `address_binding::address_proof_payload`. Also log
the relay's `AddressDeclared` / `AddressDeclarationRefused` responses.
Additive and non-destructive: relays that do not advertise the capability or
omit the challenge see no change, and any failure in the new path is caught and
leaves the authenticated session intact (unrouted, as before).
Validated against relay-server: the node's off1 address now binds on connect.
The parent commit taught the Python Internet transport to answer the
relay's address challenge. On a quiet connection it works. On a real
one it mostly doesn't.
The declaration went out *after* internet_status_changed(true), which
is the call that flushes the outbox. The relay attributes each frame
by whatever the connection has proved at the moment it reads that
frame and never re-stamps retroactively, so everything in a reconnect
burst stays attributed by account name, and its address-stamped
Message.sender then fails validate_transport_sender at the receiver.
That drops precisely the key-package and welcome frames a new session
needs, which means the bug this PR set out to fix survived every
reconnect. The declaration goes first now and frame ordering does the
rest.
The refusal arm listened for "AddressDeclarationRefused". That is the
name of the FFI method, not the frame. The relay sends "AddressError",
so every refusal fell through to the unhandled-message branch.
Neither answer reached the core at all, which quietly disabled the
binding-mismatch lockstep check that is the entire security payoff of
declaring in the first place. Both go to their dedicated FFI entry
points now.
The signed account name came from msg.get("username", device_id), and
the declared address was re-derived from the identity key rather than
read from local_address(). Both are wrong in the same direction. The
relay verifies the proof against the name *it* resolved, so signing a
local substitute yields a signature that cannot verify and reads as an
attack in its logs; and the core compares the relay's echo against
local_address(), so declaring anything else is a node raising a
security event against its own proof. There is already a Rust guard
forbidding that username fallback in the Swift and Kotlin bridges.
Python had it anyway.
The byte layout moved into address_declaration.py, next to the Swift
and Kotlin AddressDeclarationPolicy it mirrors, because a cross-repo
contract inlined at a call site is one nobody can pin. It is pinned
now, against the relay's own hex vector.
While at it: the relay's advertised capabilities were parsed and
dropped on the floor, so they now reach the SDK ahead of the status
flip, empty list included, and a stale set cannot leak across
connections. A scalar capabilities field no longer satisfies the gate
by substring match, and nothing is declared onto a socket that got
swapped out underneath it.
All of it is mutation-tested. Worth admitting that the first version
of the device-id test passed against the bug: the relay serializes
username as an explicit null, and dict.get() ignores its fallback for
a key that exists, so only the *absent* key ever exercises it. Please
check that your gate tests actually gate.
bahdotsh
force-pushed
the
fix/python-internet-declare-address
branch
from
August 22, 2026 20:13
8da0a69 to
6591449
Compare
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Problem
The Python Internet transport (
bindings/python/offline_protocol_sdk/internet_manager.py) authenticates the WebSocket connection to the relay ({"type":"Authenticate","token":…}) but never answers the relay's address-routing challenge. As a result a Python node'soff1…address is never bound on the relay, so the relay cannot route messages addressed to it. Addressed sends and Service Discovery over the Internet transport therefore never reach a Python node.The Swift transport already implements this handshake (
bindings/react-native/ios/AddressDeclarationPolicy.swift+InternetManager.swift), so this was a Python/Swift parity gap, not a protocol change.Repro
Bring up
relay-server(it advertises theaddress_routing_v1capability and mints anaddress_challengein itsAuthenticatedframe). Connect a Python node via the Internet transport and connect an RN/Swift node. The Swift node logsBinding address off1… to user …; the Python node authenticates but its address is never bound — confirmed in the relay'sconnection_managerlogs.Fix
On
Authenticated, when the relay advertises theaddress_routing_v1capability and includes anaddress_challenge, sign the domain-separated proof and reply withDeclareAddress:This matches the relay's
address_binding::address_proof_payloadand the SwiftAddressDeclarationPolicybyte-for-byte (domain, big-endian length prefix, standard-padded base64). The relay'sAddressDeclared/AddressDeclarationRefusedresponses are now logged.Why it's safe (additive, non-destructive)
address_routing_v1and sends a non-empty 32-byte challenge and a username is present. Relays without the capability (or that omit the challenge) see byte-identical behavior to today._handle_authenticated/_safe_handle_authenticatedparams are optional with defaults.Validation
Ran a Python node against
relay-serverwith this fix. The relay now logs on connect:i.e. the node's
off1address binds and becomes routable, matching Swift-node behavior. Without the fix, theBinding address …line never appears for a Python node.