FIDO2 Authenticator Testing: Strategies for Hardware and Software Keys

FIDO2 Authenticator Testing: Strategies for Hardware and Software Keys

FIDO2 is the authentication standard that powers WebAuthn and passkeys. It encompasses two layers: WebAuthn (the browser API) and CTAP2 (the Client-to-Authenticator Protocol). Testing FIDO2 authenticators — hardware security keys like YubiKey, software authenticators, and platform authenticators — requires strategies for both the protocol layer and the application layer. This guide covers all of them.

FIDO2 Architecture and What Needs Testing

FIDO2 has three components:

  1. Authenticator — the hardware key or platform biometric (Touch ID, Windows Hello)
  2. Client — the browser that mediates between authenticator and server
  3. Relying Party (RP) — your server that validates credentials

Each component introduces testing requirements:

Layer What can go wrong Testing approach
Authenticator Wrong attestation format, UV failure Virtual authenticator, emulators
Client Incorrect challenge handling, origin mismatch Browser automation with CDP
RP Signature verification failure, replay attacks Unit tests with real crypto libraries

Testing Hardware Security Keys (Without Physical Hardware)

Physical YubiKeys and similar devices can't be automated. The solutions:

SoftHSM + OpenSC Emulation

For server-side CTAP2 testing, software HSMs can simulate authenticator responses:

# Install SoftHSM for Linux CI
<span class="hljs-built_in">sudo apt-get install softhsm2

<span class="hljs-comment"># Initialize a token
softhsm2-util --init-token --slot 0 --label <span class="hljs-string">"test-token" \
  --pin 1234 --so-pin 5678

This lets you test PKCS#11 integration without physical hardware.

Chrome DevTools Protocol Virtual Authenticator

For browser-level FIDO2 testing, Playwright's CDP interface provides the most complete simulation:

const { chromium } = require('@playwright/test');

async function setupFIDO2Authenticator(page, options = {}) {
  const client = await page.context().newCDPSession(page);
  
  await client.send('WebAuthn.enable', { enableUI: false });
  
  const authenticatorOptions = {
    protocol: options.protocol || 'ctap2',
    ctap2Version: options.ctap2Version || 'ctap2_1',
    transport: options.transport || 'usb',
    hasResidentKey: options.hasResidentKey !== false,
    hasUserVerification: options.hasUserVerification !== false,
    hasLargeBlob: options.hasLargeBlob || false,
    hasCredBlob: options.hasCredBlob || false,
    hasMinPinLength: options.hasMinPinLength || false,
    automaticPresenceSimulation: true,
    isUserVerified: options.isUserVerified !== false,
  };

  const { authenticatorId } = await client.send(
    'WebAuthn.addVirtualAuthenticator',
    { options: authenticatorOptions }
  );

  return { client, authenticatorId };
}

Testing Different Authenticator Types

FIDO2 supports multiple transports. Test each:

describe('Authenticator transport support', () => {
  const transports = ['usb', 'nfc', 'ble', 'internal'];

  for (const transport of transports) {
    it(`registers credential via ${transport} transport`, async ({ page }) => {
      const { client } = await setupFIDO2Authenticator(page, { transport });
      
      await page.goto('/register');
      await page.click('[data-testid="register-security-key"]');

      await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
    });
  }
});

Testing CTAP2 Protocol Behaviors

CTAP2 has specific protocol behaviors that affect security. Test the edge cases.

Resident Keys (Discoverable Credentials)

Resident keys (also called discoverable credentials or passkeys) are stored on the authenticator itself. They enable usernameless login:

test('usernameless login with resident credential', async ({ page }) => {
  const { client } = await setupFIDO2Authenticator(page, {
    hasResidentKey: true,
    transport: 'internal',
  });

  // Register with resident key
  await page.goto('/register');
  await page.fill('[name="username"]', 'user@example.com');
  await page.click('[data-testid="create-passkey"]');
  await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible();

  // Authenticate without entering username
  await page.goto('/login');
  // Don't fill username — test usernameless flow
  await page.click('[data-testid="use-passkey"]');

  // Virtual authenticator auto-selects the resident credential
  await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});

test('non-resident credentials require username hint', async ({ page }) => {
  await setupFIDO2Authenticator(page, {
    hasResidentKey: false, // server-side credential
    transport: 'usb',
  });

  await page.goto('/login');
  // Without entering username first, passkey flow should prompt for it
  await page.click('[data-testid="use-passkey"]');
  
  await expect(page.locator('[data-testid="username-prompt"]')).toBeVisible();
});

User Verification (UV) Requirements

User verification (biometric or PIN) can be required or optional:

describe('User verification requirements', () => {
  it('succeeds when UV is required and authenticator supports it', async ({ page }) => {
    await setupFIDO2Authenticator(page, {
      hasUserVerification: true,
      isUserVerified: true,
    });

    // App requires UV for sensitive operations
    await page.goto('/account/delete');
    await page.click('[data-testid="verify-identity"]');

    await expect(page.locator('[data-testid="identity-verified"]')).toBeVisible();
  });

  it('rejects when UV required but not performed', async ({ page }) => {
    await setupFIDO2Authenticator(page, {
      hasUserVerification: true,
      isUserVerified: false, // UV not performed
    });

    await page.goto('/account/delete');
    await page.click('[data-testid="verify-identity"]');

    // Should reject — UV was required but not performed
    await expect(page.locator('[data-testid="verification-failed"]')).toBeVisible();
  });
});

Server-Side FIDO2 Validation Tests

The server validates FIDO2 attestation objects and assertion signatures. These need unit tests with real cryptographic operations.

Testing Attestation Verification

# Using python-fido2 library
from fido2.server import Fido2Server
from fido2.webauthn import PublicKeyCredentialRpEntity

rp = PublicKeyCredentialRpEntity("example.com", "Example Corp")
server = Fido2Server(rp)

def test_valid_attestation_passes():
    """Test that valid CTAP2 attestation is accepted"""
    state, options = server.register_begin(user_info)
    
    # Simulate authenticator response (using test fixtures)
    credential = create_test_credential(options, private_key=TEST_PRIVATE_KEY)
    auth_data, _ = server.register_complete(state, credential)
    
    assert auth_data.credential_data is not None
    assert auth_data.credential_data.aaguid is not None

def test_wrong_origin_rejected():
    """Test that credentials from wrong origin are rejected"""
    state, options = server.register_begin(user_info)
    
    # Credential signed with wrong origin
    credential = create_test_credential(
        options, 
        origin="https://malicious.com",  # wrong origin
        private_key=TEST_PRIVATE_KEY
    )
    
    with pytest.raises(ValueError, match="origin"):
        server.register_complete(state, credential)

def test_replay_attack_rejected():
    """Test that replaying a credential response is rejected"""
    state, options = server.register_begin(user_info)
    credential = create_test_credential(options, private_key=TEST_PRIVATE_KEY)
    
    # First use succeeds
    server.register_complete(state, credential)
    
    # Replay is rejected (challenge already used)
    with pytest.raises(ValueError):
        server.register_complete(state, credential)

Testing Counter Validation

FIDO2 authenticators maintain a signature counter to detect cloned keys:

def test_authenticator_clone_detection():
    """Test that signature counter regression is detected"""
    # Authenticate once — counter = 1
    login_success = authenticate_user(user_id, counter=1)
    assert login_success
    
    # Replay with same counter — should be rejected (possible clone)
    login_duplicate = authenticate_user(user_id, counter=1)
    assert not login_duplicate
    
    # Replay with lower counter — should be rejected
    login_regressed = authenticate_user(user_id, counter=0)
    assert not login_regressed

Testing Multiple Authenticators Per User

Users may register multiple security keys (primary + backup). Test this:

test('user can register and use multiple authenticators', async ({ page }) => {
  const client = await page.context().newCDPSession(page);
  await client.send('WebAuthn.enable', { enableUI: false });

  // Register first authenticator
  const { authenticatorId: auth1 } = await client.send('WebAuthn.addVirtualAuthenticator', {
    options: { protocol: 'ctap2', transport: 'usb', hasResidentKey: true,
               hasUserVerification: true, isUserVerified: true }
  });

  await page.goto('/settings/security');
  await page.click('[data-testid="add-security-key"]');
  await expect(page.locator('[data-testid="key-1"]')).toBeVisible();

  // Remove first authenticator, add second
  await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId: auth1 });
  const { authenticatorId: auth2 } = await client.send('WebAuthn.addVirtualAuthenticator', {
    options: { protocol: 'ctap2', transport: 'nfc', hasResidentKey: true,
               hasUserVerification: true, isUserVerified: true }
  });

  await page.click('[data-testid="add-security-key"]');
  await expect(page.locator('[data-testid="key-2"]')).toBeVisible();

  // Verify both keys appear in the list
  const keyCount = await page.locator('[data-testid^="key-"]').count();
  expect(keyCount).toBe(2);

  // Authenticate with second key
  await page.goto('/login');
  await page.click('[data-testid="use-passkey"]');
  await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});

Attestation Format Testing

FIDO2 supports multiple attestation formats. Test that your RP handles all of them:

ATTESTATION_FORMATS = ['packed', 'tpm', 'android-key', 'fido-u2f', 'none', 'apple']

@pytest.mark.parametrize('fmt', ATTESTATION_FORMATS)
def test_attestation_format_accepted(fmt):
    """Test each attestation format is handled correctly"""
    credential = create_credential_with_attestation_format(fmt)
    
    # For 'none' format, verification should succeed without attestation validation
    # For other formats, behavior depends on RP's attestation requirement level
    result = rp_server.verify_registration(credential)
    
    # At minimum, 'none' attestation must always succeed
    if fmt == 'none':
        assert result.verified

CI/CD Integration for FIDO2 Tests

# .github/workflows/fido2-tests.yml
name: FIDO2 Tests

jobs:
  browser-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Playwright
        run: npx playwright install chromium --with-deps
      - name: Run FIDO2 browser tests
        run: npx playwright test tests/fido2/

  server-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Python deps
        run: pip install fido2 pytest
      - name: Run server-side FIDO2 tests
        run: pytest tests/server/fido2/

Summary

FIDO2 testing requires: virtual authenticators (CDP) for browser-level tests covering resident keys, UV requirements, and transport types; server-side unit tests for attestation verification, origin validation, and replay attack prevention; counter validation tests for clone detection; multiple authenticator tests for backup key scenarios; and parametrized attestation format tests. The most critical tests are the server-side ones — a broken signature verifier silently accepts invalid credentials or rejects valid ones, either of which is a security or usability disaster.

Read more