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
- Android Beam deprecated (Android 10+):
setNdefPushMessage()andenableForegroundNdefPush()are no longer available. The replacement is NFC reader mode (NfcAdapter.enableReaderMode()): one device acts as an NDEF tag (using Host Card Emulation or a static NDEF tag record), the other scans it in reader mode. This works on all Android versions that support NFC (API 19+). - iOS NFC: Core NFC in reader mode only — iPhones can scan NDEF tags but cannot act as a tag. QR code is the only viable iOS path for key exchange. This prototype targets Android; the QR fallback is the iOS bridge.
- NFC range: ~4 cm physical proximity. This is intentional — the tap gesture is the consent signal. Spoofing requires physical access.
BLE
- Minimum SDK for BLE advertising: API 21 (Android 5.0). Scanning is available from API 18, but advertising requires 21.
- MAC address randomisation: Android 8+ randomises the BLE MAC address per scan session. This is insufficient on its own to prevent tracking — a persistent advertisement payload would still correlate across MAC rotations. The payload must also rotate.
- Rotation interval: Every 15–20 minutes maximum. Shorter intervals reduce tracking risk; longer intervals save battery. 15 minutes is the recommended default.
- Background BLE: Android permits background BLE scanning with
ACCESS_BACKGROUND_LOCATION(API 29+). The prototype does not require background scanning for MVP — foreground only is sufficient to validate the UX.
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
- Android Studio Hedgehog (2023.1.1) or later
- Two physical Android devices with NFC support (NFC cannot be tested in the emulator)
- Both devices on API 21+ (API 29+ recommended for background location permission flow)
JAVA_HOMEpointing to JDK 17+
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
- Open the app on both devices. Verify BLE beacons appear in logcat on both.
- On Device A, tap "Show QR" or tap phones for NFC. On Device B, scan or tap.
- Both devices should show "Contact stored" within 3 seconds (NFC) or 5 seconds (QR).
- Force-close and restart both apps. Verify contacts persist.
- 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
- NFC tap → contact stored in < 3 seconds (measured from first NFC field detection to Room insert confirmed).
- QR scan → contact stored in < 5 seconds (measured from camera focus confirmed to Room insert confirmed).
- Both devices derive an identical X3DH shared secret, verified by a deterministic test vector with a fixed seed.
- BLE advertisement payload changes every
rotationInterval; no payload repeats within a 60-minute observation window on a third scanning device. - After key exchange, devices mutually recognise each other over BLE within 10 seconds of coming within BLE range (challenge-response complete).
- 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.
- 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 |