Skip to content

Conversation

@coreyleavitt
Copy link
Owner

@coreyleavitt coreyleavitt commented Jan 21, 2026

Summary

Implements hardware-backed challenge-response authentication with KEK (Key Encryption Key) wrapping to enable multiple devices to unlock the same database. Based on PR #63 discussion.

Two operating modes:

  • Legacy mode: CR mixed into composite key (KeePassXC-compatible, YubiKey HMAC-SHA1 only)
  • KEK mode: CR used to wrap/unwrap KEK (multi-device, any provider type)

Key changes:

  • Add ChallengeResponseProvider Protocol for structural typing
  • Add YubiKeyHmacSha1 for KeePassXC-compatible HMAC-SHA1
  • Add Fido2HmacSecret ABC with YubiKeyFido2 implementation
  • Add KEK wrapping module (security/kek.py) for multi-device support
  • Add device enrollment API: enroll_device(), revoke_device(), list_enrolled_devices()
  • Add testing module with MockYubiKey, MockFido2, MockProvider
  • Store enrolled devices in header CustomData with AES-256-GCM wrapped KEKs

Design Decisions

  1. KEK mode is default for all enrollments (even single-device)
  2. Legacy mode opt-in only for explicit KeePassXC/KeePassDX compatibility
  3. FIDO2 always uses KEK mode - 32-byte responses rejected in legacy mode
  4. Cannot mix legacy and KEK modes on same database
  5. Protocol not ABC for ChallengeResponseProvider - allows third-party implementations
  6. Mocks in testing module - NOT in security/ to avoid implying they're secure

KEK Architecture

Legacy Mode (KeePassXC-compatible):
  Password + Keyfile + CR_Response → Composite Key → KDF → Master Key

KEK Mode (multi-device):
  Password + Keyfile → Composite Key → KDF → Base Master Key
  Device CR → Unwrap KEK → Base Master ⊕ KEK → Final Master Key

Each enrolled device wraps the same KEK with its unique CR output (AES-256-GCM).

New Public API

from kdbxtool import (
    ChallengeResponseProvider,  # Protocol
    YubiKeyHmacSha1,            # YubiKey HMAC-SHA1 (KeePassXC-compatible)
    YubiKeyFido2,               # YubiKey FIDO2 hmac-secret
)
from kdbxtool.testing import MockYubiKey, MockFido2  # For testing

# KEK mode (recommended) - supports multiple devices
db = Database.create(password="secret")
db.enroll_device(yubikey, label="Primary YubiKey")
db.enroll_device(fido2, label="Backup FIDO2")
db.save("vault.kdbx")

# Either device can open
db = Database.open("vault.kdbx", password="secret", challenge_response_provider=yubikey)
db = Database.open("vault.kdbx", password="secret", challenge_response_provider=fido2)

# Legacy mode (KeePassXC compatible, single YubiKey only)
db.save("vault.kdbx", challenge_response_provider=yubikey)

Test plan

  • All existing tests pass (806 passed, 12 skipped, 5 xfailed)
  • KEK wrap/unwrap unit tests (43 tests)
  • Device enrollment API tests
  • KEK mode roundtrip tests
  • Mixed YubiKey + FIDO2 enrollment tests
  • Legacy mode rejection of FIDO2 (32-byte responses)
  • Hardware testing with YubiKey (manual)
  • Type checking with mypy

Implement a Protocol-based abstraction for hardware-backed challenge-response
authentication, enabling pluggable backends without requiring library imports.

Changes:
- Add ChallengeResponseProvider Protocol for structural typing
- Rename HardwareYubiKey to YubiKeyHmacSha1 (protocol-based naming)
- Add Fido2HmacSecret ABC with YubiKeyFido2 implementation
- Add FIDO2 hmac-secret support for broader device compatibility
- Add testing module with MockYubiKey, MockFido2, MockProvider
- Update Database API to use challenge_response_provider parameter
- Add ChallengeResponseError and Fido2Error exception hierarchies
- Support both 20-byte (YubiKey HMAC-SHA1) and 32-byte (FIDO2) responses
- Add multi-key enrollment support in header CustomData

This provides the foundation for supporting various hardware security keys
through a clean, extensible interface.
Implements Key Encryption Key (KEK) mode to enable multiple hardware
devices (YubiKeys, FIDO2 keys) to unlock the same database. Each
enrolled device wraps the same KEK with its unique CR output.

Two operating modes:
- Legacy: CR mixed into composite key (KeePassXC-compatible, YubiKey only)
- KEK: CR used to wrap/unwrap KEK (multi-device, any provider type)

New enrollment API:
- db.enroll_device(provider, label) - enroll a device
- db.revoke_device(label) - remove a device
- db.list_enrolled_devices() - list enrolled devices
- db.kek_mode - whether database uses KEK mode

Key design decisions:
- KEK mode is default for all enrollments (even single-device)
- Legacy mode only for explicit KeePassXC compatibility
- FIDO2 always uses KEK mode (32-byte responses rejected in legacy)
- Cannot mix legacy and KEK modes on same database
@coreyleavitt coreyleavitt changed the title feat: challenge-response provider abstraction for hardware keys feat: challenge-response providers with KEK multi-device support Jan 21, 2026
Critical fixes:
- Add _kek to zeroize_credentials() in database.py
- Fix docstring: nonce is 16 bytes (PyCryptodome default), not 12
- Fix test to check first 16 bytes for nonce uniqueness

High priority fixes:
- Remove unused import parse_device_key_name
- Add wrapped_kek size validation in deserialize_device_entry
- Update EnrolledDevice docstring (60 -> 64 bytes)
- Add KeePassXC incompatibility warning to enroll_device docstring

FIDO2 updates:
- Rewrite fido2.py for python-fido2 v2.0 API compatibility
- Use DefaultClientDataCollector instead of URL string
- Use result.response.attestation_object for credential_id
- Use client_extension_results.hmac_get_secret.output1

Lint/type fixes:
- Fix import sorting (ruff I001)
- Remove unused imports (ruff F401)
- Add strict=True to zip() call (ruff B905)
- Fix line length issues (ruff E501)
- Replace pytest.raises(Exception) with AuthenticationError
- Remove unused type: ignore comments
Add methods for KEK mode management:
- disable_kek_mode(): Remove KEK protection, return to password-only mode
  for migration to KeePassXC or other KeePass applications
- rotate_kek(): Regenerate KEK and re-wrap for specified devices, use
  after revoking a device to invalidate old backups

Security improvements from audit:
- Add runtime warning when enrolling first device (KeePassXC incompatibility)
- Remove device count from error messages (information leakage)
- Add version forward-compatibility check for unknown CR versions
- Fix bug where disable_kek_mode didn't clear challenge_response_provider

Include comprehensive security audit report from 6 specialized agents
covering cryptographic security, memory safety, API design, attack
surface, ecosystem compatibility, and test coverage.
Replace plain SHA-256 hash with HKDF-SHA256 (RFC 5869) when deriving
AES keys from challenge-response outputs. This provides domain separation
to ensure keys derived for KEK wrapping cannot be confused with keys
derived for other purposes, even if the same CR response is used elsewhere.

- Add _hkdf_sha256() using standard library hmac module
- Add HKDF_INFO_KEK_WRAP constant for domain separation
- Update wrap_kek() and unwrap_kek() to use HKDF
- Add comprehensive tests for HKDF function
Reorder enroll_device() to test the provider before modifying any
database state. Previously, if the provider failed during first
enrollment (e.g., device not connected), the database could be left
with a salt generated but no KEK set.

This only affects first enrollment when converting to KEK mode -
subsequent device additions already have the salt set, so provider
failure leaves no partial state. The bug was likely missed because
most testing uses already-enrolled databases.

Now enrollment either fully succeeds or leaves no state changes:
1. Generate temporary salt (not stored yet)
2. Test provider with challenge_response()
3. Only on success, store salt and proceed with KEK generation

Add tests verifying atomic behavior for both first enrollment and
adding subsequent devices.
Reject challenge-response outputs shorter than 16 bytes (128 bits) to
prevent weak key derivation from providers returning very short responses.

- Add MIN_CR_RESPONSE_LENGTH constant (16 bytes)
- Validate in wrap_kek() and unwrap_kek()
- Add tests for length validation

YubiKey HMAC-SHA1 (20 bytes) and FIDO2 hmac-secret (32 bytes) both
exceed this minimum.
Remove xfail marker from TestMixedProviderTypes and update tests to use
KEK mode (enroll_device) instead of legacy mode (challenge_response_provider).

Legacy mode only supports YubiKey HMAC-SHA1 (20 bytes), while FIDO2
produces 32-byte responses and must use KEK mode.

Tests verify that using the wrong provider type fails to open a database.
Add tests for error handling edge cases identified in security audit:

test_kek.py:
- Fix docstring typo (60 -> 64 bytes)
- Truncated wrapped value at nonce/tag/ciphertext boundaries
- Very long CR response handling
- Empty CR response rejection

test_multi_key.py (new TestKekModeErrorPaths class):
- Wrong password + right device
- Right password + wrong device
- Corrupted file handling
- Error messages don't leak sensitive info
New docs/hardware-keys.md covering:
- Compatibility matrix (kdbxtool vs KeePassXC/KeePassDX/KeePass)
- Legacy vs KEK operating modes
- Mode selection guide
- Backup strategies (multiple devices, add later, password-only, migration)
- Device management (list, revoke, rotate KEK)
- Security considerations

Update docs/index.md to include new page and mention FIDO2/multi-device.
@coreyleavitt
Copy link
Owner Author

Hardware Key Testing Instructions

This PR adds KEK (Key Encryption Key) wrapping for multi-device challenge-response support. To verify the implementation, hardware testing with a physical YubiKey is required.

Prerequisites

  • YubiKey with HMAC-SHA1 configured in slot 2
  • Configure with: ykman otp chalresp --generate 2

Running Hardware Tests

# Run all hardware tests (22 tests total)
pytest -m hardware tests/test_hardware_keys.py -v

# With specific YubiKey serial
YUBIKEY_SERIAL=12345678 pytest -m hardware -v

Test Coverage

Test Class Mode Tests
TestYubiKeyHardware N/A 6 low-level provider tests
TestDatabaseYubiKeyHardware Legacy 5 database integration tests
TestKekModeHardware KEK 9 multi-device enrollment tests
TestKeePassXCCompatibility Both 2 format compatibility tests

KEK Mode Tests (TestKekModeHardware)

  • test_enroll_single_device - Single device enrollment
  • test_enroll_device_bytes_roundtrip - Serialization roundtrip
  • test_modify_and_resave_kek_mode - Modify and resave workflow
  • test_wrong_password_kek_mode - Wrong password rejection
  • test_missing_device_kek_mode - Missing device rejection
  • test_list_enrolled_devices - Device listing
  • test_disable_kek_mode_migration - Migration to password-only
  • test_rotate_kek - KEK rotation
  • test_revoke_device_prevents_access - Device revocation

Expected Results

Without hardware: All 22 tests will be skipped with "No YubiKey connected"

With hardware: All 22 tests should pass

KeePassXC Compatibility Testing (Manual)

To verify legacy mode compatibility:

  1. Create a database with kdbxtool in legacy mode:
from kdbxtool import Database, YubiKeyHmacSha1

db = Database.create(password="testpassword")
db.root_group.create_entry(title="Test", username="user", password="pass")
db.save("test.kdbx", challenge_response_provider=YubiKeyHmacSha1(slot=2))
  1. Open in KeePassXC with the same YubiKey
  2. Verify the entry is accessible

See tests/HARDWARE_TESTING.md for the complete testing guide.

- Rename test_yubikey_hardware.py to test_hardware_keys.py to reflect
  broader scope (KEK mode, FIDO2, multi-device support)
- Add TestKekModeHardware class with 9 tests covering:
  - Device enrollment and database roundtrip
  - Wrong password/missing device rejection
  - Device listing, revocation, KEK rotation
  - disable_kek_mode migration to password-only
- Add TestKeePassXCCompatibility class for format verification
- Create tests/HARDWARE_TESTING.md with:
  - YubiKey setup instructions
  - Test execution guide
  - Troubleshooting section
  - Multi-device and KeePassXC compatibility testing

Total hardware tests: 22 (6 provider, 5 legacy, 9 KEK, 2 compatibility)
Replace "legacy" terminology with clearer naming:
- VERSION_LEGACY -> VERSION_COMPAT
- "Legacy mode" -> "KeePassXC-compatible mode" in docs/comments
- Update error messages to reference KeePassXC-compatible mode
- Update test names and docstrings

The term "legacy" implied deprecated/inferior when this mode is
actually the correct choice for users who need KeePassXC/KeePassDX
interoperability. The new naming makes the purpose clearer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants