Passkey — a WebAuthn authenticator core for the Light Phone 3, in Kotlin. (LightOS tool, caBLE hybrid)
Very much a WIP - at this point the blocker is utilising the fingerprint, which requires a working PIN code, neither of which the LP3 currently supports.
Done and verified:
- caBLE v2 core (
:cable), authenticator (:core), and the LightOS tool with the companion merged into one APK (:app/:server, toolbox-launched). - Real-Chrome interop on the LP3 through the live relay (2026-08-22): webauthn.io register ("Success! Now try to authenticate…") and login ("You're logged in!") with UV (temporary lock PIN). The interop gate found and fixed four wire bugs (see Protocol facts).
- Manager (2026-08-22, emulator-verified): passkeys list screen (RP
domain, user name, created/last-used dates), tap-to-delete with a confirm
panel,
createdAt/lastUsedAttimestamps (legacy rows read as 0), and an account picker when a GetAssertion matches >1 credential for the RP (session state"pick"+ candidate names;PickAccount(index)resumes, cancel → denied). Fixing the picker path also fixed a latent bug: an absent GA allowList decoded as empty, so allow-less GAs always failed withCTAP_ERR_NO_CREDENTIALS— now null = "match all resident keys". :cable:run(1179 asserts, incl. real-Chrome regression vectors); DesktopClient ceremonies (40 checks, uv=false and uv=true); on-device LP3 ceremonies (40 checks each, real BLE advert).
Blocker — UV on the LP3:
- Real Chrome forces
uv: requiredfor hybrid regardless of the RP's preference (verified on the wire: webauthn.io sentuserVerification: discouraged, Chrome's MC carrieduv: true) — there is no no-UV path with real Chrome. - UV requires an Android secure lock; any PIN or fingerprint activates the AOSP keyguard, breaking the LP3's zero-unlock wake flow (standby clock → tool button → toolbox). Pending a LightOS-native lock/enrollment path (Light's product decision).
- A uv-required ceremony on a lockless device now fails honestly:
isDeviceSecureis checked before UV-bound keygen and before a UV-required sign-in; the tool shows "UV unavailable — no secure lock on this device" as the outcome instead of a raw Keystore failure.
:cable— caBLE v2 protocol core, pure JVM, zero deps:Qr(FIDO:/ digit payload),Eid(BLE advert),Cable(HKDF + Noise KNpsk0/NKpsk0 + Crypter),Ctap2(MC/GA codec, authData/COSE parsers), tunnel transport (TunnelServer/Ws/TunnelCheck).:core— the authenticator:CredentialStore(EC P-256 in Android Keystore, resident-key metadata in app-private JSON via the pure-JVMCredentialCodec),UvGate(BiometricPrompt, injectable),PasskeyAuthenticator(WebAuthn JSONregister/assertplus rawregisterCtap/assertCtappieces for the session; an injectableaccountPickerresolves multi-credential assertions).:app— the LightOS tool (PasskeyScreen,@InitialScreen): scans the desktop's QR (LightQrCodeScanner), pollsGetSessionState(~400 ms), outcome titles ("Passkey created" / "Signed in" / "Couldn't sign in"), the account-picker branch ("pick"state + candidate names, CANCEL), an inline passkeys panel on the idle screen (top bar "Passkey" heading + rows, SCAN in the bar), a top-left back button on the finished-session outcome screen (returns to the panel) andPasskeysDetailsScreen(top bar with the account name, name + site + created/last-used dates in the body, bottom-bar REMOVE →DeleteCredentialScreenconfirm). Debug auto-QR button for the emulator path — remove for release.:server— companion library merged into the tool APK (serverPackage = com.lightphone.passkey):SessionManager(state machine incl. the pending pick: suspend the responder,"pick"state,PickAccount(index)resumes),HybridSession(tunnel → EID advert → KNpsk0 handshake → pushed getInfo → CTAP loop dispatching 0x01 MC / 0x02 GA),UvPrompt+UvActivity(the UV bridge),PasskeyServiceMethods(StartPasskeySession/StopSession/ GetSessionState/ListPasskeys/DeletePasskey/PickAccount),ServerBootstrapProvider+ PlatformRelay (all keys relayed tocom.lightos).:rp— local WebAuthn4J relying party (host-side).
source ../tools/env.sh
../tools/build --dir passkey :app:assembleDebug # build (use --force for tight RAM)
./gradlew :cable:run # self-check (1179 asserts)
./gradlew :cable:tunnelCheck # live tunnel round-trip
adb -s emulator-5554 install -r app/build/outputs/apk/debug/app-debug.apk
adb -s emulator-5554 shell am start -n com.lightphone.passkey/com.thelightphone.sdk.LightActivity
adb -s emulator-5554 logcat -s passkey # QR key / routing / advert / sessionDesktopClient ceremony (vs the app): take the advert/tunnel-id/QR-key hex from logcat, then
java -cp "$(./gradlew -q :cable:classpath)" com.lightphone.passkey.cable.DesktopClientKt <adv> <tid> <qrKey> [uv] [multi] [userName]multi omits the GA allowList (matches every resident key for the RP — the
account-picker path); the trailing userName names the registered credential
(default "user"), so repeated runs can register several accounts for one RP.
Run via java -cp, not gradle — startup eats the tunnel's ~30 s window.
With uv, type the lock PIN into the BiometricPrompt twice (MC then GA).
- QR =
FIDO:/+ 7-byte→17-digit encoding of CBOR{0: compressed desktop P-256 pubkey, 1: 16B secret, 2: numDomains, 3: ts, 4: supportsLinking, 5: requestType("ga"/"mc")}. Desktop derives identity from a 32B seed; seed→key =EC_KEY_derive_from_secret(HKDF info "derive EC key P-256"). derive(secret, nonce, type)= HKDF-SHA256(ikm=secret, salt=nonce, info=4B LE type);EID_KEY=1(64B),TUNNEL_ID=2(16B),PSK=3(32B).- EID plaintext 16B =
[0x00][10B nonce][3B routing][2B BE domain]; advert = AES-256-ECB(16B) ‖ HMAC-SHA256(hmacKey, ct)[0:4] (20B). Domains: 0 = cable.ua5v.com, 1 = cable.auth.com, ≥256 hashed. - Tunnel: phone
wss://<dom>/cable/new/<hex tunnel_id>(subprotocolfido.cable), routing ID in theX-caBLE-Routing-IDhandshake header (6 hex chars); desktopwss://<dom>/cable/connect/<hex routing>/<hex id>. - Handshake = Noise KNpsk0 (QR flow):
ck=h=protocol name, prologue[0x01], MixHash(desktop identity pub), MixKeyAndHash(psk); then eph mix/ECDH as inCable.kt.psk = derive(secret, plaintextEid, PSK). Noise protocol names are zero-padded to 32 bytes before use. - Phone pushes, right after the handshake response: encrypted CBOR
{1: getInfo, 3: ["ctap"]}— no MessageType byte on this message. - Post-handshake frames =
[MessageType][encrypted]; CTAP = 1, Shutdown = 0. Replies must carry the MessageType byte as the first plaintext byte ([0x01 kCTAP][0x00 status][cbor]) — Chromium'sfido_tunnel_device.ccreads it after decryption; a reply without it parses askShutdown. - CTAP2 MakeCredential request (Chromium
ctap_make_credential_request.cc, captured 219-byte real request):{1: clientDataHash, 2: rp{id, name}, 3: user{id, name, displayName}, 4: [{alg, type}], 5: exclude[{id}], 7: options{rk, uv}}— inner maps use TEXT keys. - CTAP2 GetAssertion request:
{1: rpId, 2: clientDataHash, 3: allow[{id}], 5: options{uv}}— allow entries use TEXT keys. An absent allowList decodes as null (match every resident key for the RP) — the account-picker path; a present-but-empty list is a real "nothing allowed". (Before 2026-08-22 an absent allowList decoded as empty → allow-less GAs always failed withCTAP_ERR_NO_CREDENTIALS.) - CTAP2 GetAssertion response:
{1: credential{id, type}, 2: authData, 3: signature, 4: user{id, name, displayName}, 5: numberOfCredentials}— credential and user entities use TEXT keys; the signature stays DER (Chrome's ownkDeviceGetAssertionResponsetest vector is DER). - Map keys must be in canonical CBOR order (RFC 8949 §4.2.1: by encoded
byte length, then byte-wise lexical) — Chromium's
cbor::Readerrejects non-canonical maps. The user entity therefore encodesid(2B),name(4B),displayName(11B) — NOT lexicographic order. - Chrome escalates UV for hybrid: resident keys are required on hybrid and
UV is upgraded to
requiredfor resident keys on non-platform authenticators (make_credential_request_handler.cc) — a uv=discouraged RP still getsoptions: {uv: true}on the wire. No-UV hybrid interop does not exist. - getInfo map (key 1) =
{1:["FIDO_2_0","FIDO_2_1"], 2:["prf"], 3:<16 zeros>, 4:{"rk":true,"uv":true}, 9:["cable","hybrid","internal"]}. - Crypter: pad to 32B (
msg ‖ zeros ‖ [count]), AES-256-GCM nonce = 8 zeros ‖ 4B BE counter, empty AAD. Noise nonce = 4B BE counter ‖ 8 zeros (differs!).
- The relay drops the tunnel ~30s after the desktop connects — the whole
ceremony must finish inside that window, and the desktop must connect within
~30s of
/cable/newor you get HTTP 418. Fresh session each time: grab all three hex values from logcat. HybridSession's coroutine scope must NOT be cancelled instop()—start()callsstop()first, so a cancelled scope silently no-ops every laterscope.launch(the MC/GA dispatch). The scope is per-instance; let it die with the instance.- The emulator's BLE advertise fails with a SecurityException (missing runtime
BLUETOOTH_ADVERTISE) — harmless; the advert is logged.
pm grant com.lightphone.passkey android.permission.BLUETOOTH_ADVERTISEsilences it. - UV on the emulator needs a lock screen:
adb shell locksettings set-pin 1234(wipe-data resets it). After a reboot with a PIN set, the emulator boots locked — user-0 apps won't launch until the PIN is entered:input keyevent 224(wake) theninput text 1234 && input keyevent 66. The emulator's credential field ignoresinput textuntil tapped first. UvPrompt's crypto map is aConcurrentHashMap— it rejects null values; null cryptos are simply not stored.- Local
check(cond, msg)helpers have no smart-cast contract — use!!after them; compose list state needsmutableStateListOf; the app needsandroidx.activity:activity-compose;ECPublicKey.parametersisparamsin Kotlin (ECKey.getParams()). - On the LP3: never
locksettings set-pinwithout a cleanup plan — any secure credential activates the AOSP keyguard over the wake flow. Cleanup:locksettings clear --old <pin>+wm dismiss-keyguard.