PKTap

GitHub Repo | Updated: April 2026

Privacy-first, decentralized contact exchange for Android. Tap phones over NFC to swap encrypted contact info — no accounts, no servers, no cloud.

Tech Stack: - Kotlin (Android app, Jetpack Compose, NFC HCE Service) - Rust (pktap-core — all cryptographic operations) - UniFFI (Kotlin/Rust FFI bridge) - Ed25519 and X25519 ECDH (key exchange) - XChaCha20-Poly1305 (contact field encryption) - PKARR / Mainline DHT (encrypted record publish and resolve) - Android Keystore (StrongBox/TEE, non-extractable master keys)

Why I Built This

Sharing contact information on phones currently means trusting an intermediary. AirDrop requires an Apple account. Sharing via QR codes sends plaintext. Bluetooth contact exchange apps route data through servers. The pattern of "tap phones and share" should not require an account, a server, or a cloud service to be private.

The goal was a tool that uses the hardware already in both phones — NFC — to bootstrap a cryptographic key exchange, encrypts the contact fields locally, and uses the Mainline DHT as the only network touch point. No PKTap server is ever contacted. Records expire from the DHT naturally. The master key is non-extractable from the Android Keystore using StrongBox or Trusted Execution Environment hardware — it cannot leave the device.

The architecture splits cleanly: the Rust core handles all cryptographic operations (key generation, ECDH, XChaCha20-Poly1305 encryption, PKARR DHT publish and resolve, BIP-39 mnemonic backup), and the Android app handles UI and NFC. No crypto runs in the JVM layer.

How It Works

The exchange happens in four steps. Two phones tap over NFC using Android Host Card Emulation, exchanging Ed25519 public keys. ECDH derives a shared secret; contact fields are encrypted with XChaCha20-Poly1305. The encrypted record is published to the Mainline DHT at a deterministic address — the SHA-256 hash of the sorted pair of public keys. The other device resolves that same address, decrypts, and displays the contact fields.

+------------------+       UniFFI       +------------------+
|   Android App    | <================> |    pktap-core     |
|   (Kotlin/       |    (Kotlin FFI)    |    (Rust)         |
|    Compose)       |                   |                   |
+------------------+                    +------------------+
| Jetpack Compose  |                    | Ed25519 keys     |
| NFC HCE Service  |                    | X25519 ECDH      |
| Android Keystore |                    | XChaCha20-Poly1305|
| Room DB          |                    | Pkarr/DHT client  |
| Navigation       |                    | BIP-39 mnemonic   |
+------------------+                    +------------------+

The Mainline DHT imposes a 1000-byte ceiling on PKARR records, which limits exchange to text fields — phone numbers, email addresses, a short bio. All secret material is zeroed after use: the Rust layer uses the zeroize crate; the Kotlin layer uses explicit ByteArray zeroing. The master key is stored in the Android Keystore under StrongBox or TEE and is non-extractable from the device.

What I Learned

This project is at v1.0 with 5 of 7 phases complete. The Rust crypto core, Pkarr DHT integration, UniFFI bridge, Android Keystore module, and NFC HCE module are all done. App Integration and Core UI (Phase 6) and QR Fallback and Public Mode (Phase 7) are still pending. The current state is a working cryptographic stack and NFC subsystem — not yet a complete user-facing Android app.

The iOS limitation shaped the Android-only design from the start. iOS does not support NFC Host Card Emulation for write operations, only for payment use cases. Android HCE is the only practical path for symmetric NFC contact exchange on consumer hardware.

The Rust/Kotlin split via UniFFI was the right call. Cross-compiling the Rust library to Android targets (aarch64, armv7, x86_64) and generating Kotlin bindings via UniFFI adds build complexity — the Gradle build invokes cargo-ndk to cross-compile and then runs uniffi-bindgen — but it means the cryptographic primitives are memory-safe by construction and shared between any future platform targets. Writing the same crypto twice in JVM would have been a worse tradeoff.


Tap to share — no accounts, no servers, no middleman.