Skip to content

feat(leaf): a paired phone provisions its leaf with application membership - #692

Closed
bahdotsh wants to merge 4 commits into
feat/app-gate-guardsfrom
feat/leaf-membership-provisioning
Closed

bahdotsh wants to merge 4 commits into
feat/app-gate-guardsfrom
feat/leaf-membership-provisioning

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Nov 4, 2026

Copy link
Copy Markdown
Member

Step 9a of the shared-network plan (ADR 0037). Stacked on #691.

A leaf can't fetch a certificate, so its paired phone hands it one inside their 1:1 session. From then on the leaf runs the membership exchange like any other node.

Sealed

  • MemberProvision: base64 of version(1)=1 || kind(1)=3 || publisher_key(32) || cert(93) || u64be(supplied_time), 135 bytes. Kind 3 is not a __MEMBER__ step, so neither parser accepts the other's body.
  • The new prefix __MEMBER_PROVISION__ is reserved, added to the pinned list and the shadow test.
  • Vectors: a provision block and five rejects in app-membership-v1.vectors.json.
  • The leaf and the engine stamp the one constant, WIRE_APP_ID.

Phone (engine and UDL)

  • provision_leaf_membership(peer, cert) -> MessageId:
    • It needs a publisher key, and the certificate must name the leaf, verify and be current.
    • It seals into the confirmed session only and never starts a session.
    • It sends on the reliable ladder, then restarts the exchange with the leaf.
  • Every binding is regenerated (Swift, Kotlin, Python, Ruby, Java, C dispatcher, C#, local API table). The method is classified PLATFORM.
  • A bare __MEMBER_PROVISION__ on the wire, or in a decrypted plaintext on a node that is not a leaf, is consumed and acted on by nothing.

Leaf (offline-protocol-leaf/src/membership.rs)

  • Accepting: it acts on provisioning only as a decrypted application message, after the sender's credential is bound. The certificate must name the device, verify and be current at the supplied time.
  • Ordering: it stores the record before group.write_to_storage(), and the acknowledgement is minted after both. A failed store leaves no group write and no ack, so the phone's retransmission still opens.
  • The exchange: it mirrors the engine rule for rule, and stamps offline on the wire after provisioning. It uses mls-rs crypto only, with no new dependency.
  • Clock: the leaf reads none. "Supplied time" is the later of the provisioning time and the now firmware passes in, and it never moves backwards.
  • Firmware: it calls LeafDevice::neighbor_discovered(peer, now) for each neighbour its radio connects.
  • Bounds: 16 exchanges and 16 visible members. A corrupt stored record counts as unprovisioned, so a bad record cannot brick the device.

Found while stacking it on the inbound gate (#689)

Because of the inbound gate, a configured phone drops an unprovisioned leaf's key package unless its application named the leaf. The application does that through the out-of-band anchor step, by asking for a session with the address it verified (establish_secure_session). That call names the peer even before any key package has arrived.

  • docs/spec/leaf-provisioning.md states this.
  • The test rig models it.
  • a_configured_phone_never_pairs_a_leaf_its_application_did_not_name pins the refusal. Mutation-checked: forcing the inbound gate open makes it fail.

Decisions to review

  1. Any paired peer's session can provision the leaf, and the latest one wins. A single-owner rule would sit on top.
  2. The leaf admits __MEMBER__ under the older control payload, as it already does for __MLS_KEY_PKG__. Otherwise a provisioned leaf would be invisible to members it never paired with. Freshness comes from the nonces.
  3. An unprovisioned leaf remembers the last hello from each neighbour (in memory, at most 16) and answers it once provisioning lands. Until then it sends and writes nothing.

Checks

  • cargo test --lib: offline-protocol 2222, sealed 108, leaf 6 and uniffi 318, all passing.
  • cargo clippy --workspace -- -D warnings, rustdoc under -D warnings and fmt all pass.
  • Bare-metal clippy for sealed and leaf passes on thumbv6m and riscv32imc (the agent also ran all four targets plus WASI).
  • Every generator's --check passes.

CI runs only once the stack below merges into main.

…MEMBER_PROVISION__

A leaf cannot fetch a certificate, so its paired phone hands it one inside
their 1:1 session (ADR 0037, "Leaves"). `MemberProvision` is that body:
version 1, kind 3, the publisher key, the leaf's certificate and the
phone's clock, 135 bytes, fixed layout. Kind 3 is no `__MEMBER__` step, so
neither parser reads the other's body.

`prefixes::MEMBER_PROVISION` names the prefix it travels under, inside an
`__MLS_ENC__` envelope only. `CONFIGURED_WIRE_APP_ID` is the one spelling
of the constant wire app id a configured node stamps, so the engine and a
leaf cannot split into two groups by the field meant to name none.

Vectors: a `provision` block and five rejects in app-membership-v1,
computed by tools/spec-vectors/generate.py.
Step 9a of the shared network plan (ADR 0037, "Leaves").

- A `__MEMBER_PROVISION__` body is acted on only as a decrypted
  application message in the pair's session, after the sealing member is
  bound to the sender. The certificate must name this device and verify
  under the publisher key it came with, and be current at the supplied
  time. It is written to the store before the group state that spends the
  frame's generation, and acknowledged after both: a refusal or a failed
  store leaves the device unprovisioned, unacknowledged and able to open
  the retransmission.
- `K_app` is derived from the publisher key and the app id the device was
  opened with, exactly as the engine does. The exchange follows the
  engine's rules step for step, with mls-rs primitives (ChaCha20-Poly1305
  from the provider's ChaCha suite). `__MEMBER__` frames are never
  acknowledged and are admitted under the older control payload, since a
  neighbour that never paired with the device never learned it verifies
  the newer one and the exchange's freshness is in its nonces.
- No clock is read: certificates are checked against the latest supplied
  time, the provisioning's (never backwards) or the caller's, whichever
  is later.
- Once provisioned, every frame stamps the constant wire app id
  "offline". The provisioning survives a power cut.
- An unprovisioned device is unchanged: it sends no membership frame,
  answers none and writes nothing. It remembers the last hello each
  neighbour sent, in memory, so the exchange is answered the moment a
  provisioning lands.

New API: `LeafDevice::neighbor_discovered`, `is_member_visible`,
`membership_not_after`; `LeafEvent::{MembershipProvisioned, MemberVisible}`;
`LeafError::MembershipRefused`; `store::KEY_TYPE_MEMBERSHIP`.
…embership

`OfflineProtocol::provision_leaf_membership(peer, cert)` checks the
certificate against the rules the leaf applies (it names the leaf,
verifies under the configured publisher key, is current), then seals the
publisher key, the certificate and this node's clock inside the confirmed
1:1 session with the leaf and puts it on the reliable ladder. It never
starts a session: provisioning outside the pair's session is exactly what
the frame must not be. It then restarts the membership exchange with the
leaf, since the hello sent when the phone met it went unanswered and
would hold off a new one for the whole interval.

`__MEMBER_PROVISION__` is reserved, so application text cannot carry it,
and an engine consumes it wherever it finds it, bare or sealed: an engine
takes its own certificate from the application.

Across the FFI as `provision_leaf_membership(peer_id, cert) -> string`.
Every binding is regenerated from the one definition (Swift, Kotlin,
Python, Ruby, Java), with the C dispatcher, the Python local API table and
the C# generated sources; the method is a platform operation on the local
API, like the rest of application membership.

Tests over a real engine and a real leaf: the two see each other once
provisioned and the provisioning is acknowledged; a member that never
paired sees a provisioned leaf across a power cut; a provisioning frame
outside the session provisions nothing; a store that cannot keep it means
no acknowledgement and no visibility, and the retransmission provisions it
once the store recovers; an unprovisioned leaf is unchanged.
…amed

Under the inbound gate a configured phone drops an unprovisioned leaf's
key package unless the application named the leaf, which it does by asking
for a session with the address the anchor verified. The pairing rig models
that step, a new test pins the refusal, and the leaf provisioning chapter
states it. The leaf and the engine stamp one wire constant, WIRE_APP_ID.
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.

1 participant