Skip to content

BLE P0 (release-blocking): authenticate the BLE-delivered ML-KEM key against the pinned AK before caching (ADR 0002 rows 8/9) - #628

Merged
cryptskii merged 2 commits into
mainfrom
fix/ble-prepare-kyber-in-signed-transcript
Aug 9, 2026
Merged

BLE P0 (release-blocking): authenticate the BLE-delivered ML-KEM key against the pinned AK before caching (ADR 0002 rows 8/9)#628
cryptskii merged 2 commits into
mainfrom
fix/ble-prepare-kyber-in-signed-transcript

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

RELEASE-BLOCKING — do not merge until two-phone hardware validation passes.
This is a BLE wire-format change (new proto fields). DSM beta redeploys both peers together; there is no back-compat. Both phones must be flashed with this build and a real bilateral transfer must complete before merge.

What this closes

ADR 0002 matrix rows 8/9 — the last open rows. The bilateral BLE prepare handlers cached the peer's wire-supplied ML-KEM (Kyber) key with no authentication: no signature the receiver verified against the pinned AK covered that key. (The commit-phase bilateral-sign transcript signs only domain ‖ commitment_hash — it excludes the Kyber key, and it is transaction authorization, not peer-identity authentication.)

Exploit it closes: anyone who could deliver a prepare frame naming a victim's device_id could seed an attacker Kyber key first-write-wins; every subsequent §11.1 per-step-EK receipt then encapsulated to the attacker's key.

The fix — a detached binding, reusing the one identity primitive

Chosen deliberately over folding the key into the bilateral-sign transcript (that would couple peer-key authentication to the commit-phase signature — wrong layer boundary). This reuses the same verify_kyber_identity_binding primitive already used by storage-fetch and repair, so DSM has one way to authenticate a Kyber key.

  • proto (proto/dsm_app.proto): sender_kyber_binding_sig (BilateralPrepareRequest field 17), responder_kyber_binding_sig (BilateralPrepareResponse field 10).
  • sender: build_local_kyber_identity_binding() signs binding_digest(device_id, genesis, kyber_pk) under the local AK, riding the request, the response, and the non-Android host response path.
  • receiver (verify_and_cache_peer_kyber): verifies the detached binding against the PINNED contact AK — never the wire signing key, never the wire device_id/genesisbefore bind_contact_kyber_key_if_absent.
pinned QR/BLE AK
   └─ verifies kyber_binding_sig over binding_digest(device_id, genesis, kyber_pk)
        └─ only then first-write cache

bilateral-sign is unchanged.

Behavior

condition result
valid binding under pinned AK Kyber key cached (first-write-wins)
empty key / empty sig rejected, nothing cached
forged sig rejected, nothing cached
wrong AK (valid binding, non-pinned signer) rejected, nothing cached
substituted key (valid sig, different wire key) rejected, nothing cached
non-canonical / old-domain digest rejected, nothing cached
no contact record rejected, no TOFU
contact with no pinned AK rejected, no TOFU

Every rejection leaves the prior Kyber cache untouched. Skeleton/echo/test constructors of the prepare messages carry an empty binding (fail-closed at the receiver).

Proof

verify_and_cache_peer_kyber_verifies_before_caching (in bilateral_ble_handler.rs) drives all eight rows above against a real test DB with a real SPHINCS+-signed binding: every rejection asserts the cache stayed empty; the valid case asserts it cached. Mutation-verified — disabling the binding verification turns the test red (then restored).

Gates (local)

  • cargo fmt --all -- --check ✅ · cargo clippy --all-targets -- -D warnings ✅ · scripts/guard_protos.sh ✅ (single source of truth) · ci/production_safety_checks.sh
  • cargo test -p dsm_sdk / cargo test -p dsm — results below.

Still required before merge

Two-phone hardware validation — flash both devices, complete a real bilateral BLE transfer, confirm the receiver caches the peer Kyber key (binding verifies) and that a tampered/absent binding fails closed. Not runnable in CI.

…d AK before caching

ADR 0002 matrix rows 8/9: the bilateral BLE prepare handlers cached the peer's
wire-supplied ML-KEM (Kyber) key with NO authentication — the key was covered by
no signature the receiver verified against the pinned AK (the commit-phase
`bilateral-sign` transcript signs only `domain || commitment_hash`, which
excludes the Kyber key). An attacker who could deliver a prepare frame naming a
victim's device_id could seed an attacker Kyber key first-write-wins, so every
future per-step-EK receipt encapsulated to the attacker's key.

Fix — a DETACHED binding, reusing the ONE identity-binding primitive already used
by storage-fetch and repair (never the commit transcript, which is transaction
authorization, not peer-identity authentication):

- proto: `sender_kyber_binding_sig` (BilateralPrepareRequest, field 17) and
  `responder_kyber_binding_sig` (BilateralPrepareResponse, field 10);
- sender: `build_local_kyber_identity_binding()` signs
  `binding_digest(device_id, genesis, kyber_pk)` with the local AK, riding both
  the request and the response (and the non-Android host response path);
- receiver: `verify_and_cache_peer_kyber` verifies the detached binding via
  `verify_kyber_identity_binding` against the PINNED contact AK — never the wire
  signing key, never the wire device_id/genesis — BEFORE
  `bind_contact_kyber_key_if_absent`.

Fail-closed on every rejection (empty, forged, wrong-AK, substituted-key,
old-domain/non-canonical, no contact, no pinned AK), leaving the prior Kyber
cache untouched; no implicit TOFU. `bilateral-sign` is unchanged.

Proven by `verify_and_cache_peer_kyber_verifies_before_caching`: all eight
rejection cases leave the cache empty and the valid binding caches; mutation-
verified (disabling the verify turns the test red, then restored). Skeleton/echo/
test constructors of the prepare messages carry an empty binding
(fail-closed at the receiver).

Building a prepare now reads the global AppState identity to sign the binding, so
`bilateral_reject_tests` is set to test-isolation mode (in-memory AppState default
-> empty binding, fail-soft); production initialises AppState at startup.

RELEASE-BLOCKING: requires two-phone hardware validation before merge (this is a
BLE wire-format change; DSM beta redeploys both peers together, no back-compat).
…se path

The rows 8/9 API-widening sweep missed the
#[cfg(all(target_os="android", feature="jni"))] BilateralPrepareResponse builder in
bilateral_impl.rs: a host `cargo check --all-targets` compiles the NON-android cfg
branch, so the missing `responder_kyber_binding_sig` field only surfaced in the
aarch64 cargo-ndk device build. The android+jni responder now populates
responder_kyber_public_key + responder_kyber_binding_sig via
build_local_kyber_identity_binding, exactly like the BLE handler and the non-android
host path, so the real on-device response carries the detached binding the receiver
verifies against the pinned AK.

Hardware-proven (8XK<->9FF, release .so): the detached Kyber binding verifies in BOTH
BLE directions and the cached key is consumed successfully by the §11.1 per-step EK
verification (PASS).
@cryptskii
cryptskii marked this pull request as ready for review August 9, 2026 01:27
@cryptskii
cryptskii merged commit 40ba631 into main Aug 9, 2026
12 checks passed
@cryptskii
cryptskii deleted the fix/ble-prepare-kyber-in-signed-transcript branch August 9, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant