FIDO2/WebAuthn protocol library for Android — passkeys, attestation, and caBLE hybrid transport without Google Play Services.
A standalone Android library implementing FIDO2/WebAuthn protocols from scratch in Kotlin. Built to work on any Android 9+ device, including those without GMS (Google Mobile Services).
Key feature: Full caBLE v2 (hybrid transport) implementation — the only known Kotlin/Java library that does this.
| Module | Description |
|---|---|
webauthn |
AuthenticatorData, AttestationObject, CoseKey, ClientData, CTAP HID/NFC framing |
attestation |
7 attestation verifiers + 4 generators (packed, android-key, fido-u2f, none, tpm, apple, safetynet) |
cable |
caBLE v2 hybrid transport — QR + linked device sessions, BLE GATT server, WebSocket tunnel, Noise, CTAP2 processor + extensions |
nfc |
NFC APDU state machine — SELECT, CTAP_MSG, command/response chaining (ISO 7816-4) |
crypto |
AES-256-GCM encrypt/decrypt utility |
In your app's settings.gradle.kts:
includeBuild("../rivik-fido-sdk") {
dependencySubstitution {
substitute(module("dev.rivik.fido:webauthn")).using(project(":webauthn"))
substitute(module("dev.rivik.fido:attestation")).using(project(":attestation"))
substitute(module("dev.rivik.fido:cable")).using(project(":cable"))
substitute(module("dev.rivik.fido:crypto")).using(project(":crypto"))
substitute(module("dev.rivik.fido:nfc")).using(project(":nfc"))
}
}Then in module build.gradle.kts:
dependencies {
implementation("dev.rivik.fido:cable") // includes webauthn + attestation transitively
implementation("dev.rivik.fido:nfc") // NFC APDU transport
// or pick individual modules:
implementation("dev.rivik.fido:webauthn")
implementation("dev.rivik.fido:attestation")
}import dev.rivikauth.lib.webauthn.AuthenticatorData
val flags = (AuthenticatorData.FLAG_UP.toInt() or
AuthenticatorData.FLAG_UV.toInt() or
AuthenticatorData.FLAG_AT.toInt()).toByte()
val authData = AuthenticatorData.build(
rpId = "example.com",
flags = flags,
signCount = 1,
attestedCredentialData = AuthenticatorData.buildAttestedCredentialData(
aaguid = ByteArray(16),
credentialId = credentialId,
publicKey = ecPublicKey,
),
)import dev.rivikauth.lib.attestation.verify.PackedVerifier
import dev.rivikauth.lib.attestation.generate.NoneGenerator
// Verify attestation from a client
val result = PackedVerifier().verify(attStmt, authData, clientDataHash)
// Generate attestation for registration
val attStmt = NoneGenerator().generate(authData, clientDataHash, keyPair)import dev.rivikauth.lib.cable.*
// 1. Parse QR code from browser
val qrData = CableQrCode.parse(rawQrValue)
// 2. Implement credential storage
val store = object : FidoCredentialStore {
override suspend fun save(credential: StoredCredential) { /* your DB */ }
override suspend fun getByRpId(rpId: String): List<StoredCredential> { /* your DB */ }
override suspend fun updateSignCount(id: String, signCount: Long, lastUsedAt: Long) { /* your DB */ }
}
// 3. Create processor with extensions and BLE advertiser
val config = AuthenticatorConfig(
extensions = listOf("credProtect", "hmac-secret", "largeBlobKey"),
)
val processor = CtapProcessor(store, masterKeyBytes, config)
val advertiser = object : CableSession.BleAdvertiserCallback {
override fun startAdvertising(eid: ByteArray) { /* start BLE advert */ }
override fun stopAdvertising() { /* stop BLE advert */ }
}
// 4. Run QR session
val session = CableSession(qrData, processor, advertiser)
// Observe state via StateFlow
launch {
session.state.collect { state ->
when (state) {
is CableSession.SessionState.Advertising -> { /* BLE advertising */ }
is CableSession.SessionState.Connecting -> { /* tunnel connecting */ }
is CableSession.SessionState.Processing -> { /* CTAP2 in progress */ }
is CableSession.SessionState.Success -> { /* done */ }
is CableSession.SessionState.Error -> {
// Human-readable message + optional CTAP error code
val message = state.message // e.g. "No registered key for this site"
val code = state.ctapErrorCode // e.g. 0x2E
}
else -> {}
}
}
}
session.run() // suspend, runs full caBLE flow
session.cancel() // cancel from another coroutineAfter a successful QR session, the browser can pair with the authenticator. Future sessions reconnect without scanning a QR code.
// Implement LinkedDeviceStore to persist pairings
val linkedStore = object : LinkedDeviceStore {
override suspend fun getIdentity(): AuthenticatorIdentity { /* your long-term keypair */ }
override suspend fun savePairing(contactId: ByteArray, pairedSecret: ByteArray, peerIdentityKey: ByteArray) { /* save */ }
override suspend fun findByContactId(contactId: ByteArray): LinkedClientData? { /* lookup */ }
override suspend fun removePairing(contactId: ByteArray) { /* delete */ }
override suspend fun listAll(): List<LinkedClientData> { /* list all pairings */ }
}
// QR session with pairing enabled — pass linkedDeviceStore
val qrSession = CableSession(
mode = CableSessionMode.Qr(qrData),
ctapProcessor = processor,
bleAdvertiser = advertiser,
linkedDeviceStore = linkedStore, // enables pairing after successful QR session
)
qrSession.run()
// Linked session — reconnect to a previously paired browser
val clientData = linkedStore.findByContactId(contactId)!!
val identity = linkedStore.getIdentity()
val linkedSession = CableSession(
mode = CableSessionMode.Linked(clientData, identity),
ctapProcessor = processor,
bleAdvertiser = advertiser,
)
linkedSession.run() // Noise NKpsk0 handshake, no QR neededCableLinkedListener maintains persistent WebSocket connections and BLE advertising for all paired devices:
val listener = CableLinkedListener(
ctapProcessor = processor,
bleAdvertiser = advertiser,
linkedDeviceStore = linkedStore,
config = CableLinkedListener.Config(
eidRotationIntervalMs = 120_000, // rotate BLE EID every 2 min
reconnectBaseDelayMs = 5_000, // exponential backoff on failure
),
)
listener.onEvent = { event ->
when (event) {
is CableLinkedListener.Event.SessionCompleted -> { /* success */ }
is CableLinkedListener.Event.SessionFailed -> { /* error */ }
is CableLinkedListener.Event.TunnelReconnecting -> { /* retrying */ }
}
}
listener.start(coroutineScope)
// ...
listener.stop()FidoBleGattServer makes the phone appear as a standard BLE FIDO2 security key (UUID 0xFFFD). Supported on Windows and macOS Chrome.
import dev.rivikauth.lib.ble.FidoBleGattServer
val gattServer = FidoBleGattServer(context, processor)
gattServer.onStateChanged = { state -> /* Advertising, Connected, Response sent */ }
gattServer.start() // advertises FIDO service, accepts GATT connections
// ...
gattServer.stop()The BLE framing protocol (BleFrame) handles INIT/CONT frame fragmentation, keepalive notifications, and reassembly per the CTAP2 BLE transport spec.
import dev.rivikauth.lib.nfc.NfcCtapHandler
// 1. Create handler with your CTAP processor
val handler = NfcCtapHandler(
commandProcessor = { data -> runBlocking { ctapProcessor.processCommand(data) } },
maxResponseLen = 256,
)
// 2. In HostApduService.processCommandApdu():
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray =
handler.processApdu(commandApdu)
// 3. In HostApduService.onDeactivated():
override fun onDeactivated(reason: Int) {
handler.reset()
}NfcCtapHandler manages the full APDU state machine: SELECT with AID validation, CTAP_MSG with command chaining (CLA bit 4), and response chaining via GET_RESPONSE (SW 61XX).
CtapProcessor handles authenticatorMakeCredential (0x01), authenticatorGetAssertion (0x02), and authenticatorGetInfo (0x04):
val config = AuthenticatorConfig(
extensions = listOf("credProtect", "hmac-secret", "largeBlobKey"),
transports = listOf("hybrid", "nfc", "ble"),
options = mapOf("rk" to true, "uv" to true),
)
val processor = CtapProcessor(store, masterKeyBytes, config)
// Process raw CTAP2 command bytes
val response = processor.processCommand(commandBytes)
// Build GetInfo for initial caBLE handshake message
val initialMessage = processor.buildInitialMessage()| Extension | makeCredential | getAssertion | Description |
|---|---|---|---|
credProtect |
policy stored | enforcement | Credential protection levels 1-3. Policy 2+ requires allowList for discovery. |
hmac-secret |
CredRandom generated | Phase 2 | Encrypted 32-byte CredRandom stored per credential. |
largeBlobKey |
key generated | key returned | 32-byte key stored encrypted, returned on assertion when requested. |
Extensions are requested via CBOR key 6 (makeCredential) or key 4 (getAssertion). The SDK parses, validates, and stores extension data automatically.
Consumers must implement two interfaces — FidoCredentialStore for passkeys and optionally LinkedDeviceStore for linked sessions:
interface FidoCredentialStore {
suspend fun save(credential: StoredCredential)
suspend fun getByRpId(rpId: String): List<StoredCredential>
suspend fun updateSignCount(id: String, signCount: Long, lastUsedAt: Long)
}
interface LinkedDeviceStore {
suspend fun getIdentity(): AuthenticatorIdentity
suspend fun savePairing(contactId: ByteArray, pairedSecret: ByteArray, peerIdentityKey: ByteArray)
suspend fun findByContactId(contactId: ByteArray): LinkedClientData?
suspend fun removePairing(contactId: ByteArray)
suspend fun listAll(): List<LinkedClientData>
}- Min SDK: 28 (Android 9)
- Compile SDK: 35
- Kotlin: 2.1+
- Java: 17
- cbor-java — CBOR encoding/decoding
- Bouncy Castle — cryptographic primitives (Noise, ECDH, HKDF)
- OkHttp — WebSocket tunnel transport
- Kotlinx Coroutines — async operations
Full CTAP 2.2 hybrid transport (caBLE v2):
- QR-initiated sessions —
FIDO:/URI parsing, Noise KNpsk0 handshake - Linked device (state-assisted) sessions — Noise NKpsk0, reconnect without QR
- Pairing exchange — after QR session, stores pairing data for future linked sessions
- EID generation and AES-256-ECB encryption
- WebSocket tunnel to
cable.ua5v.com - P-256 ECDH (not Curve25519) per CTAP spec
- Bidirectional encrypted CTAP2 message exchange
- CTAP2.1 extensions:
credProtect,hmac-secret,largeBlobKey
Standard FIDO2 BLE transport (GATT server):
- Service UUID
0xFFFDwith fidoControlPoint, fidoStatus, fidoControlPointLength, fidoServiceRevisionBitfield - BLE framing: INIT/CONT frames with fragmentation and reassembly
- Keepalive notifications during CTAP2 processing
- Chrome discovers the device as a standard BLE security key (Windows/macOS)
| Format | Verify | Generate |
|---|---|---|
| packed (full + self) | yes | yes |
| android-key | yes | yes |
| fido-u2f | yes | yes |
| none | yes | yes |
| tpm | yes | — |
| apple | yes | — |
| android-safetynet | yes | — |
| Algorithm | COSE ID | Signing | Attestation |
|---|---|---|---|
| ES256 | -7 | yes | yes |
| ES384 | -35 | — | yes |
| ES512 | -36 | — | yes |
| EdDSA (Ed25519) | -8 | — | yes |
| RS256 | -257 | — | yes |
| PS256 | -37 | — | yes |
| PS384 | -38 | — | yes |
| PS512 | -39 | — | yes |
AGPL-3.0 — free for open-source projects.
For commercial use without AGPL obligations, contact: swarogan@gmail.com