Spec: Proximity Discovery & Key Exchange

Overview

This page is the full technical specification for Prototype 3. The prototype goal is described in the research prototype catalogue and the motivation in plain language in Story 3: The First Meeting. Two Android phones broadcast unlinkable BLE beacons, show an anonymous "1 person nearby" indicator, and then — when both users consent — exchange three public keys (Ed25519 identity key, X25519 DH key, ML-KEM-768 post-quantum KEM key) over a single NFC tap or QR scan. X3DH key agreement runs on both devices and both arrive at the same shared secret. The contact is persisted locally. On subsequent encounters within BLE range, a challenge-response protocol lets the devices mutually authenticate without broadcasting identity to passive observers. No messaging, no internet, no feeds — just the physical-first trust foundation everything else builds on.


API Surface / Key Interfaces

All interfaces are Kotlin pseudocode. Types reference Android SDK and BouncyCastle conventions where naming would otherwise be ambiguous.

BLE Advertising

/**
 * Rotates the BLE advertisement payload every [rotationInterval].
 * The payload is 16 random bytes — no identity, no persistent token.
 * Android randomises the MAC address automatically; rotating the payload
 * closes the remaining correlation vector (same payload across MAC changes).
 */
interface ProximityBeacon {
    fun startAdvertising(rotationInterval: Duration)
    fun stopAdvertising()
}

BLE Scanning

/**
 * Calls [callback] for every distinct beacon seen in the scan window.
 * BeaconSeen carries only signal strength (RSSI) and the raw payload bytes —
 * never a persistent identifier.
 */
data class BeaconSeen(val rssi: Int, val payload: ByteArray, val seenAt: Instant)

interface ProximityScanner {
    fun startScan(callback: (BeaconSeen) -> Unit)
    fun stopScan()
}

NFC Key Exchange

/**
 * Produces an NDEF record containing:
 *   - Ed25519 public key   (32 bytes)
 *   - X25519 public key    (32 bytes)
 *   - ML-KEM-768 public key (1184 bytes)
 *   - Unix timestamp       (8 bytes, prevents replay)
 *   Total payload: 1256 bytes, within NDEF single-record capacity.
 *
 * One phone calls this and presents the tag; the other scans in reader mode.
 * Android Beam / setNdefPushMessage is deprecated since Android 10 — this
 * implementation uses NfcAdapter.enableReaderMode() on the scanning device.
 */
interface NfcKeyExchange {
    fun generatePayload(): NdefMessage
    fun receivePayload(message: NdefMessage): KeyBundle
}

QR Key Exchange

/**
 * Encodes the same KeyBundle as the NDEF payload into a QR code.
 * 1256 bytes binary → base64url → ~1675 chars.
 * At QR version 40 / ECC level L capacity is ~2953 alphanumeric chars,
 * so the payload fits in a single QR code at a scannable size.
 * Falls back to two-frame animated QR if the implementation targets
 * smaller screen sizes (see Risks section).
 */
interface QrKeyExchange {
    fun generateQR(): Bitmap
    fun scanQR(bitmap: Bitmap): KeyBundle
}

Key Bundle

/**
 * Canonical on-wire representation of one party's public keys.
 * Serialised as a concatenation of raw public key bytes + timestamp.
 * No ASN.1, no TLS encoding — intentionally minimal.
 */
data class KeyBundle(
    val identityKey: Ed25519PublicKey,   // 32 bytes — also seeds feed_id
    val dhKey: X25519PublicKey,          // 32 bytes
    val kemPublicKey: MLKem768PublicKey, // 1184 bytes
    val timestamp: Instant
)

X3DH Key Agreement

/**
 * Extended Triple Diffie-Hellman adapted for hybrid classical + PQ:
 *
 *   DH1 = X25519(myIdentityPriv,  theirDhPub)
 *   DH2 = X25519(myEphemeralPriv, theirDhPub)
 *   DH3 = X25519(myEphemeralPriv, theirDhPub)   // standard X3DH shape
 *   KEM = ML-KEM-768 encapsulation → kemSecret
 *
 *   sharedSecret = HKDF-SHA256(DH1 || DH2 || DH3 || kemSecret)
 *
 * Both initiator and responder derive the same sharedSecret.
 * The ephemeral X25519 key is generated fresh for each exchange and discarded.
 */
interface X3DH {
    fun initiateKeyAgreement(
        myKeys: KeyBundle,
        myPrivateKeys: PrivateKeyBundle,
        theirKeys: KeyBundle
    ): SharedSecret
}

Contact Store

/**
 * Persists a contact after successful key agreement.
 * feed_id = base64url(SHA-256(theirKeys.identityKey.publicBytes))
 * sharedSecret is stored in Android Keystore-backed encrypted storage.
 * displayName is optional — user can label the contact later.
 */
interface ContactStore {
    fun storeContact(
        theirFeedId: String,
        sharedSecret: SharedSecret,
        displayName: String? = null
    )

    fun lookupByFeedId(feedId: String): Contact?
    fun allContacts(): List<Contact>
}

BLE Contact Recognizer

/**
 * After a prior key exchange, a known contact can be re-identified over BLE
 * using an authenticated challenge-response:
 *
 *   1. Device A sees an unrecognised beacon payload.
 *   2. A connects to B's GATT service, sends a 32-byte random challenge
 *      encrypted with B's Ed25519 pubkey (NaCl box).
 *   3. B decrypts, signs the challenge + a nonce with its Ed25519 privkey,
 *      returns the signature.
 *   4. A verifies the signature against the stored Ed25519 pubkey.
 *
 * Passive observers see only a GATT connection with opaque encrypted bytes —
 * no identity is revealed unless the challenge succeeds.
 */
interface ContactRecognizer {
    fun identifyDevice(scanResult: ScanResult): Contact?
}

Dependencies & Libraries

Library Version Purpose License
Android NFC APIs (NfcAdapter, NdefMessage, IsoDep) built-in (API 19+) NDEF tag presentation and reader mode Apache 2.0 (AOSP)
Android BLE APIs (BluetoothLeAdvertiser, BluetoothLeScanner, BluetoothGatt) built-in (API 21+) BLE advertising, scanning, GATT for challenge-response Apache 2.0 (AOSP)
tink-android 1.11.0 Ed25519 signing, X25519 ECDH, HKDF-SHA-256 Apache 2.0
bouncycastle-android (bcprov-jdk18on) 1.78.1 ML-KEM-768 (Kyber) post-quantum KEM MIT
zxing-android-embedded 4.3.0 QR code generation (BarcodeEncoder) and scanning (IntentIntegrator) Apache 2.0
androidx.room + SQLite 2.6.1 Contact store persistence Apache 2.0
androidx.security:security-crypto 1.1.0-alpha06 EncryptedSharedPreferences wrapping Android Keystore Apache 2.0
Briar QR exchange (reference only) Reference implementation for QR key exchange UX patterns GPLv3 — do not link; read only

Note on ML-KEM-768: BouncyCastle 1.77+ ships org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters. Verify the specific artifact is bcprov-jdk18on not bcprov-jdk15on — the older artifact does not include the PQ primitives. If Tink is already in the build, prefer it for Ed25519 and X25519 and use BouncyCastle only for the KEM.


Platform Constraints

NFC

BLE

Cryptographic Performance on Mobile

Operation Approximate time (mid-range Android 2024)
X25519 ECDH < 1 ms
Ed25519 sign < 1 ms
Ed25519 verify < 2 ms
ML-KEM-768 key generation ~3 ms
ML-KEM-768 encapsulation ~2 ms
ML-KEM-768 decapsulation ~2 ms
HKDF-SHA-256 < 1 ms
Full X3DH (all DH + KEM + HKDF) < 10 ms

The full key agreement completes well within the NFC tap interaction window (~1–2 seconds from tap to stored contact). No performance constraints apply.

QR Payload Size

KeyBundle serialised: 32 + 32 + 1184 + 8 = 1256 bytes raw. Base64url encoding: ~1675 characters. At QR version 40, ECC level L, byte mode capacity is 2953 bytes — this fits. At ECC level M (recommended for robustness) capacity is 2331 bytes — still fits for raw bytes if binary mode is used instead of base64url. Verify encoding mode in ZXing's BarcodeEncoder.encodeBitmap() to confirm binary vs character encoding is selected.


Build & Test Instructions

Prerequisites

Project Setup

# Create a new Android project (Kotlin, empty activity)
# In Android Studio: File → New → New Project → Empty Views Activity
# Language: Kotlin, Min SDK: 21

# Or clone a reference skeleton and replace the module
git clone https://github.com/your-org/prototype-key-exchange.git
cd prototype-key-exchange

Gradle Dependencies

// build.gradle.kts (app module)
dependencies {
    // Google Tink for Ed25519, X25519, HKDF
    implementation("com.google.crypto.tink:tink-android:1.11.0")

    // BouncyCastle for ML-KEM-768
    implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")

    // ZXing for QR generation and scanning
    implementation("com.journeyapps:zxing-android-embedded:4.3.0")

    // Room for contact store
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1")

    // Encrypted storage backed by Android Keystore
    implementation("androidx.security:security-crypto:1.1.0-alpha06")

    // Coroutines (BLE and NFC callbacks are async)
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Build

./gradlew assembleDebug

Deploy to Physical Devices

# Connect both devices via USB (enable USB debugging on each)
adb devices   # verify both appear

# Install on device 1
adb -s <DEVICE_1_SERIAL> install app/build/outputs/apk/debug/app-debug.apk

# Install on device 2
adb -s <DEVICE_2_SERIAL> install app/build/outputs/apk/debug/app-debug.apk

Run Unit Tests

./gradlew test
# Covers: X3DH shared secret derivation, KeyBundle serialisation, NDEF encode/decode

Run Instrumented Tests (Requires Physical Device)

# NFC and BLE tests require physical hardware — emulator will fail
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.annotation=PhysicalDeviceTest

Manual Test Flow

  1. Open the app on both devices. Verify BLE beacons appear in logcat on both.
  2. On Device A, tap "Show QR" or tap phones for NFC. On Device B, scan or tap.
  3. Both devices should show "Contact stored" within 3 seconds (NFC) or 5 seconds (QR).
  4. Force-close and restart both apps. Verify contacts persist.
  5. Bring devices back into BLE range. Verify "Contact nearby" indicator appears.

Test Matrix

Test Type Pass Condition
X3DH shared secret derivation Unit Both sides produce identical 32-byte secret for a fixed test vector
KeyBundle serialise / deserialise Unit Round-trip produces identical struct; no bytes truncated
NDEF payload encode / decode Unit generatePayload()receivePayload() round-trip is lossless
ML-KEM-768 encap / decap Unit sharedSecretA == sharedSecretB for fresh key pair
NFC tap → shared secret Integration Both devices derive identical secret in < 3 seconds
QR scan → shared secret Integration Both devices derive identical secret in < 5 seconds
BLE beacon rotation Integration BluetoothLeScanner observes payload change after rotationInterval elapses; no repeated payload within a 60-minute window
Contact persistence Integration Contact is present in ContactStore.allContacts() after app restart
BLE mutual auth — known contact Integration After key exchange, ContactRecognizer.identifyDevice() returns non-null Contact within 10 seconds of coming into BLE range
BLE mutual auth — unknown device Integration ContactRecognizer.identifyDevice() returns null for a device that has not performed key exchange
Replay prevention Unit receivePayload() rejects an NDEF message with a timestamp older than 5 minutes

Success Criteria

  1. NFC tap → contact stored in < 3 seconds (measured from first NFC field detection to Room insert confirmed).
  2. QR scan → contact stored in < 5 seconds (measured from camera focus confirmed to Room insert confirmed).
  3. Both devices derive an identical X3DH shared secret, verified by a deterministic test vector with a fixed seed.
  4. BLE advertisement payload changes every rotationInterval; no payload repeats within a 60-minute observation window on a third scanning device.
  5. After key exchange, devices mutually recognise each other over BLE within 10 seconds of coming within BLE range (challenge-response complete).
  6. No identity information (feed_id, public key bytes, display name) appears in the BLE advertisement payload at any time; confirmed by packet capture on the third device.
  7. Contact store (Room database) survives app force-stop, device reboot, and app update with data migration.

Risks & Unknowns

Risk Likelihood Impact Mitigation
NFC reader mode complexity — Android Beam gone, HCE setup is non-trivial Medium High Use the simpler "static NDEF tag via HCE" approach rather than full ISO-DEP; Briar's QR exchange is the fallback if NFC proves too brittle
ML-KEM-768 unavailable or broken on target Android version Medium High Pin bcprov-jdk18on:1.78.1; write a unit test that confirms ML-KEM-768 parameters resolve at startup; fallback to X25519-only if KEM fails to initialise
BLE beacon unlinkability — rotating payload insufficient against adversary with many receivers Medium Medium Rotation closes naive passive tracking; a well-resourced adversary with overlapping coverage can still correlate by timing. Documented as known limitation. Full mitigation requires additional noise injection (fake beacons), deferred to post-prototype analysis
NFC tap UX — optimal tap duration and orientation unclear High Low Run informal usability sessions with 5+ participants; measure tap-detection latency in logs; adjust NDEF record timeout and NfcAdapter flags based on findings
BLE mutual auth protocol complexity — challenge-response over GATT adds a round-trip Medium Medium Prototype can initially skip mutual auth and only show "contact nearby" as a count, validating key exchange independently; layer in auth in days 9-10
QR payload size — 1256-byte KeyBundle exceeds some QR generator defaults Low Medium Explicitly specify binary mode in ZXing; test scan distance at 1080p and 720p camera; if size is a practical problem, consider omitting the ML-KEM-768 key from QR (classical-only QR path) with a flag in the payload indicating PQ key must be fetched over GATT
BouncyCastle / Tink version conflict (both on classpath) Medium Medium Exclude BouncyCastle transitive dependency from Tink; use Tink for classical crypto, BC only for KEM; run ./gradlew dependencies to verify no duplicate provider registrations
iOS path undefined High Low This prototype is Android-only by design; iOS path is QR-only and deferred; flag as a known gap in findings document

2-Week Day-by-Day Build Plan

Day Goal Deliverable Dependencies
1 Project setup; NFC reader mode: Phone A reads NDEF tag from Phone B Both phones exchange a "hello world" NDEF text record; NFC HCE service registered and responding Physical devices with NFC; Android Studio setup
2 Key generation: Ed25519, X25519, ML-KEM-768; serialise to KeyBundle; write NDEF payload encoder/decoder NfcKeyExchange.generatePayload() and receivePayload() passing unit tests; KeyBundle round-trip test green BouncyCastle 1.78.1 on classpath; ML-KEM-768 unit test passing
3 X3DH key agreement: both sides derive identical shared secret X3DH.initiateKeyAgreement() unit test with fixed test vector; both sides confirm matching sharedSecret Day 2 KeyBundle complete
4 Contact store: Room schema; storeContact(); lookupByFeedId(); persistence test Room database initialised; contact persists across app restart; feed_id derivation correct Day 3 X3DH complete
5 QR fallback: encode KeyBundle as QR via ZXing; decode on scan QrKeyExchange.generateQR() and scanQR() passing unit tests; end-to-end QR → contact stored on two devices Day 4 ContactStore complete
6 BLE advertising: ProximityBeacon.startAdvertising() with 16-byte random payload BLE beacon visible in nRF Connect on a third device; payload confirmed random (no identity bytes) Android BLE permissions; API 21+
7 BLE beacon rotation: payload rotates every rotationInterval; verify no payload repeats Third-device scan log shows payload change at expected interval; unit test for rotation scheduler Day 6 advertising working
8 BLE scanning: ProximityScanner; RSSI filtering; anonymous "N contacts nearby" counter in UI UI shows count of distinct beacons above RSSI threshold; count updates as beacons appear/disappear Day 7 rotation working
9 BLE mutual auth: GATT service exposing challenge-response endpoint; ContactRecognizer.identifyDevice() identifyDevice() returns correct Contact for a known device; returns null for unknown device; integration test on two physical devices Day 4 ContactStore; Day 6 BLE advertising
10 Integration: full flow NFC tap → contact → BLE recognition end-to-end Two-device demo: tap → stored contact → app restart → come into BLE range → "Contact nearby" indicator Days 1–9 all complete
11 UX polish: haptic feedback on tap success; visual confirmation screen; "Connecting…" progress indicator Users can complete the flow without reading logs; tap success is unambiguous Day 10 integration green
12 Multi-contact test: 3+ contacts stored; all recognisable over BLE simultaneously Three-device test: all three recognise each other; no contact confusion; correct feed_ids in store Day 11 UX complete
13 Measurement: instrument tap-to-contact latency; BLE re-recognition latency; log to CSV for analysis Latency CSV with 10+ samples per flow; BLE re-recognition P50 / P95 measured Day 12 multi-contact stable
14 Documentation: findings document; edge cases logged; iOS implications noted; spec updated with actual results Findings page in _plans/ or wiki; open issues filed for anything deferred; this spec updated with measured latency values Day 13 measurements complete