Skip to content

fix(python): declare off1 address to relay (Internet transport parity with Swift) - #404

Merged
bahdotsh merged 2 commits into
mainfrom
fix/python-internet-declare-address
Aug 22, 2026
Merged

fix(python): declare off1 address to relay (Internet transport parity with Swift)#404
bahdotsh merged 2 commits into
mainfrom
fix/python-internet-declare-address

Conversation

@kivtxs

@kivtxs kivtxs commented Aug 21, 2026

Copy link
Copy Markdown
Member

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's off1… 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 the address_routing_v1 capability and mints an address_challenge in its Authenticated frame). Connect a Python node via the Internet transport and connect an RN/Swift node. The Swift node logs Binding address off1… to user …; the Python node authenticates but its address is never bound — confirmed in the relay's connection_manager logs.

Fix

On Authenticated, when the relay advertises the address_routing_v1 capability and includes an address_challenge, sign the domain-separated proof and reply with DeclareAddress:

payload = "offline-relay-addr-v1" || u32be(len(account.utf8)) || account.utf8 || challenge
DeclareAddress { address, public_key(b64), signature = sign(payload) }

This matches the relay's address_binding::address_proof_payload and the Swift AddressDeclarationPolicy byte-for-byte (domain, big-endian length prefix, standard-padded base64). The relay's AddressDeclared / AddressDeclarationRefused responses are now logged.

Why it's safe (additive, non-destructive)

  • No-op unless invited: runs only when the relay advertises address_routing_v1 and 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.
  • Never breaks auth: the entire new path is wrapped; any failure emits a diagnostic and leaves the connection authenticated (just unrouted, exactly as before).
  • One file, backward-compatible signatures: the new _handle_authenticated / _safe_handle_authenticated params are optional with defaults.

Validation

Ran a Python node against relay-server with this fix. The relay now logs on connect:

Registering user: <node> (conn N)
Binding address off1q9eqy0ww55qxm8ve0jv8gxpxknay7fkj9veg0swe to user <node> (conn N)

i.e. the node's off1 address binds and becomes routable, matching Swift-node behavior. Without the fix, the Binding address … line never appears for a Python node.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@kivtxs
kivtxs force-pushed the fix/python-internet-declare-address branch from 2eeca96 to e9a90d4 Compare August 21, 2026 20:05
@kivtxs

kivtxs commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Aug 21, 2026
@bahdotsh
bahdotsh force-pushed the fix/python-internet-declare-address branch from 36ee072 to 8da0a69 Compare August 22, 2026 20:10
kivtxs and others added 2 commits August 23, 2026 01:42
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
bahdotsh force-pushed the fix/python-internet-declare-address branch from 8da0a69 to 6591449 Compare August 22, 2026 20:13
@bahdotsh
bahdotsh merged commit d0edf3c into main Aug 22, 2026
25 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants