JohnMcLear/yondoor_android

Yondoor NFC HCE unlock Android companion app

★ 0Forks 0KotlinGitHub ↗Compare

README

Yondoor Unlock — Android App

Sideload-only Android app that turns a phone into a Yondoor NFC unlock token. Tap the phone to a paired Yondoor reader → reader authenticates the phone via HMAC-SHA256 → door unlocks → app shows a gold-key celebration animation.

  • Min Android: 6.0 (API 23). Needs NFC + HCE-capable hardware.
  • Distribution: direct APK + F-Droid. No Google Play.
  • License: Apache License 2.0. See LICENSE. SPDX-License-Identifier: Apache-2.0.
  • Companion repo (reader firmware): https://github.com/JohnMcLear/esphome_yondoor_unlock

Quick start

  1. Get the APK. Download the latest from Releases, or build it yourself (see "Build" below).
  2. Sideload it.
    adb install -r yondoor-unlock.apk
    
    (Or open the APK on the phone with a file manager; you may need to enable "install from unknown sources" for that file manager.)
  3. Pair with a reader. You'll need a Yondoor reader already flashed (see the firmware repo). Call start_pairing on the reader from HA, copy the URI it publishes.
  4. In the app, tap + Pair new reader, paste the URI, give the pairing a name (e.g. "Front door").
  5. Tap the phone to the reader. Watch the gold key.

That's it. Pair multiple readers (front door + office + back door) on the same phone; revoke from the reader side any time.


What it does

  1. On first run, you pair the phone with a Yondoor reader by pasting a yondoor://pair?... URI shown on the reader's pairing_uri text_sensor (or grep'd from the serial logs).
  2. The app registers a HostApduService for the proprietary AID F0796F6E646F6F72 (F0 prefix + ASCII "yondoor").
  3. When you tap the phone to a paired reader, the OS dispatches APDUs to that service; the service replies with HMAC-SHA256(shared_secret, nonce).
  4. The reader verifies the HMAC and sends a final UNLOCK_CONFIRM APDU (CLA=0x80 INS=0x02) back to the phone. The unlock celebration animation fires only on receipt of that confirmation — so it strictly mirrors the reader's accept decision rather than the phone's own "I produced a valid HMAC" guess. Tapping a different reader, a session-replay rejection, or any other reader-side denial means no animation.

The app stores a LIST of paired readers. Each appears as a row in the main activity with its name, the reader-assigned synthetic UID, and the paired-at date. Each row has its own Remove button. The + Pair new reader button at the bottom adds another reader without overwriting existing ones — the same phone can be a credential for the front door AND the office, with distinct secrets, distinct UIDs, and independent revocation.

No background work, no network, no analytics. FOSS.


Data model

ReaderStore (app/src/main/java/uk/co/yondoor/unlock/ReaderStore.kt) backs persistence on EncryptedSharedPreferences (AES-256-GCM) with the master key in the Android Keystore (MasterKey.KeyScheme.AES256_GCM).

The store is a single key paired_readers whose value is a JSON array of PairedReader:

data class PairedReader(
    val readerId: String,    // 4 bytes hex, e.g. "01ABCDEF" — the reader's rid
    val label: String,       // user-supplied name, e.g. "Front door"
    val secret: ByteArray,   // 32 bytes
    val pairedAt: Long       // millis since epoch
)

Build

./gradlew assembleDebug

APK lands in app/build/outputs/apk/debug/app-debug.apk.

The wrapper expects Gradle 8.7 (auto-downloaded on first run) and an Android SDK with platform 34 + build-tools 34.0.0. Set ANDROID_HOME or ANDROID_SDK_ROOT. On Debian/Ubuntu the system android-sdk package only ships build-tools; install platform-34 via:

sdkmanager "platforms;android-34" "platform-tools" "build-tools;34.0.0"

For a release-signed APK you'll need a signing key in keystore.jks and matching credentials in ~/.gradle/gradle.properties. The debug APK is unsigned-but-installable for personal sideload use.


Sideload

adb install -r app/build/outputs/apk/debug/app-debug.apk

Or copy the APK to the phone and open it with a file manager. You may need to enable "install from unknown sources" for that file manager.


Pair

Open the app, tap + Pair new reader, paste the yondoor://pair?... URI from the reader. Name the pairing.

To get the URI from a reader:

  • Via HA: read the text_sensor.<device>_yondoor_pairing_uri state after calling esphome.<device>_start_pairing.
  • Via serial logs: esphome logs <device>.yaml | grep "PAIRING URI".

(Send the URI to the phone over an end-to-end encrypted channel — Signal, iMessage, in-person paste. Treat it as the door's master key for that user; anyone with the URI can pair and unlock until revoked.)


One-tap pairing via deep link

The app registers an intent-filter for yondoor://pair?... URIs. Once installed, tapping a pairing link in Signal/email/browser jumps straight to the Name this reader dialog with the URI already parsed — one tap instead of four.

End-to-end:

  1. Someone in your household (the reader's HA admin) generates a pairing URI in Home Assistant by calling esphome.<device>_start_pairing and copying the text_sensor.<device>_yondoor_pairing_uri value.
  2. They send it to you over Signal / iMessage / encrypted email.
  3. You tap the link in the messaging app. Android offers to open it with Yondoor Unlock (or opens directly if you've already set Yondoor as the default for yondoor:// links).
  4. The app opens to the Name this reader dialog with a pre-filled placeholder like Reader AB-CD-EF-12 (derived from the reader's synthetic UID). Edit if you want, tap OK.
  5. Done. The reader is paired; tap the phone to unlock.

If the app is already running when the link is tapped, the same dialog appears on top of the existing UI — no need to back out first.

Requires Yondoor Unlock to be installed and the OS to associate it with the yondoor:// scheme. (First-time tap usually surfaces Android's "Open with" picker; pick Yondoor and optionally check "Always".) Falls back gracefully to the paste-URI flow if the deep link can't be opened — the URI is still the same string, just copy-paste it via + Pair new reader.

Security caveat is unchanged from the paste flow: the pairing URI IS the credential. Anyone who intercepts it in transit (or finds it in a notification preview) can pair their own phone. Out-of-band encrypted delivery is the soft mitigation; revocation on the reader side is the hard one.


Test

Pure-JVM unit tests, no device or emulator needed:

./gradlew :app:test

26 tests cover AID layout, APDU classification (SELECT / CHALLENGE / UNLOCK_CONFIRM / unsupported INS / unsupported CLA / malformed), HMAC-SHA256 against a known vector cross-verified with openssl dgst, the pairing-URI parser, the multi-secret handleApdu signature, and the confirmation round-trip classifier.

The HMAC vector is identical to the one pinned in the firmware repo's tests/test_apdu_hmac.cpp, so phone-side and firmware-side agree by construction.


Protocol implementation

UnlockProtocol.kt is the canonical pure-protocol surface (no Android dependencies, host-testable):

  • SELECT AID — handleApdu matches 00A4040008F0796F6E646F6F72 against the stored secrets list; returns 9000 when at least one valid secret is present, 6A82 otherwise.
  • CHALLENGE — 8001 + 16-byte nonce. Computes HMAC-SHA256(secret, nonce) for the FIRST secret in the list whose length is 32 bytes (skipping malformed entries). Returns mac || 9000. The reader is responsible for trying all of its own paired slots against this response.
  • UNLOCK_CONFIRM — 8002 0000 00 (5 bytes). Always returns 9000 regardless of secrets list. wasUnlockConfirmation(apdu) is the inspector the service layer uses to decide when to fire UnlockEvents.emitUnlockGranted().

UnlockApduService is a thin HostApduService that hands every APDU to UnlockProtocol.handleApdu and only checks wasUnlockConfirmation on the way out to fire the celebration. No state, no logic.


Manifest declarations

app/src/main/AndroidManifest.xml:

  • Hard NFC + HCE feature flags (android.hardware.nfc, android.hardware.nfc.hce).
  • <service android:name=".UnlockApduService"> exporting android.nfc.cardemulation.action.HOST_APDU_SERVICE with android:permission="android.permission.BIND_NFC_SERVICE" and the apduservice meta-data pointing at res/xml/apduservice.xml.
  • android.permission.VIBRATE for the unlock-success haptic.

res/xml/apduservice.xml:

  • requireDeviceUnlock="true" — the OS will not dispatch APDUs to this service while the screen is locked. A stolen, locked phone is inert.
  • <aid-filter android:name="F0796F6E646F6F72" /> — registers the Yondoor proprietary AID. No category="payment" — this is an "other" category service; doesn't compete with Google Pay or other wallets.

res/xml/data_extraction_rules.xml disables cloud backup and device-to-device transfer of the paired-readers shared-prefs file, so the encrypted secret blob never leaves the device.


UI

Single-Activity (UnlockActivity.kt) layout:

  • Top: paired-reader list (each row = name + synthetic UID + paired-at
    • per-row Remove button with confirmation dialog).
  • Bottom: + Pair new reader button → opens a paste-URI dialog.
  • Overlay: UnlockSuccessView — gold-bling animation triggered by UnlockEvents.unlockGranted SharedFlow (collected with repeatOnLifecycle(STARTED)). Fires only on receipt of the UNLOCK_CONFIRM APDU from a reader that accepted the HMAC.

Threat model and security

Key mitigations the code enforces:

  • apduservice.xml has android:requireDeviceUnlock="true" — OS won't dispatch APDUs while locked, so a stolen-and-locked phone is useless.
  • Shared secret stored in EncryptedSharedPreferences (AES-256-GCM, AndroidKeyStore-bound master key).
  • data_extraction_rules.xml forbids cloud backup and device-to-device transfer of the secret.
  • Logs never carry the secret or the HMAC.

Known UX issue: locked-phone tap surfaces Google Wallet

Hold a locked phone to a Yondoor reader. Android wakes the screen, prompts unlock, then opens Google Wallet (or whichever app is the device's default for tap-and-pay). Wallet doesn't authenticate against the Yondoor proprietary AID F0796F6E646F6F72 — it never sees a SELECT it understands — but Android's NFC stack surfaces Wallet's UI during the wake-and-unlock flow regardless. The user dismisses Wallet, the phone stays in the reader's field, the next scan cycle routes the SELECT to Yondoor's HostApduService, and the unlock fires.

User-visible failure mode:

  1. Tap locked phone to reader.
  2. Phone wakes, prompts for unlock.
  3. After unlock, Google Wallet UI surfaces.
  4. User dismisses Wallet.
  5. User taps the phone to the reader a second time (or holds it in the field through the next scan cycle).
  6. Yondoor's bling fires, the door unlocks.

Root cause. Android's tap-while-locked UX is biased toward the default tap-and-pay app. Yondoor registers its AID in category="other" (correct for a non-payment HCE service) and requireDeviceUnlock=true in apduservice.xml gates HCE dispatch to the unlocked state — which is the right secure default. Neither of those flags changes which app's UI Android brings to the foreground during the wake-and-unlock flow.

Workaround for users today: unlock the phone first (screen on, lock state cleared), then tap. Skips the Wallet preempt entirely.

Possible mitigations considered, each with a real trade-off:

  • Register Yondoor as a payment-category service. Would put us on the default-payment-app path Android prioritises. Cost: significantly larger NFC surface, payment-app regulatory exposure, and only one payment app can be default — competes with the user's actual wallet. Rejected.
  • CardEmulation.setPreferredService(...). Only works while the Yondoor activity is foreground. Defeats the pocket-tap UX entirely. Not viable.
  • Disable Google Wallet on the device. Kills the user's actual payments. Not viable.

Future path: investigate whether a constrained payment-category AID for Yondoor is viable (smaller HMAC-only surface, no card data), or whether a later Android API offers a cleaner "default-non-payment-HCE" hook.


Known limitations

  • Wallet preempt on locked tap — documented in detail above.
  • Bling animation only fires when the Yondoor Activity is foreground. UnlockEvents.flow is collected with repeatOnLifecycle(STARTED) inside UnlockActivity, so when the app is backgrounded the unlock event is dropped silently. The reader still grants the unlock; the user just sees no celebration. By design — the celebration is UI feedback, not part of the security boundary.
  • requireDeviceUnlock=true is the strict secure default. The OS won't dispatch APDUs to this service while the screen is locked, so a stolen-and-locked phone is inert. Flipping the flag (rebuilding the APK) would enable pocket-tap UX from a locked phone but lets anyone with the device unlock the door. This is a build-time choice, not a runtime toggle.
  • Stored credentials never expire phone-side. PairedReader entries in EncryptedSharedPreferences stay until the user explicitly removes one or uninstalls. The phone has no awareness of reader-side slot expiry or revocation; a revoked phone just silently fails HMAC verification on tap with no user-visible signal. The reader is the source of truth for revocation.
  • Multi-reader phones return only the first valid HMAC. When paired with multiple readers, UnlockProtocol.handleApdu returns HMAC(secrets.first { size == 32 }, nonce). If the wrong stored secret is "first" in the list for the reader being tapped, the reader rejects (no slot match) and the user sees no animation. The clean fix (multi-HMAC challenge response shape) is on the roadmap; in practice the phone uses the right reader because each tap is on one specific reader.
  • APK distribution is F-Droid + direct sideload only. No Google Play, per Yondoor's no-walled-garden principle. Casual users need to enable "install from unknown sources" for their browser or for the F-Droid client. By design.
  • Pairing URI must be entered while looking at the reader (or pasted from HA / logs); no Bluetooth or QR push from the reader. A QR-scanner pairing path is on the roadmap.
  • The launcher icon is a placeholder vector — see res/drawable/ic_yondoor.xml.
  • androidx.security:security-crypto:1.1.0-alpha06 is the current version supporting Android 14. The stable 1.1.0 hasn't shipped yet. Track upstream and bump when stable lands.
  • The pairing URI IS the credential. Anyone who intercepts the yondoor://pair?... URI in transit can paste it into their own Yondoor app and unlock the door. Out-of-band encrypted delivery (Signal, encrypted email, in-person paste) is the soft mitigation. No in-band crypto protects URI delivery; future path is asymmetric crypto.
  • Phone-to-reader is plaintext on the wire (authenticated, not confidential). An NFC sniffer sees the reader's nonce and the phone's HMAC response in the clear. Replay-protected and key-recovery-resistant, but not eavesdrop-protected.

File layout

app/src/main/java/uk/co/yondoor/unlock/
├── UnlockActivity.kt        Pairing + status UI (single Activity)
├── UnlockApduService.kt     HostApduService — thin wrapper, no logic
├── UnlockProtocol.kt        Pure protocol logic (testable, no Android deps)
├── PairingUri.kt            yondoor://pair?... parser
├── PairedReader.kt          Storage data class
└── ReaderStore.kt           EncryptedSharedPreferences wrapper

Contributors

JohnMcLear

Issues