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
- Get the APK. Download the latest from Releases, or build it yourself (see "Build" below).
- Sideload it.
(Or open the APK on the phone with a file manager; you may need to enable "install from unknown sources" for that file manager.)
adb install -r yondoor-unlock.apk - Pair with a reader. You'll need a Yondoor reader already
flashed (see the firmware repo).
Call
start_pairingon the reader from HA, copy the URI it publishes. - In the app, tap + Pair new reader, paste the URI, give the pairing a name (e.g. "Front door").
- 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.
- 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). - The app registers a HostApduService for the proprietary AID
F0796F6E646F6F72(F0prefix + ASCII"yondoor"). - 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). - The reader verifies the HMAC and sends a final
UNLOCK_CONFIRMAPDU (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.
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
)./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.
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.
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_uristate after callingesphome.<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.)
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:
- Someone in your household (the reader's HA admin) generates a
pairing URI in Home Assistant by calling
esphome.<device>_start_pairingand copying thetext_sensor.<device>_yondoor_pairing_urivalue. - They send it to you over Signal / iMessage / encrypted email.
- 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). - 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. - 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.
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.
UnlockProtocol.kt is the canonical pure-protocol surface (no Android
dependencies, host-testable):
- SELECT AID —
handleApdumatches00A4040008F0796F6E646F6F72against the stored secrets list; returns9000when at least one valid secret is present,6A82otherwise. - CHALLENGE —
8001+ 16-byte nonce. ComputesHMAC-SHA256(secret, nonce)for the FIRST secret in the list whose length is 32 bytes (skipping malformed entries). Returnsmac || 9000. The reader is responsible for trying all of its own paired slots against this response. - UNLOCK_CONFIRM —
8002 0000 00(5 bytes). Always returns9000regardless of secrets list.wasUnlockConfirmation(apdu)is the inspector the service layer uses to decide when to fireUnlockEvents.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.
app/src/main/AndroidManifest.xml:
- Hard NFC + HCE feature flags (
android.hardware.nfc,android.hardware.nfc.hce). <service android:name=".UnlockApduService">exportingandroid.nfc.cardemulation.action.HOST_APDU_SERVICEwithandroid:permission="android.permission.BIND_NFC_SERVICE"and theapduservicemeta-data pointing atres/xml/apduservice.xml.android.permission.VIBRATEfor 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. Nocategory="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.
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 byUnlockEvents.unlockGrantedSharedFlow (collected withrepeatOnLifecycle(STARTED)). Fires only on receipt of the UNLOCK_CONFIRM APDU from a reader that accepted the HMAC.
Key mitigations the code enforces:
apduservice.xmlhasandroid: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.xmlforbids cloud backup and device-to-device transfer of the secret.- Logs never carry the secret or the HMAC.
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:
- Tap locked phone to reader.
- Phone wakes, prompts for unlock.
- After unlock, Google Wallet UI surfaces.
- User dismisses Wallet.
- User taps the phone to the reader a second time (or holds it in the field through the next scan cycle).
- 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.
- Wallet preempt on locked tap — documented in detail above.
- Bling animation only fires when the Yondoor Activity is
foreground.
UnlockEvents.flowis collected withrepeatOnLifecycle(STARTED)insideUnlockActivity, 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=trueis 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.
PairedReaderentries inEncryptedSharedPreferencesstay 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.handleApdureturnsHMAC(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-alpha06is 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.
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