Skip to content

feat(bindings): a configured application's air identifiers come from its application key - #693

Closed
bahdotsh wants to merge 1 commit into
feat/leaf-membership-provisioningfrom
feat/app-identifiers-from-app-key
Closed

bahdotsh wants to merge 1 commit into
feat/leaf-membership-provisioningfrom
feat/app-identifiers-from-app-key

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Nov 4, 2026

Copy link
Copy Markdown
Member

Steps 10 and 11 of the shared-network plan (ADR 0037). Stacked on #692.

A configured application (one with a publisher key) no longer shows its application id on the air. Every identifier a carrier exposes outside a frame is derived from K_app under its own label. Unconfigured applications produce exactly the same bytes as before: the legacy app-tag vectors and an independently computed legacy nearby topic still match.

The derivations (control-messages.md, "What the application shows on the air")

ble_app_tag   = HMAC-SHA256(K_app, "offline-app-ble-tag-v1" || device_id_utf8)[..8]
wd_record_app = hex(HKDF(K_app, info="offline-app-wifi-direct-record-v1")[..4])
wd_network    = "DIRECT-op-" || hex(HKDF(K_app, info="offline-app-wifi-direct-network-v1")[..4])
wd_passphrase = hex(HKDF(K_app, info="offline-app-wifi-direct-passphrase-v1")[..16])
nearby_secret = HKDF(K_app, info="offline-app-web-nearby-v1")   (replaces the app id bytes in the room input)

Pinned by the identifiers block of app-membership-v1.vectors.json. The sealed crate, the engine and the browser crate all check it.

Shape

  • One implementation, in the engine. AppIdentifiers lives in protocol/app_identifiers.rs and uses the same K_app as the exchange. The bridges call it, so there are no new hand mirrors. The legacy SHA-256 tag mirrors stay, and run only without a publisher key.
  • UDL: sequence<u8>? ble_app_tag(string device_id) and WifiDirectIdentifiers? wifi_direct_identifiers(). Both return null without a publisher key. Both read a copy taken at construction rather than going through the engine lock, because Android answers App tag reads on a binder thread. A test holds the engine lock and still gets an answer. Every binding is regenerated.
  • Central selection (iOS, Android, web):
    • BleServiceInstanceSelection.selectKeyed: a configured central reads Device id only on tagged instances while it chooses, and takes the first instance whose tag matches. Device id is unauthenticated, so a match only picks an instance.
    • An unconfigured central reads no Device id before choosing. A Robolectric test asserts the exact read list.
  • Wi-Fi Direct: WifiDirectManager takes all three values from wifiDirectIdentifiers(), and falls back to the app-id derivations without a key.
  • Web: bluetooth.js asks the engine for the keyed tag, and worker.js opens nearby rooms through engine.nearbyRoom.

Guards

  • The uniffi guard section 4 checks that BleAppTag has no HMAC on either platform, that the bridges call the engine, and that Device id reads are routed to the choice.
  • The new web guard the_page_takes_keyed_identifiers_from_the_engine checks the page side.

Decisions to review

  1. A label in the BLE tag (offline-app-ble-tag-v1), for domain separation from the hello HMAC. The ADR line reads HMAC(K_app, instance_address) and could use that word.
  2. The tag input is the Device id string as served, not the 21-byte address payload. A central computes it from the bytes it just read, before parsing anything.
  3. The Wi-Fi Direct values still group devices. There is one set per application, because every member must join one group. Someone in range sees which devices share a record, but not which application it is. Threat model R22 is updated in the docs PR.
  4. The browser cannot set a publisher key yet. The keyed browser paths are reachable and tested from Rust only. Exposing appPublisherKey and setAppMembership to the page is a follow-up.

Checks

Rebased onto #692. The UDL, dispatch, local-API, vector and sealed-test conflicts were merged by keeping both sides, and every generated artefact (bindings, C dispatcher, C header, local API table, C#, vectors) was regenerated afterwards.

  • Run after the rebase:
    • cargo clippy --workspace -- -D warnings and cargo test --workspace --lib pass.
    • cargo test -p offline-protocol-uniffi: 319 passed, guards included.
    • cargo test -p offline-protocol-web: 92 of 93 pass. The one failure is no_shipped_file_names_a_relay, and its only offenders are under a stale local .claude/worktrees, so it does not apply to CI.
  • Run before the rebase:
    • Android JVM unit tests: 777 pass.
    • swift test for the iOS sources: 505 pass.
    • The CI iOS typecheck command: rc 0.

Not run: bindings/web/scripts/build.sh and test-browser.sh (the sandbox refused the bash scripts; node --check passed on the three edited JS files), and no device runs. CI runs only once the stack below merges into main.

…its application key

Steps 10 and 11 of the shared network plan (ADR 0037, "Nothing in the
clear"). With `ProtocolConfig.app_publisher_key` set, nothing a carrier
shows outside a frame is derived from the application id any more: the
Bluetooth LE App tag, the Android Wi-Fi Direct group name, passphrase and
`app` record value, and the browser nearby room are all derived from
`K_app`, which another application's developer cannot compute. With no
publisher key every value is byte for byte what it was.

One implementation, in the engine. `AppIdentifiers`
(offline-protocol/src/protocol/app_identifiers.rs) derives each value
from the same `K_app` the membership exchange uses, under a label of its
own from `offline-protocol-sealed::membership`:

- App tag: HMAC-SHA256(K_app, "offline-app-ble-tag-v1" || device_id)[..8],
  over the Device id exactly as served, so it is per instance and groups
  nothing;
- Wi-Fi Direct: HKDF(K_app) under "offline-app-wifi-direct-record-v1",
  "-network-v1" and "-passphrase-v1", in the shapes the application-id
  values had;
- nearby: a 32-byte secret under "offline-app-web-nearby-v1" replaces the
  application id at the front of the room's input.

The bridges ask for it rather than mirroring an HMAC: two new UDL
methods, `ble_app_tag(device_id)` and `wifi_direct_identifiers()`, both
null without a publisher key, answered from a copy taken at construction
so the Android peripheral's binder-thread App tag read never waits on the
engine lock. The browser asks its engine (`bleAppTagIsKeyed`,
`bleKeyedAppTag`, `nearbyRoom`); the page cannot configure a publisher
key yet, so its keyed paths are exercised from Rust only.

The central selection rule changes for a configured central only: with
several instances it also reads Device id on each tagged instance and
takes the first whose tag equals the keyed tag over that Device id, then
rules 2 and 3 as before. A Device id read while choosing is routed to the
choice, never the handshake, and one answered late from an unchosen
instance is dropped. An unconfigured central reads no Device id before it
chooses, as before.

Vectors: an `identifiers` block in app-membership-v1.vectors.json,
computed by tools/spec-vectors/generate.py over hashlib, checked by the
sealed crate, the engine and the browser crate; the legacy nearby topic
is pinned from an independent computation. Selection tests on both
platforms and Robolectric tests of the Android probe; a source guard pins
that every bridge takes the keyed values from the engine and that
BleAppTag computes no HMAC.

Every binding regenerated: Swift, Kotlin, Python, Ruby, Java, the C
dispatcher, the C# generated sources and the Python local API table. The
two methods are platform operations on the local API: carrier drivers
read them, and the passphrase is a secret no client needs.

Specs: ble-framing.md (the two App tag forms and the keyed selection
rule), control-messages.md (the identifier derivations), web.md,
local-api.md.
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