Skip to content

swarogan/rivik-fido-sdk

Repository files navigation

rivik-fido-sdk

CI

FIDO2/WebAuthn protocol library for Android — passkeys, attestation, and caBLE hybrid transport without Google Play Services.

Overview

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.

Modules

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

Integration

Composite build (recommended)

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")
}

Usage

WebAuthn — build authenticator data

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,
    ),
)

Attestation — verify or generate

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)

caBLE v2 — hybrid transport session

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 coroutine

Linked device (state-assisted) sessions

After 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 needed

Background linked listener

CableLinkedListener 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()

BLE FIDO transport — GATT security key

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.

NFC — APDU transport for HCE

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).

CTAP2 processor

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()

CTAP2.1 Extensions

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.

FidoCredentialStore interface

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>
}

Requirements

  • Min SDK: 28 (Android 9)
  • Compile SDK: 35
  • Kotlin: 2.1+
  • Java: 17

Dependencies

  • cbor-java — CBOR encoding/decoding
  • Bouncy Castle — cryptographic primitives (Noise, ECDH, HKDF)
  • OkHttp — WebSocket tunnel transport
  • Kotlinx Coroutines — async operations

Protocol details

caBLE v2 implementation

Full CTAP 2.2 hybrid transport (caBLE v2):

  • QR-initiated sessionsFIDO:/ 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

BLE FIDO transport

Standard FIDO2 BLE transport (GATT server):

  • Service UUID 0xFFFD with 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)

Attestation formats

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

Supported algorithms

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

License

AGPL-3.0 — free for open-source projects.

For commercial use without AGPL obligations, contact: swarogan@gmail.com

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages