k_card/tests/test_k_server.py

363 lines
14 KiB
Python

"""
Unit + round-trip tests for k_server_app._verify_assertion_token.
Run:
uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
python3 -m unittest tests/test_k_server.py
The unit tests (TestVerifyAssertionToken) only need cbor2 + cryptography.
The round-trip tests (TestVerifyAssertionTokenRoundTrip) also need fido2
(through CardEmulator) — they are skipped automatically if fido2 is absent.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
sys.path.insert(0, os.path.dirname(__file__))
import k_server_app
# ---------------------------------------------------------------------------
# Dependency guards
# ---------------------------------------------------------------------------
try:
import cbor2 # noqa: F401
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.ec import (
ECDSA,
SECP256R1,
generate_private_key,
)
from cryptography.hazmat.primitives.hashes import SHA256
HAS_CRYPTO = True
except ImportError:
HAS_CRYPTO = False
try:
from card_emulator import CardEmulator
HAS_FIDO2 = True
except ImportError:
HAS_FIDO2 = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _b64u_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def _b64u_decode(s: str) -> bytes:
padded = s + "=" * ((4 - len(s) % 4) % 4)
return base64.urlsafe_b64decode(padded)
# COSE ES256 key layout matching card_emulator._cose_es256 exactly.
def _cose_es256(x: bytes, y: bytes) -> bytes:
return (
bytes([0xA5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20])
+ x
+ bytes([0x22, 0x58, 0x20])
+ y
)
def _make_bundle(host: str, nonce: str) -> tuple[str, object]:
"""Return (base64url-token, private_key) for a fresh P-256 assertion.
Mirrors exactly what proxy_service.dart's _handleAuthGetToken produces.
"""
priv = generate_private_key(SECP256R1(), default_backend())
pub = priv.public_key().public_numbers()
x = pub.x.to_bytes(32, "big")
y = pub.y.to_bytes(32, "big")
cose_key = _cose_es256(x, y)
# AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
aaguid = bytes.fromhex("1234567890abcdef0123456789abcdef")
cred_id = os.urandom(16)
cred_data = aaguid + struct.pack(">H", len(cred_id)) + cred_id + cose_key
# Challenge = SHA256(host|nonce) — same as proxy_service.dart
challenge_b64u = _b64u_encode(hashlib.sha256(f"{host}|{nonce}".encode()).digest())
cdj = json.dumps(
{
"type": "webauthn.get",
"challenge": challenge_b64u,
"origin": "https://localhost",
"crossOrigin": False,
},
separators=(",", ":"),
)
cdj_bytes = cdj.encode()
cdh = hashlib.sha256(cdj_bytes).digest()
# authData: rpIdHash(32) + flags(1) + signCount(4)
auth_data = hashlib.sha256(b"localhost").digest() + b"\x01" + struct.pack(">I", 1)
sig = priv.sign(auth_data + cdh, ECDSA(SHA256()))
bundle = {
"v": 1,
"host": host,
"nonce": nonce,
"authData": _b64u_encode(auth_data),
"sig": _b64u_encode(sig),
"cdj": _b64u_encode(cdj_bytes),
"cred": _b64u_encode(cred_data),
"user": "testuser",
}
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode()), priv
def _tamper(token: str, key: str, transform) -> str:
bundle = json.loads(_b64u_decode(token))
bundle[key] = transform(bundle[key])
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
# ---------------------------------------------------------------------------
# Group 1 — unit tests (cbor2 + cryptography only)
# ---------------------------------------------------------------------------
@unittest.skipUnless(HAS_CRYPTO, "cbor2 / cryptography not installed")
class TestVerifyAssertionToken(unittest.TestCase):
def setUp(self):
self.host = "127.0.0.1"
self.nonce = "deadbeef01234567"
self.token, _ = _make_bundle(self.host, self.nonce)
def _check(self, token=None, host=None) -> bool:
return k_server_app._verify_assertion_token(
self.token if token is None else token,
host if host is not None else self.host,
)
def test_valid_token_accepted(self):
self.assertTrue(self._check())
def test_token_valid_for_any_path_on_host(self):
# Domain-level binding: the same token covers all paths on the host.
self.assertTrue(self._check())
def test_cross_server_replay_rejected(self):
# Token issued for self.host must not pass when a different server verifies it.
self.assertFalse(self._check(host="other-server.com"))
def test_cross_server_replay_case_insensitive(self):
# Case variation of the expected host still rejects a token for a different host.
token_b, _ = _make_bundle("BANK.com", self.nonce)
self.assertFalse(k_server_app._verify_assertion_token(token_b, "evil.com"))
def test_tampered_nonce_invalidates_challenge(self):
tampered = _tamper(self.token, "nonce", lambda _: "tampered00000000")
self.assertFalse(self._check(tampered))
def test_tampered_host_invalidates_challenge(self):
tampered = _tamper(self.token, "host", lambda _: "attacker.com")
self.assertFalse(self._check(tampered))
def test_tampered_signature_rejected(self):
def flip_last_byte(b64: str) -> str:
raw = bytearray(_b64u_decode(b64))
raw[-1] ^= 0xFF
return _b64u_encode(bytes(raw))
tampered = _tamper(self.token, "sig", flip_last_byte)
self.assertFalse(self._check(tampered))
def test_wrong_public_key_rejected(self):
other = generate_private_key(SECP256R1(), default_backend())
pub = other.public_key().public_numbers()
x = pub.x.to_bytes(32, "big")
y = pub.y.to_bytes(32, "big")
new_cose = _cose_es256(x, y)
def swap_key(b64: str) -> str:
orig = _b64u_decode(b64)
cred_id_len = (orig[16] << 8) | orig[17]
return _b64u_encode(orig[:18 + cred_id_len] + new_cose)
tampered = _tamper(self.token, "cred", swap_key)
self.assertFalse(self._check(tampered))
def test_malformed_token_returns_false(self):
self.assertFalse(self._check(token="!!!not-base64!!!"))
def test_empty_token_returns_false(self):
self.assertFalse(self._check(token=""))
def test_missing_field_returns_false(self):
bundle = json.loads(_b64u_decode(self.token))
del bundle["sig"]
truncated = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
self.assertFalse(self._check(truncated))
def test_cdj_wrong_type_rejected(self):
bundle = json.loads(_b64u_decode(self.token))
cdj = json.loads(_b64u_decode(bundle["cdj"]))
cdj["type"] = "webauthn.create" # wrong type
bundle["cdj"] = _b64u_encode(json.dumps(cdj, separators=(",", ":")).encode())
tampered = _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
self.assertFalse(self._check(tampered))
# ---------------------------------------------------------------------------
# Group 2 — end-to-end round-trip via CardEmulator (needs fido2)
# ---------------------------------------------------------------------------
@unittest.skipUnless(HAS_FIDO2, "fido2 not installed")
class TestVerifyAssertionTokenRoundTrip(unittest.TestCase):
"""Full round-trip: CardEmulator → assertion bundle → server verification.
Mirrors the actual k_phone flow:
make_credential (enrollment) → get_assertion (per-request binding)
→ bundle as k_server_app.py expects → _verify_assertion_token.
"""
def _register_and_assert(
self,
emulator: "CardEmulator",
host: str,
nonce: str,
) -> str:
"""Return a token string after one register + one assertion."""
# 1. Register — mirrors makeCredential in fido2_ops.dart
reg_cdh = hashlib.sha256(b"registration-placeholder").digest()
attest = emulator.make_credential(
client_data_hash=reg_cdh,
rp={"id": "localhost", "name": "ChromeCard"},
user={"id": b"testuid", "name": "alice"},
key_params=[{"type": "public-key", "alg": -7}],
)
# AttestedCredentialData = authData[37:]
auth_data_make = bytes(attest.auth_data)
cred_data = auth_data_make[37:]
cred_id_len = (cred_data[16] << 8) | cred_data[17]
cred_id = cred_data[18:18 + cred_id_len]
# 2. Assert with domain-level challenge — mirrors _handleAuthGetToken in proxy_service.dart
challenge_b64u = _b64u_encode(
hashlib.sha256(f"{host}|{nonce}".encode()).digest()
)
cdj = json.dumps(
{
"type": "webauthn.get",
"challenge": challenge_b64u,
"origin": "https://localhost",
"crossOrigin": False,
},
separators=(",", ":"),
)
cdj_bytes = cdj.encode()
assertion = emulator.get_assertion(
rp_id="localhost",
client_data_hash=hashlib.sha256(cdj_bytes).digest(),
allow_list=[{"type": "public-key", "id": cred_id}],
)
# 3. Encode bundle — same as proxy_service.dart _handleAuthGetToken
bundle = {
"v": 1,
"host": host,
"nonce": nonce,
"authData": _b64u_encode(bytes(assertion.auth_data)),
"sig": _b64u_encode(bytes(assertion.signature)),
"cdj": _b64u_encode(cdj_bytes),
"cred": _b64u_encode(cred_data),
"user": "alice",
}
return _b64u_encode(json.dumps(bundle, separators=(",", ":")).encode())
def test_roundtrip_accepted(self):
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "cafebabe12345678")
self.assertTrue(
k_server_app._verify_assertion_token(token, "example.com"),
"valid round-trip token must be accepted",
)
def test_roundtrip_valid_for_any_path(self):
"""Domain-level token: accepted regardless of which path is requested."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "aabbccdd11223344")
self.assertTrue(
k_server_app._verify_assertion_token(token, "example.com"),
"domain-level token must be accepted for any path on the host",
)
def test_roundtrip_same_token_tampered_nonce_rejected(self):
"""Changing the nonce in a real assertion bundle breaks verification."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "original00000000")
tampered = _tamper(token, "nonce", lambda _: "tampered11111111")
self.assertFalse(
k_server_app._verify_assertion_token(tampered, "example.com"),
"tampered nonce must break challenge verification",
)
def test_roundtrip_tampered_host_rejected(self):
"""Changing the host in the bundle breaks challenge verification."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "example.com", "deadbeef00112233")
tampered = _tamper(token, "host", lambda _: "attacker.com")
self.assertFalse(
k_server_app._verify_assertion_token(tampered, "example.com"),
"tampered host must break challenge verification",
)
def test_roundtrip_cross_server_replay_rejected(self):
"""Token issued for server-a must not validate on server-b."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "server-a.com", "1122334455667788")
self.assertFalse(
k_server_app._verify_assertion_token(token, "server-b.com"),
"cross-server replay: token for server-a must be rejected by server-b",
)
def test_roundtrip_cross_server_replay_accepted_on_correct_server(self):
"""Sanity: same token is accepted on the server it was issued for."""
emulator = CardEmulator()
token = self._register_and_assert(emulator, "server-a.com", "aabbccdd99887766")
self.assertTrue(
k_server_app._verify_assertion_token(token, "server-a.com"),
"token must still be valid on the correct server",
)
def test_roundtrip_replayed_for_different_user_rejected(self):
"""Two users register separate credentials; each token is only valid for its own key."""
em_a = CardEmulator()
em_b = CardEmulator()
host, nonce = "example.com", "00112233aabbccdd"
token_a = self._register_and_assert(em_a, host, nonce)
token_b = self._register_and_assert(em_b, host, nonce)
# token_a's signature was made with em_a's key — must not verify with em_b's public key.
# Tamper: swap cred (public key) from token_b into token_a's bundle.
bundle_a = json.loads(_b64u_decode(token_a))
bundle_b = json.loads(_b64u_decode(token_b))
bundle_a["cred"] = bundle_b["cred"]
cross = _b64u_encode(json.dumps(bundle_a, separators=(",", ":")).encode())
self.assertFalse(
k_server_app._verify_assertion_token(cross, host),
"cross-user key swap must fail verification",
)
if __name__ == "__main__":
unittest.main(verbosity=2)