Protocol Documentation
This document describes the protocols used in FIDO Bridge, including CTAP2 message handling, WebAuthn flows, and the custom pairing protocol.
CTAP2 Protocol Overview
FIDO Bridge implements the CTAP2 (Client to Authenticator Protocol 2) specification to communicate between browsers and the YubiKey.
CTAP2 Message Types
GetInfo (0x04)
Purpose: Query authenticator capabilities
Request: No parameters (empty CBOR map)
Response:
{
"versions": ["FIDO_2_0", "U2F_V2"],
"aaguid": "fa2b99dc-9e39-4257-8f92-4a30d23c4118",
"options": {
"rk": true,
"up": true,
"uv": true,
"plat": false,
"clientPin": true
},
"maxMsgSize": 1200,
"pinProtocols": [1, 2],
"extensions": ["credProtect", "hmac-secret"]
}
Implementation:
// From /crates/client/src/uhid_fido.rs
// GetInfo is broadcast to all paired devices
// First response wins, others are cached for future requests
pub async fn handle_getinfo(&mut self, request: Vec<u8>) -> Result<Vec<u8>> {
let transaction_id = uuid::Uuid::new_v4().to_string();
// Create transaction with 240s timeout
let transaction = Transaction::new(
TransactionType::Enumeration,
DEFAULT_TIMEOUT_MS, // 240000ms = 4 minutes
"GetInfo".to_string(),
);
// ... send to all paired devices
}
Timeout: 240 seconds (configurable via DEFAULT_TIMEOUT_MS)
MakeCredential (0x01)
Purpose: Create a new credential (registration)
Request:
{
"clientDataHash": "<32-byte SHA-256 hash>",
"rp": {
"id": "example.com",
"name": "Example Corp"
},
"user": {
"id": "<user identifier>",
"name": "user@example.com",
"displayName": "User Name"
},
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 } // ES256
],
"options": {
"rk": true, // Resident key
"uv": true // User verification
},
"extensions": {
"hmac-secret": true
}
}
Response:
{
"fmt": "packed",
"authData": "<authenticator data>",
"attStmt": {
"alg": -7,
"sig": "<signature>",
"x5c": ["<certificate chain>"]
}
}
Implementation:
// MakeCredential is sent to specifically selected device
pub async fn handle_makecredential(
&mut self,
request: Vec<u8>,
selected_device_id: Option<String>,
) -> Result<Vec<u8>> {
// Require device selection (from previous GetInfo)
let device_id = selected_device_id
.ok_or_else(|| Error::msg("No device selected"))?;
// Create transaction
let transaction = Transaction::new(
TransactionType::Registration,
DEFAULT_TIMEOUT_MS,
"MakeCredential".to_string(),
);
// ... send to selected device
}
Timeout: 240 seconds
GetAssertion (0x02)
Purpose: Authenticate with existing credential
Request:
{
"rpId": "example.com",
"clientDataHash": "<32-byte SHA-256 hash>",
"allowList": [
{
"type": "public-key",
"id": "<credential ID>"
}
],
"options": {
"up": true, // User presence
"uv": true // User verification
},
"extensions": {
"hmac-secret": {
"keyAgreement": "<COSE key>",
"saltEnc": "<encrypted salt>",
"saltAuth": "<HMAC of saltEnc>"
}
}
}
Response:
{
"credential": {
"type": "public-key",
"id": "<credential ID>"
},
"authData": "<authenticator data>",
"signature": "<signature>",
"extensions": {
"hmac-secret": "<encrypted output>"
}
}
Implementation:
pub async fn handle_getassertion(
&mut self,
request: Vec<u8>,
selected_device_id: Option<String>,
) -> Result<Vec<u8>> {
let device_id = selected_device_id
.ok_or_else(|| Error::msg("No device selected"))?;
let transaction = Transaction::new(
TransactionType::Authentication,
DEFAULT_TIMEOUT_MS,
"GetAssertion".to_string(),
);
// ... send to selected device
}
Timeout: 240 seconds
ClientPIN (0x06)
Purpose: PIN protocol for user verification
Sub-Commands:
getKeyAgreement(0x02): Get authenticator's public keygetPINToken(0x05): Obtain PIN tokengetPinUvAuthTokenUsingPinWithPermissions(0x09): Get token with permissions
Request (getKeyAgreement):
{
"pinProtocol": 1,
"subCommand": 2
}
Response (getKeyAgreement):
{
"keyAgreement": {
"1": 2, // kty: EC2
"3": -25, // alg: ECDH-ES+HKDF-256
"-1": 1, // crv: P-256
"-2": "<x-coordinate>",
"-3": "<y-coordinate>"
}
}
Implementation:
// ClientPIN operations use shorter timeout (30s)
pub fn create_clientpin_transaction(/* ... */) -> Transaction {
Transaction {
transaction_id: uuid::Uuid::new_v4().to_string(),
timeout_ms: 30000, // 30 seconds for ClientPIN
// ...
}
}
Timeout: 30 seconds (faster than credential operations)
Session Caching:
// Session cache holds ClientPIN key agreement for 30 seconds
// Reduces NFC taps during multi-step PIN flow
const SESSION_CACHE_TIMEOUT: Duration = Duration::from_secs(30);
CTAP2 Error Codes
FIDO Bridge handles standard CTAP2 error codes:
| Code | Name | Description |
|---|---|---|
| 0x00 | CTAP2_OK | Success |
| 0x01 | CTAP1_ERR_INVALID_COMMAND | Invalid command |
| 0x02 | CTAP1_ERR_INVALID_PARAMETER | Invalid parameter |
| 0x14 | CTAP2_ERR_TIMEOUT | Transaction timeout (240s) |
| 0x2E | CTAP2_ERR_NO_CREDENTIALS | No matching credentials |
| 0x31 | CTAP2_ERR_PIN_REQUIRED | PIN required |
| 0x34 | CTAP2_ERR_PIN_INVALID | Invalid PIN |
| 0x36 | CTAP2_ERR_PIN_BLOCKED | PIN blocked after too many attempts |
WebAuthn Message Types
FIDO Bridge defines custom message types for communication between components.
Message Envelope
All messages use a tagged JSON envelope:
// From /crates/transport/src/webauthn_messages.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum WebAuthnMessage {
GetInfoRequest(GetInfoRequest),
GetInfoResponse(GetInfoResponse),
MakeCredentialRequest(MakeCredentialRequest),
MakeCredentialResponse(MakeCredentialResponse),
GetAssertionRequest(GetAssertionRequest),
GetAssertionResponse(GetAssertionResponse),
ClientPinRequest(ClientPinRequest),
ClientPinResponse(ClientPinResponse),
ErrorResponse(ErrorResponse),
}
Request Messages
GetInfoRequest
{
"type": "GetInfoRequest",
"data": {
"request_id": "req-123e4567-e89b-12d3-a456-426614174000",
"timeout_ms": 240000,
"created_at": 1704067200
}
}
MakeCredentialRequest
{
"type": "MakeCredentialRequest",
"data": {
"request_id": "req-123e4567-e89b-12d3-a456-426614174000",
"transaction_id": "txn-123e4567-e89b-12d3-a456-426614174000",
"timeout_ms": 240000,
"device_id": "linux-desktop-1",
"cbor_data": "<base64-encoded CTAP2 request>",
"created_at": 1704067200
}
}
GetAssertionRequest
{
"type": "GetAssertionRequest",
"data": {
"request_id": "req-123e4567-e89b-12d3-a456-426614174000",
"transaction_id": "txn-123e4567-e89b-12d3-a456-426614174000",
"timeout_ms": 240000,
"device_id": "linux-desktop-1",
"cbor_data": "<base64-encoded CTAP2 request>",
"created_at": 1704067200
}
}
Response Messages
GetInfoResponse
{
"type": "GetInfoResponse",
"data": {
"request_id": "req-123e4567-e89b-12d3-a456-426614174000",
"device_id": "android-phone-1",
"cbor_data": "<base64-encoded CTAP2 response>",
"created_at": 1704067201
}
}
ErrorResponse
{
"type": "ErrorResponse",
"data": {
"request_id": "req-123e4567-e89b-12d3-a456-426614174000",
"error_code": "TIMEOUT",
"error_message": "Transaction timed out after 240 seconds",
"created_at": 1704067440
}
}
Transaction Lifecycle
State Machine
Transaction States
// From /crates/transport/src/transaction.rs
pub enum TransactionState {
AwaitingGetInfo { request_id: String },
DeviceSelection { available_devices: Vec<String> },
AwaitingMakeCredential { request_id: String },
AwaitingGetAssertion { request_id: String },
AwaitingClientPinResponse { request_id: String },
Completed { response_type: String },
Failed { error: String },
Expired,
}
Transaction Creation
impl Transaction {
pub fn new(
transaction_type: TransactionType,
timeout_ms: u32,
display_name: String,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let transaction_id = uuid::Uuid::new_v4().to_string();
Self {
transaction_id: transaction_id.clone(),
state: TransactionState::AwaitingGetInfo {
request_id: format!("{}-getinfo", transaction_id),
},
selected_device_id: None,
created_at: now,
timeout_ms,
expires_at: now + (timeout_ms as u64 / 1000),
rp_id: None,
metadata: TransactionMetadata {
display_name,
transaction_type,
origin: None,
},
}
}
}
Timeout Handling
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
now >= self.expires_at || matches!(self.state, TransactionState::Expired)
}
pub fn remaining_time_secs(&self) -> i64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
(self.expires_at as i64) - (now as i64)
}
Pairing Protocol
Pairing Message Types
// From /crates/transport/src/pairing/protocol.rs
pub struct InitiatorMessage {
pub device_id: DeviceId,
pub device_info: DeviceInfo,
pub spake2_message: Vec<u8>,
pub timestamp: u64,
}
pub struct ResponderMessage {
pub device_id: DeviceId,
pub device_info: DeviceInfo,
pub spake2_message: Vec<u8>,
pub timestamp: u64,
}
Device Information
pub struct DeviceInfo {
pub device_name: String, // e.g., "Alice's ThinkPad"
pub device_type: String, // e.g., "linux", "android"
pub device_model: Option<String>, // e.g., "ThinkPad X1"
pub os_version: Option<String>, // e.g., "Ubuntu 24.04"
}
Auto-Detection:
impl DeviceInfo {
pub fn detect() -> Self {
let device_type = if cfg!(target_os = "linux") {
"linux".to_string()
} else if cfg!(target_os = "android") {
"android".to_string()
} else {
"unknown".to_string()
};
let device_name = Self::get_hostname()
.unwrap_or_else(|| format!("{} Device", device_type.to_uppercase()));
let os_version = Self::get_os_version();
Self { device_name, device_type, device_model: None, os_version }
}
}
Pairing Flow Sequence
Freshness Validation
pub fn verify_message_freshness(timestamp: u64, max_age_seconds: u64) -> PairingResult<()> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| PairingError::ProtocolError("System time error".to_string()))?
.as_secs();
// Reject if message is too old
if now.saturating_sub(timestamp) > max_age_seconds {
return Err(PairingError::ProtocolError("Message too old".to_string()));
}
// Reject if message is from future (allow 60s clock skew)
if timestamp > now + 60 {
return Err(PairingError::ProtocolError("Message from future".to_string()));
}
Ok(())
}
PRF Extension Support
PRF (Pseudo-Random Function) Extension
FIDO Bridge fully supports the WebAuthn PRF extension for advanced cryptographic use cases.
Use Cases:
- Deriving encryption keys from WebAuthn credentials
- Password managers (e.g., deterministic password generation)
- Credential wrapping
Request Format:
{
"extensions": {
"prf": {
"eval": {
"first": "<32-byte salt>"
}
}
}
}
Response Format:
{
"extensions": {
"prf": {
"results": {
"first": "<32-byte output>"
}
}
}
}
Implementation: Transparently forwarded through FIDO Bridge to YubiKey via NFC.
NFC APDU Protocol
YubiKey Communication
The Android app communicates with YubiKey using ISO 14443A NFC APDUs.
APDU Structure:
CLA INS P1 P2 Lc Data Le
Example - Select FIDO2 Applet:
00 A4 04 00 08 A0000006472F0001 00
Example - CTAP2 GetInfo:
80 10 00 00 01 04 00
Response Format:
<data> SW1 SW2
SW1 SW2 = 90 00: SuccessSW1 SW2 = 69 85: Conditions not satisfied (e.g., user presence required)
Implementation: See /app/android/app/src/main/kotlin/com/example/fido_bridge/NfcHandler.kt
Polling Strategy
Linux Client Polling
// From /crates/client/src/main.rs
// Background task polls for messages every 250ms
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
match client.poll_messages().await {
Ok(messages) => {
for message in messages {
// Handle message
}
}
Err(e) => {
log::debug!("Background message polling error: {}", e);
}
}
}
Interval: 250ms (active polling during transactions)
Android App Polling
// Adaptive polling intervals
const activePollInterval = Duration(milliseconds: 250);
const backgroundPollInterval = Duration(seconds: 5);
// Poll more frequently when expecting responses
Timer.periodic(activePollInterval, (timer) async {
final messages = await pollServer();
// Process messages
});
Strategy:
- Active: 250ms when app is in foreground with active transaction
- Background: 5s (or less frequent) when app is backgrounded
- Exponential Backoff: On repeated errors
Message Size Limits
CTAP2 Message Size
Maximum CTAP2 Message: 1200 bytes (per CTAP2 spec)
Fragmentation: Not currently implemented
- Messages larger than 1200 bytes will be rejected
- Future enhancement: Implement CTAP2 fragmentation for large credentials
WebAuthn Message Envelope
Overhead:
- JSON envelope: ~200 bytes
- Encryption overhead: 12 bytes (nonce) + 16 bytes (GCM tag) = 28 bytes
- Base64 encoding: ~33% overhead
Total Maximum: ~2KB per encrypted message
Error Handling
Error Propagation
// Errors propagate from NFC layer → Android → Server → Linux → Browser
pub enum FidoBridgeError {
NfcError(String), // NFC communication failed
TimeoutError(Duration), // Transaction timed out
DeviceNotPaired, // No paired device available
CryptoError(String), // Encryption/decryption failed
ProtocolError(String), // Invalid message format
ServerError(String), // Server returned error
}
Retry Logic
Transient Errors (retry automatically):
- Network timeouts
- Server 503 (Service Unavailable)
- NFC tag lost (user moved YubiKey away)
Permanent Errors (fail immediately):
- Invalid credential ID
- PIN blocked
- User rejected transaction
- CTAP2 error codes (except timeout)
Next Steps
- Security Architecture - Cryptographic implementation details
- Troubleshooting - Protocol-level debugging
- Testing - Protocol test coverage