Skip to content

fix: implement phone-shows-code ceremony for own-device enrollment - #7

Merged
jtwolfe merged 3 commits into
rework/unifyfrom
cursor/fix-own-device-enrollment-af25
Aug 14, 2026
Merged

fix: implement phone-shows-code ceremony for own-device enrollment#7
jtwolfe merged 3 commits into
rework/unifyfrom
cursor/fix-own-device-enrollment-af25

Conversation

@jtwolfe

@jtwolfe jtwolfe commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the own-device enrollment race condition where scanning the QR first (the natural order) would cause enrollment to fail.

Old Flow (Broken)

The previous enrollment flow had these bugs:

  1. Node generated the challenge code and printed it on screen next to the QR
  2. Race condition: handle_enroll_connection denied immediately if challenge_confirmed was not already true. If the user scanned the QR before typing the challenge (the natural order), enrollment failed.
  3. Protocol mismatch: Carrier spoke length-prefixed JSON, but MyMesh spoke bincode frames with EnrollMessage::EnrollRequest/EnrollAccept/EnrollDeny
  4. Wire format bug: DeviceId serialized as a JSON array of 32 numbers, but carrier expected a hex string

New Flow (Phone-Shows-Code Ceremony)

Per the spec, the corrected flow is:

  1. Node runs mymesh enroll start, shows QR only (no code printed on node)
  2. Phone scans QR, connects over iroh ALPN mymesh-enroll/1
  3. Phone sends request with ticket, person identity, mesh name
  4. Node verifies ticket + session not expired, sends challenge_waiting
  5. Phone generates 6-digit code, displays it on phone screen, sends challenge_offer { digits }
  6. Human types the phone's code INTO the node (stdin/TUI prompt)
  7. Node constant-time compares; on match: store owner, send result { success: true }

Security properties:

  • QR contains only ticket and device id, no challenge code
  • A photo of the QR is not enough (the code lives only on the phone)
  • The code is transmitted from phone → node only after connection
  • Constant-time comparison prevents timing attacks

Wire Protocol

ALPN: mymesh-enroll/1
Transport: iroh bidirectional stream
Framing: 4-byte big-endian length + UTF-8 JSON
Messages (serde tag = "type"):

  • request: { ticket, person_public_key_hex, person_id, mesh_name } (phone → node)
  • challenge_waiting: {} (node → phone)
  • challenge_offer: { digits } (phone → node)
  • result: { success, device_id, device_label, error } (node → phone)

Success result example:

{"type":"result","success":true,"device_id":"a1a1a1...64 hex chars...","device_label":"my-node"}

Note: device_id is now a hex string (64 chars), matching the QR device field format. This fixes interop with carrier which expects a string, not an array of numbers.

Changes

Crate Change
mymesh-protocol Add new EnrollMessage variants with JSON tag format; device_id as hex string; add length-prefixed JSON framing helpers; add wire format tests
mymesh-core EnrollSession no longer generates challenge (phone does); add verify_phone_challenge() for constant-time compare
mymesh-net Add send_raw/recv_raw to PeerConnection trait for JSON framing
mymesh-session Rewrite handle_enroll_connection for new flow; add ChallengePromptFn callback for stdin prompt
mymesh-cli Update cmd_enroll_start (no challenge printed, wait for phone)
docs/REWORK-UNIFY.md Update ceremony text

Tests

  • enroll_accepted_after_phone_code: correct code path
  • enroll_denied_on_wrong_code: wrong code path
  • enroll_denied_without_session: no session
  • enroll_denied_with_wrong_ticket: invalid ticket
  • enroll_result_success_wire_format: verify JSON structure has hex string
  • enroll_result_failure_wire_format: verify error omits device_id

Notes

  • Does NOT touch mymesh link (24-word pairing)
  • Does NOT touch main or feature/car-mesh-improv branches
  • Does NOT change DeviceId's global serde (other protocol messages use it)
  • No Glass/holofs/Tor changes
Open in Web Open in Cursor 

The old enrollment flow had these bugs:
- Node generated the challenge code and printed it on screen
- handle_enroll_connection denied if challenge_confirmed was not already true
- Protocol mismatch between carrier (length-prefixed JSON) and node (bincode frames)

New phone-shows-code flow (per spec):
1. Node shows QR only (device id + ticket), does NOT print a code
2. Phone scans QR, connects over iroh ALPN mymesh-enroll/1
3. Phone sends 'request' with ticket, person identity, mesh name
4. Node verifies ticket + session, sends 'challenge_waiting'
5. Phone generates 6-digit code, displays it, sends 'challenge_offer { digits }'
6. Node prompts human on stdin to type the digits
7. Constant-time compare; on match: store owner, send 'result { success: true }'

Wire protocol (ALPN mymesh-enroll/1):
- Transport: iroh bidirectional stream
- Framing: 4-byte big-endian length + UTF-8 JSON
- Messages use serde tag="type":
  - request: { ticket, person_public_key_hex, person_id, mesh_name }
  - challenge_waiting: {}
  - challenge_offer: { digits }
  - result: { success, device_id, device_label, error }

Changes:
- mymesh-protocol: Add new EnrollMessage variants with JSON tag format
- mymesh-protocol: Add length-prefixed JSON framing helpers
- mymesh-core: EnrollSession no longer generates challenge (phone does)
- mymesh-core: Add verify_phone_challenge() for constant-time compare
- mymesh-net: Add send_raw/recv_raw to PeerConnection trait
- mymesh-session: Rewrite handle_enroll_connection for new flow
- mymesh-session: Add ChallengePromptFn callback for stdin prompt
- mymesh-cli: Update cmd_enroll_start (no challenge printed, wait for phone)
- docs/REWORK-UNIFY.md: Update ceremony text

Tests:
- enroll_accepted_after_phone_code: correct code path
- enroll_denied_on_wrong_code: wrong code path
- enroll_denied_without_session: no session
- enroll_denied_with_wrong_ticket: invalid ticket

Co-authored-by: jtwolfe <jtwolfe@users.noreply.github.com>
@jtwolfe
jtwolfe marked this pull request as ready for review August 14, 2026 06:20
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

cursoragent and others added 2 commits August 14, 2026 06:22
Carrier deserializes device_id as a JSON string (hex), but DeviceId's
default serde serializes as a JSON array of 32 numbers. This caused
successful enrollment results to fail parsing on the phone side.

Changes:
- EnrollMessage::Result.device_id is now Option<String> (64 hex chars)
- Handler converts DeviceId to hex string before sending
- Added wire format tests to verify JSON structure

Wire format (success):
{"type":"result","success":true,"device_id":"<64 hex chars>","device_label":"my-node"}

Does not change DeviceId's global serde (other protocol messages use it).

Co-authored-by: jtwolfe <jtwolfe@users.noreply.github.com>
- Apply rustfmt formatting fixes
- Add crate-level allow for clippy::double_must_use in mymesh-net
  (false positive from async_trait + Result return types)
- Remove unused tokio::io::AsyncReadExt import (RecvStream has read_exact)

Co-authored-by: jtwolfe <jtwolfe@users.noreply.github.com>
@jtwolfe
jtwolfe merged commit bd854a0 into rework/unify Aug 14, 2026
2 checks passed
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.

2 participants