Skip to content

feat: secure-element offline-bearer DeviceAnchor (host-side Phase 5)#493

Open
cryptskii wants to merge 12 commits into
mainfrom
feat/secure-element-offline-bearer-attestation
Open

feat: secure-element offline-bearer DeviceAnchor (host-side Phase 5)#493
cryptskii wants to merge 12 commits into
mainfrom
feat/secure-element-offline-bearer-attestation

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

Summary

Wires the offline-bearer DeviceAnchor gate into bilateral finalize, so an offline-bearer transfer requires a hardware secure-element (Safe 7 / TROPIC01) island signature, folded append-only into the §16.6 successor tip, fail-closed. This is the host half of the anti-clone feature: a software clone on different hardware cannot continue a value lineage because it cannot produce the island attestation.

Non-attested transfers are byte-identical to before (append-only-when-present encoding; None → identical bytes, proven by a strict-prefix KAT). The mode is dormant until a caller injects an anchor + sets the OBR policy.

What's in it

  • attestation: dsm_ui_transcript (consent-oracle binding), offline-bearer payload hash, compute_anchor_set_id / canonical_anchor_set, compute_anchor_proof_hash (digest form — reconstructable from the receipt), canonical_signature_bundle; ui_transcript folded into the island challenge.
  • crypto: async AnchorTransport trait + Ed25519 verify_anchor_signature + gated MockAnchorTransport.
  • types/operations: AuthorityPolicy on Operation::Transfer, append-only canonical encode / symmetric decode. Trigger is per-Operationnot value_capability, not inside pre_commit.
  • bilateral: compute_successor_tip_attested (append-only; None delegates to the unchanged tip), run_offline_bearer_gate (6 fail-closed rejects), anchor_proof_hash_from_receipt, finalize wiring (no DeviceState mutation — the caller flips Attested and supplies value_capability).
  • device_state: IslandAttestation widened (id_anchor_set, ui_transcript_hash, policy_id) for receipt reconstructability; compute_chain_tip fold unchanged.
  • proto + chain_segment: IslandAttestationProto fields 4/5, both codec sites.

Tests

  • 6-case gate unit test + finalize_with_offline_bearer_required_attests_or_fails_closed e2e (admitted-anchor → attested; no-anchor → hard reject; byte-identity KAT).
  • dsm lib 1600/0, dsm_sdk lib 1498/0.

Known follow-up (before activation)

The dsm_sdk storage codec (storage/codecs.rs, smart_commitment_sdk.rs op_fingerprint) does not yet persist authority_policy (encode ignores it, decode sets None). Harmless while the mode is dormant, but offline-bearer ops won't round-trip through that codec until it's extended.

Device side (separate repo)

The Safe 7 firmware (dsm_anchor app) cut-1a is proven on the T3W1 emulator (2/2 device tests: get_identity, sign + host-verify, tamper-reject) and lives in the trezor-firmware fork — not in this PR.

cryptskii added 12 commits June 11, 2026 00:07
Adds research-only native capture + on-device collectors used to test
whether the C-DBRW orbit timing channel separates same-make/same-model
devices (3x Galaxy A16, mt6789). NOT a production path; no K_DBRW or
enrollment-schema change.

- siliconfp.cpp: captureOrbitRaw() exposing the walk-altering knobs
  (rotation r, mu injection cadence k, per-core lane via verified pin)
  and returning raw per-probe CNTVCT timing + per-probe perf-cycle
  deltas (for a two-clock ratio) + mu histogram, for host-side
  extractor sweeps.
- SiliconFingerprintNative.kt: captureOrbitRaw binding.
- SiliconFpExtractorSweepTest / SiliconFpScaleSweepTest: arg-driven
  collectors (rotation x cadence x lane x altitude).

Finding (3-device, no-pair-collapse gate, shuffle control): the timing
channel does NOT provide same-model separation — matched warm-state
pair collapses to ~1.08x; large gaps were transient thermal/DVFS state
(fresh device), shrinking as state matched. Die-to-die variation is
real but below this channel's noise floor; finer instruments
(perf_event_open) are SELinux-denied to untrusted_app. Same-model
anti-cloning rests on K_DBRW + per-device salt; the orbit remains a
SoC-family / liveness signal. Full evidence in
.claude/traces/sessions/TRACE-2026-06-11-001.json.
Secure-element attestation (Trezor Safe 7 / TROPIC01) becomes the controlling path for the optional offline-bearer authority tier, replacing C-DBRW silicon binding (retired per the dsm_anticlone ceiling theorem: no software-only physical channel can bind a transition against an off-device clone).

- crypto/classical_verify.rs: scoped classical verifiers (Ed25519 TROPIC01, ECDSA-P256/SHA256 OPTIGA), fenced to external attestation; DSM's own crypto stays PQ (BLAKE3/SPHINCS+).
- attestation/: frame_authenticate_device, dsm_island_challenge, verify_island_attestation (X.509 chain walk to pinned Safe 7 production roots; dev devices rejected), per-transition verify_island_intent_signature, SettlementPath decision (failure falls back to online, never a hard reject).
- types/device_state.rs: IslandAttestation folded into compute_chain_tip append-only-when-present (zero canonical change for non-attested tips); OfflineBearerAttestation capability (deny-unless-proven, reset-on-recovery).
- recovery/: faithful RelationshipChainStateProto codec carries island_attestation.
- proto/dsm_app.proto + frontend dsm_app_pb.ts: IslandAttestationProto (field 11).
- frontend: remove the C-DBRW dev screen + dbrw trust-protocol UI.
- formal: reconcile lean4/tla code-correspondence with current code.

Offline-bearer gate is implemented but activation-gated pending host transport. 1564 lib tests + clippy clean.
…to mod/signatures; update android manifest; prune stale docs
…(Phase 5)

Offline-bearer transfers now require a hardware-island signature that is
folded into the §16.6 successor tip, fail-closed. Non-attested transfers stay
byte-identical (append-only-when-present encoding, proven by strict-prefix KAT).

- attestation: dsm_ui_transcript (consent-oracle binding), dsm_offline_bearer
  payload hash, compute_anchor_set_id / canonical_anchor_set, OfflineBearerMode,
  compute_anchor_proof_hash (digest form — reconstructable from the receipt),
  canonical_signature_bundle; ui_transcript folded into the island challenge
- crypto: AnchorTransport trait (+ Ed25519 verify helper, gated MockAnchorTransport)
- types/operations: AuthorityPolicy on Operation::Transfer with append-only
  canonical encode/symmetric decode (trigger is per-Operation, NOT value_capability,
  NOT inside pre_commit)
- bilateral: compute_successor_tip_attested (append-only; None delegates to the
  unchanged tip), run_offline_bearer_gate (6 fail-closed rejects),
  anchor_proof_hash_from_receipt, finalize wiring (no DeviceState mutation;
  caller flips Attested + supplies value_capability); manager anchor_transport field
- device_state: IslandAttestation widened (id_anchor_set, ui_transcript_hash, policy_id)
  for receipt reconstructability; compute_chain_tip fold unchanged
- proto + chain_segment: IslandAttestationProto fields 4/5, both codec sites
- tests: 6-case gate unit test + finalize_with_offline_bearer_required_attests_or_
  fails_closed e2e (admitted->attested, no-anchor->hard reject, byte-identity KAT)

dsm lib 1600/0, dsm_sdk lib 1498/0.

Known follow-up before offline-bearer activation: the dsm_sdk storage codec
(storage/codecs.rs, smart_commitment_sdk.rs op_fingerprint) does not yet persist
authority_policy (encode ignores it, decode sets None) — harmless while the mode
is dormant, but offline-bearer ops will not round-trip through that codec until
it is extended.
…one-per-device frontier (host)

Track 0 + Track 1 host side of the secmon-gated offline-bearer anchor.

- 9-field stateful receipt: proto IslandAttestationProto fields 6-12 + IslandAttestation
  + chain_segment codec, append-only. compute_chain_tip fold unchanged
  (id_island/signature/policy_id only) -> byte-identity preserved.
- Challenge binding: dsm_island_challenge folds a receipt_commit over
  {anchor_pubkey_hash, firmware_hash, policy_hash, parent_root, successor_root,
  state_number}; a tampered field fails verification. firmware_hash==enrolled is
  enforced implicitly (verify uses the pinned record.firmware_hash).
- Anchor frontier is ONE per device (keyed by id_anchor, NOT per relationship):
  parent_root = the device's single stored_root; successor_root =
  BLAKE3(parent_root || op || state); the gate reads + CAS-advances the single
  frontier, fail-closed on a forked/non-monotonic advance.
- ChainTipStore gains get/set_anchor_frontier (default no-op; SDK/Noop untouched).

1602 dsm lib tests green, dsm_sdk builds clean, 0 regressions. Firmware app (Track 2),
secmon gate (Track 3), and hardware enroll (Track 4) pending.
…pt verifier

The adversarial gate review found the offline-bearer anchor check is SENDER-only and the
receiver never pins/re-verifies the enrolled identity: verify_anchor_signature folds the
DEVICE-self-reported firmware_hash circularly, and the host frontier CAS defaults to a no-op. So a
reflashed firmware with a fresh self-provisioned identity (or an erase-then-reprovision re-genesis)
would be accepted by a receiver. This adds the receiver-side invariant as tested code.

crypto/anchor_enrollment.rs:
- AnchorEnrollment: the pinned admission record {device_id, record(pubkey/firmware_id/
  screen_template_id/firmware_hash), policy_hash, frontier_root, frontier_state}.
- verify_admitted_offline_bearer(enrollment, att, req): fail-closed — (1) anti-reprovision /
  pinned pubkey, (2) pinned firmware_hash + policy_hash, (3) anchor signature verified against the
  PINNED record (non-circular: folds the pinned firmware_hash, not the device-reported one), (4)
  frontier parent==pinned + state+1 + fresh successor.
- AnchorEnrollmentStore trait + InMemoryAnchorEnrollmentStore: admit / get / CAS advance_frontier
  (cross-receiver fork detector; errors on an unadmitted device).

Tests: 8/8 — accept genuine; reject unknown/clone identity, wrong firmware_hash, wrong
policy_hash, tampered successor_root, wrong parent vs pinned frontier; CAS serializes + rejects a
replay; advance on an unadmitted device errors. Additive only; existing 1602 lib tests unaffected.

NOT YET WIRED into the live bilateral protocol (deliberate, separate step): the attestation is
produced in the sender's finalize after the receiver accepted and the receiver-bound message carries
none. Activation = carry the IslandAttestation to the receiver before goods-release + populate the
store at admission + call the verifier in the accept path. The device's on-chip Track-2 frontier
already serializes signing; this is the receiver identity pin + cross-receiver mirror. End-to-end
offline-bearer anti-clone is NOT complete until the wire-carry + call site land.
…saction manager

BilateralTransactionManager now owns an AnchorEnrollmentStore and exposes the receiver-side
enforcement API:
- admit_anchor(sender_device_id, record, policy_hash, initial_root, initial_state): pin a
  counterparty's anchor through the authority path (never implicitly from a receipt).
- verify_incoming_offline_bearer_commit(sender_device_id, op, sender_current_tip,
  target_state_number, entropy, att): fail-closed receiver check + CAS frontier advance.
- with_enrollment_store builder; default empty store (un-admitted sender -> reject).

verify_offline_bearer_receipt (free fn next to run_offline_bearer_gate so the request
reconstruction cannot drift): rebuilds the EXACT AnchorSignRequest the device signed (value_cap=Yes
and mode=Required are gate invariants; expiry_tick==target_state_number; nonce==shared entropy;
rel_key is symmetric), verifies it against the pinned identity/firmware/policy/frontier, then
CAS-advances. Test receiver_pins_anchor_and_rejects_unadmitted_and_replay proves a genuine gate
attestation is accepted by the receiver reconstruction (byte-identity sender<->receiver), and that
a replay and an un-admitted sender are rejected. Full lib suite green (1610 tests).
…firm path

Carries the offline-bearer anchor attestation to the receiver and verifies it where the
receiver actually finalizes (the 3-step BLE confirm), closing the receiver half of the wiring:
- proto BilateralConfirmRequest gains island_attestation (IslandAttestationProto) + anchor_expiry_tick.
- handle_confirm_request: for OFFLINE_BEARER_REQUIRED transfers, decode the attestation and call
  BilateralTransactionManager::verify_incoming_offline_bearer_commit against the receiver's pinned
  enrollment (anchor pubkey + measured firmware hash + policy) and CAS-advance the pinned frontier,
  using the already-derived shared tip h_n and pre_entropy. Fail-closed: a missing attestation, an
  un-admitted/fresh identity, a non-enrolled firmware, or a forked/replayed frontier rejects the
  transfer. Ordinary transfers are unaffected (the gate predicate is false).
- island_attestation_from_generated bridges the SDK's generated proto module to the core domain type.
- sender confirm builder sets the new fields (None/0 today).

Honest status: this is the RECEIVER half, fail-closed and inert by design until the two
hardware-gated ends land — the sender produces an attestation only with the Safe 7 element signing
transport (Track 4), and admission (the receiver pinning a counterparty anchor via admit_anchor)
needs the identity exchanged at pairing, which needs the sender to hold one. Until then offline-bearer
transfers reject rather than accept, the safe direction. dsm_sdk builds clean; envelope tests green.
… end-to-end, no hardware

Adds the dsm_sdk 'mock-anchor' feature (-> dsm/test-mock) that stands in for the Safe 7 element
so the whole anti-clone path runs on real phones over real BLE, mock element only:
- init.rs + bluetooth/mod.rs: wire an in-process MockAnchorTransport (seeded deterministically from
  the device id via mock_anchor_seed) into BOTH production manager constructions.
- bilateral_ble_handler: the sender forces a plain transfer to OFFLINE_BEARER_REQUIRED (shared
  maybe_force_offline_bearer_for_test, applied on BOTH the prepare request the receiver decodes AND
  the precommit, so both parties hash the same op), produces the attestation in send_bilateral_confirm
  (new manager attest_offline_bearer_for_commitment), and carries it. The receiver derives the
  sender's deterministic mock identity, auto-admits it (admit_anchor_if_absent — stands in for
  admission-at-pairing), and verifies + CAS-advances.
- SqliteChainTipStore: in-memory anchor frontier (Arc<Mutex>) so the single per-device frontier
  advances across multiple transfers within a session (test added).

Production builds (no mock-anchor) are byte-unchanged: no transport -> the sender produces no
attestation and OFFLINE_BEARER_REQUIRED transfers fail closed; the forced-policy + auto-admit bodies
compile to nothing. Both configs build clean; dsm manager tests (14) + the SDK frontier CAS test green.

Test caveats (mock shortcuts, gated behind the feature): the auto-admit derives the counterparty
identity from its device id (real admission is the authority path at pairing); the anchor + enrollment
frontiers are in-memory (reset on app restart -> do a test run without restarting); an aborted transfer
after the sender's frontier advanced desyncs until both re-admit. The attestation is verified as a side
artifact (not yet folded into the shared chain tip).
… the element is mocked)

Replaces the mock-anchor test shortcuts with production logic. The ONLY thing that stays mocked
is the AnchorTransport (the element signing key — needs the ST-Link V3 to run on real silicon);
everything else is unconditional production on both sides:

- Policy: BilateralTransactionManager::apply_offline_bearer_policy stamps OFFLINE_BEARER_REQUIRED
  on a plain transfer IFF this device holds an anchor (manager::anchor_identity() is Some), pinning
  its own anchor set with the canonical dsm_offline_bearer_policy_id. No anchor -> plain transfer
  (online-checked fallback). Not feature-gated — the only feature-gated thing is wiring the mock
  transport in. Applied identically on the prepare request the receiver decodes AND the precommit.

- Admission: the sender carries its REAL anchor identity (AnchorIdentityProto, new) in
  BilateralPrepareRequest. The receiver PINS it through the relationship-establishment authority path
  (handle_prepare_request -> admit_anchor_if_absent, idempotent), then verifies every confirm against
  that pinned identity + enrolled firmware hash. The device-id-derived auto-admit shortcut is DELETED.
  A clone/reflash presenting a different identity is rejected; no identity carried -> fail-closed.

Swap-in path: when the ST-Link arrives, replace MockAnchorTransport with the real Safe 7 transport
  at the two manager-construction sites; the identity exchange, pinning, policy, gate, verify, and
  frontier CAS are unchanged. Both build configs compile clean. REMAINING production item: the sender
  frontier (chain_tip_store) and the receiver enrollment+frontier are still in-memory — SQLite
  persistence is the next commit so they survive an app restart.
…t (production durability)

Replaces the in-memory anchor stores with SQLite, so the offline-bearer anti-clone state survives
an app restart. An in-memory receiver frontier was a real gap: a restart reset it and a consumed-parent
replay could go through.

- Schema (client_db): anchor_frontiers (sender's per-device monotonic frontier, keyed by id_anchor)
  and anchor_enrollments (receiver's pinned identity + firmware hash + policy + frontier, keyed by
  counterparty device id). Created via CREATE TABLE IF NOT EXISTS in the existing schema-init.
- client_db/anchor_persist.rs: get/CAS-set for the frontier; get/admit/CAS-advance for the enrollment.
  Read+write are atomic under the held DB connection lock.
- SqliteChainTipStore: anchor-frontier methods now hit anchor_persist (the in-memory map is gone).
- SqliteAnchorEnrollmentStore (new): implements the dsm AnchorEnrollmentStore trait against SQLite;
  wired into BOTH production manager constructions via with_enrollment_store (unconditional — the
  receiver must persist regardless of whether the element is mock or real).

With this, the offline-bearer anti-clone is production on both sides except the element transport:
identity exchange + pinning (authority path), policy, gate, verify, and BOTH frontiers persist. The
only mocked piece is MockAnchorTransport behind cfg(mock-anchor); swap it for the real Safe 7 transport
when the ST-Link arrives and nothing else changes. Both build configs compile clean.
…age, not anchor-related)

The android+bluetooth-gated JNI path had not been recompiled since the domain-tags / jni-state
refactor (#386 et al.) and no longer compiled — surfaced when the offline-bearer anchor work forced
the first android rebuild. None of this is anchor logic; it just makes 'cargo ndk build --features
jni,bluetooth' compile again so the .so can be built:

- unified_protobuf_bridge.rs: restore the moved imports on the BLE path (BleTransportDelegate,
  jni::state::{parse_hex_32, DEVICE_ID_TO_ADDR}, client_db::get_contact_chain_tip, jni::objects::
  {JObject, JValue}, tokio::runtime::Handle); rename the used  param; add the
  Operation::Transfer fields (policy_commit, authority_policy) the metadata-hint construction was
  missing after those fields were added to the type.
- handlers/bilateral_impl.rs: import DsmError on the android BLE chunk-send error path.
- init.rs: drop a dead SignatureKeyPair import in the android bilateral-manager init (keypair comes
  from derive_device_signing_keypair). All gated to the android+bluetooth cfg; host builds unchanged.
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