diff --git a/core/src/apps/webauthn/common.py b/core/src/apps/webauthn/common.py new file mode 100644 index 0000000000..91238c5022 --- /dev/null +++ b/core/src/apps/webauthn/common.py @@ -0,0 +1,21 @@ +from micropython import const + +# COSE Key Object Parameter labels +COSE_KEY_KTY = const(1) # identification of the key type +COSE_KEY_ALG = const(3) # algorithm to be used with the key +COSE_KEY_CRV = const(-1) # elliptic curve identifier +COSE_KEY_X = const(-2) # x coordinate of the public key or encoded public key +COSE_KEY_Y = const(-3) # y coordinate of the public key + +# COSE Algorithm values +COSE_ALG_ES256 = const(-7) # ECDSA with SHA-256 +COSE_ALG_EDDSA = const(-8) # EdDSA +COSE_ALG_ECDH_ES_HKDF_256 = const(-25) # ephemeral-static ECDH with HKDF SHA-256 + +# COSE Key Type values +COSE_KEYTYPE_OKP = const(1) # octet key pair +COSE_KEYTYPE_EC2 = const(2) # elliptic curve keys with x- and y-coordinate pair + +# COSE Elliptic Curve values +COSE_CURVE_P256 = const(1) # NIST P-256 curve +COSE_CURVE_ED25519 = const(6) # Ed25519 curve diff --git a/core/src/apps/webauthn/credential.py b/core/src/apps/webauthn/credential.py index 8222d37790..83ed979fba 100644 --- a/core/src/apps/webauthn/credential.py +++ b/core/src/apps/webauthn/credential.py @@ -4,12 +4,14 @@ from ubinascii import hexlify import storage.device from trezor import log, utils -from trezor.crypto import bip32, chacha20poly1305, hashlib, hmac, random +from trezor.crypto import bip32, chacha20poly1305, der, hashlib, hmac, random +from trezor.crypto.curve import ed25519, nist256p1 from apps.common import HARDENED, cbor, seed +from apps.webauthn import common if False: - from typing import Optional + from typing import Iterable, Optional # Credential ID values _CRED_ID_VERSION = b"\xf1\xd0\x02\x00" @@ -17,14 +19,26 @@ _CRED_ID_MIN_LENGTH = const(33) _KEY_HANDLE_LENGTH = const(64) # Credential ID keys -_CRED_ID_RP_ID = const(0x01) -_CRED_ID_RP_NAME = const(0x02) -_CRED_ID_USER_ID = const(0x03) -_CRED_ID_USER_NAME = const(0x04) -_CRED_ID_USER_DISPLAY_NAME = const(0x05) -_CRED_ID_CREATION_TIME = const(0x06) -_CRED_ID_HMAC_SECRET = const(0x07) -_CRED_ID_USE_SIGN_COUNT = const(0x08) +_CRED_ID_RP_ID = const(1) +_CRED_ID_RP_NAME = const(2) +_CRED_ID_USER_ID = const(3) +_CRED_ID_USER_NAME = const(4) +_CRED_ID_USER_DISPLAY_NAME = const(5) +_CRED_ID_CREATION_TIME = const(6) +_CRED_ID_HMAC_SECRET = const(7) +_CRED_ID_USE_SIGN_COUNT = const(8) +_CRED_ID_ALGORITHM = const(9) +_CRED_ID_CURVE = const(10) + +# Defaults +_DEFAULT_ALGORITHM = common.COSE_ALG_ES256 +_DEFAULT_CURVE = common.COSE_CURVE_P256 + +# Curves +_CURVE_NAME = { + common.COSE_CURVE_ED25519: "ed25519", + common.COSE_CURVE_P256: "nist256p1", +} # Key paths _U2F_KEY_PATH = const(0x80553246) @@ -39,13 +53,29 @@ class Credential: self.user_id = None # type: Optional[bytes] def app_name(self) -> str: - return "" + raise NotImplementedError def account_name(self) -> Optional[str]: return None - def private_key(self) -> bytes: - return b"" + def public_key(self) -> bytes: + raise NotImplementedError + + def _private_key(self) -> bytes: + raise NotImplementedError + + def sign(self, data: Iterable[bytes]) -> bytes: + raise NotImplementedError + + def _u2f_sign(self, data: Iterable[bytes]) -> bytes: + dig = hashlib.sha256() + for segment in data: + dig.update(segment) + sig = nist256p1.sign(self._private_key(), dig.digest(), False) + return der.encode_seq((sig[1:33], sig[33:])) + + def bogus_signature(self) -> bytes: + raise NotImplementedError def hmac_secret_key(self) -> Optional[bytes]: return None @@ -71,6 +101,8 @@ class Fido2Credential(Credential): self.creation_time = 0 # type: int self.hmac_secret = False # type: bool self.use_sign_count = False # type: bool + self.algorithm = _DEFAULT_ALGORITHM # type: int + self.curve = _DEFAULT_CURVE # type: int def __lt__(self, other: Credential) -> bool: # Sort FIDO2 credentials newest first amongst each other. @@ -83,29 +115,35 @@ class Fido2Credential(Credential): def generate_id(self) -> None: self.creation_time = storage.device.next_u2f_counter() or 0 - data = cbor.encode( - { - key: value - for key, value in ( - (_CRED_ID_RP_ID, self.rp_id), - (_CRED_ID_RP_NAME, self.rp_name), - (_CRED_ID_USER_ID, self.user_id), - (_CRED_ID_USER_NAME, self.user_name), - (_CRED_ID_USER_DISPLAY_NAME, self.user_display_name), - (_CRED_ID_CREATION_TIME, self.creation_time), - (_CRED_ID_HMAC_SECRET, self.hmac_secret), - (_CRED_ID_USE_SIGN_COUNT, self.use_sign_count), - ) - if value - } - ) + if not self.check_required_fields(): + raise AssertionError + + data = { + key: value + for key, value in ( + (_CRED_ID_RP_ID, self.rp_id), + (_CRED_ID_RP_NAME, self.rp_name), + (_CRED_ID_USER_ID, self.user_id), + (_CRED_ID_USER_NAME, self.user_name), + (_CRED_ID_USER_DISPLAY_NAME, self.user_display_name), + (_CRED_ID_CREATION_TIME, self.creation_time), + (_CRED_ID_HMAC_SECRET, self.hmac_secret), + (_CRED_ID_USE_SIGN_COUNT, self.use_sign_count), + ) + if value + } + + if self.algorithm != _DEFAULT_ALGORITHM or self.curve != _DEFAULT_CURVE: + data[_CRED_ID_ALGORITHM] = self.algorithm + data[_CRED_ID_CURVE] = self.curve + key = seed.derive_slip21_node_without_passphrase( [b"SLIP-0022", _CRED_ID_VERSION, b"Encryption key"] ).key() iv = random.bytes(12) ctx = chacha20poly1305(key, iv) ctx.auth(self.rp_id_hash) - ciphertext = ctx.encrypt(data) + ciphertext = ctx.encrypt(cbor.encode(data)) tag = ctx.finish() self.id = _CRED_ID_VERSION + iv + ciphertext + tag @@ -156,10 +194,13 @@ class Fido2Credential(Credential): cred.creation_time = data.get(_CRED_ID_CREATION_TIME, 0) cred.hmac_secret = data.get(_CRED_ID_HMAC_SECRET, False) cred.use_sign_count = data.get(_CRED_ID_USE_SIGN_COUNT, False) + cred.algorithm = data.get(_CRED_ID_ALGORITHM, _DEFAULT_ALGORITHM) + cred.curve = data.get(_CRED_ID_CURVE, _DEFAULT_CURVE) cred.id = cred_id if ( - not cred.check_required_fields() + (_CRED_ID_ALGORITHM in data) != (_CRED_ID_CURVE in data) + or not cred.check_required_fields() or not cred.check_data_types() or hashlib.sha256(cred.rp_id).digest() != rp_id_hash ): @@ -184,6 +225,8 @@ class Fido2Credential(Credential): and isinstance(self.hmac_secret, bool) and isinstance(self.use_sign_count, bool) and isinstance(self.creation_time, (int, type(None))) + and isinstance(self.algorithm, (int, type(None))) + and isinstance(self.curve, (int, type(None))) and isinstance(self.id, (bytes, bytearray)) ) @@ -200,13 +243,67 @@ class Fido2Credential(Credential): else: return None - def private_key(self) -> bytes: + def _private_key(self) -> bytes: path = [HARDENED | 10022, HARDENED | int.from_bytes(self.id[:4], "big")] + [ HARDENED | i for i in ustruct.unpack(">4L", self.id[-16:]) ] - node = seed.derive_node_without_passphrase(path, "nist256p1") + node = seed.derive_node_without_passphrase(path, _CURVE_NAME[self.curve]) return node.private_key() + def public_key(self) -> bytes: + if self.curve == common.COSE_CURVE_P256: + pubkey = nist256p1.publickey(self._private_key(), False) + return cbor.encode( + { + common.COSE_KEY_ALG: self.algorithm, + common.COSE_KEY_KTY: common.COSE_KEYTYPE_EC2, + common.COSE_KEY_CRV: self.curve, + common.COSE_KEY_X: pubkey[1:33], + common.COSE_KEY_Y: pubkey[33:], + } + ) + elif self.curve == common.COSE_CURVE_ED25519: + pubkey = ed25519.publickey(self._private_key()) + return cbor.encode( + { + common.COSE_KEY_ALG: self.algorithm, + common.COSE_KEY_KTY: common.COSE_KEYTYPE_OKP, + common.COSE_KEY_CRV: self.curve, + common.COSE_KEY_X: pubkey, + } + ) + raise TypeError + + def sign(self, data: Iterable[bytes]) -> bytes: + if (self.algorithm, self.curve) == ( + common.COSE_ALG_ES256, + common.COSE_CURVE_P256, + ): + return self._u2f_sign(data) + elif (self.algorithm, self.curve) == ( + common.COSE_ALG_EDDSA, + common.COSE_CURVE_ED25519, + ): + return ed25519.sign( + self._private_key(), b"".join(bytes(segment) for segment in data) + ) + + raise TypeError + + def bogus_signature(self) -> bytes: + if (self.algorithm, self.curve) == ( + common.COSE_ALG_ES256, + common.COSE_CURVE_P256, + ): + return der.encode_seq((b"\x0a" * 32, b"\x0a" * 32)) + elif (self.algorithm, self.curve) == ( + common.COSE_ALG_EDDSA, + common.COSE_CURVE_ED25519, + ): + return b"\x0a" * 64 + + raise TypeError + def hmac_secret_key(self) -> Optional[bytes]: # Returns the symmetric key for the hmac-secret extension also known as CredRandom. @@ -238,11 +335,20 @@ class U2fCredential(Credential): # Sort U2F credentials lexicographically amongst each other. return self.id < other.id - def private_key(self) -> bytes: + def _private_key(self) -> bytes: if self.node is None: return b"" return self.node.private_key() + def public_key(self) -> bytes: + return nist256p1.publickey(self._private_key(), False) + + def sign(self, data: Iterable[bytes]) -> bytes: + return self._u2f_sign(data) + + def bogus_signature(self) -> bytes: + return der.encode_seq((b"\x0a" * 32, b"\x0a" * 32)) + def generate_key_handle(self) -> None: # derivation path is m/U2F'/r'/r'/r'/r'/r'/r'/r'/r' path = [HARDENED | random.uniform(0x80000000) for _ in range(0, 8)] diff --git a/core/src/apps/webauthn/fido2.py b/core/src/apps/webauthn/fido2.py index 2ee7582522..63b3ab98d5 100644 --- a/core/src/apps/webauthn/fido2.py +++ b/core/src/apps/webauthn/fido2.py @@ -13,6 +13,7 @@ from trezor.ui.popup import Popup from trezor.ui.text import Text from apps.common import cbor +from apps.webauthn import common from apps.webauthn.confirm import ConfirmContent, ConfirmInfo from apps.webauthn.credential import Credential, Fido2Credential, U2fCredential from apps.webauthn.resident_credentials import ( @@ -21,7 +22,7 @@ from apps.webauthn.resident_credentials import ( ) if False: - from typing import Any, Coroutine, List, Optional, Tuple + from typing import Any, Coroutine, Iterable, List, Optional, Tuple _CID_BROADCAST = const(0xFFFFFFFF) # broadcast channel id @@ -122,17 +123,6 @@ _U2F_CONFIRM_TIMEOUT_MS = const( _FIDO2_CONFIRM_TIMEOUT_MS = const(60 * 1000) _POPUP_TIMEOUT_MS = const(4 * 1000) -# CBOR object signing and encryption algorithms and keys -_COSE_ALG_KEY = const(3) -_COSE_ALG_ES256 = const(-7) # ECDSA P-256 with SHA-256 -_COSE_ALG_ECDH_ES_HKDF_256 = const(-25) # Ephemeral-static ECDH with HKDF SHA-256 -_COSE_KEY_TYPE_KEY = const(1) -_COSE_KEY_TYPE_EC2 = const(2) # elliptic curve keys with x- and y-coordinate pair -_COSE_CURVE_KEY = const(-1) # elliptic curve identifier -_COSE_CURVE_P256 = const(1) # P-256 curve -_COSE_X_COORD_KEY = const(-2) # x coordinate of the public key -_COSE_Y_COORD_KEY = const(-3) # y coordinate of the public key - # hid error codes _ERR_NONE = const(0x00) # no error _ERR_INVALID_CMD = const(0x01) # invalid command @@ -181,7 +171,6 @@ _BOGUS_APPID_CHROME = b"A" * 32 _BOGUS_APPID_FIREFOX = b"\0" * 32 _BOGUS_APPIDS = (_BOGUS_APPID_CHROME, _BOGUS_APPID_FIREFOX) _AAGUID = b"\xd6\xd0\xbd\xc3b\xee\xc4\xdb\xde\x8dzenJD\x87" # First 16 bytes of SHA-256("TREZOR 2") -_BOGUS_PRIV_KEY = b"\xAA" * 32 # authentication control byte _AUTH_ENFORCE = const(0x03) # enforce user presence and sign @@ -1167,20 +1156,18 @@ def msg_register(req: Msg, dialog_mgr: DialogManager) -> Cmd: return Cmd(req.cid, _CMD_MSG, buf) -def msg_register_sign(challenge: bytes, cred: U2fCredential) -> bytes: - pubkey = nist256p1.publickey(cred.private_key(), False) - - # hash the request data together with keyhandle and pubkey +def basic_attestation_sign(data: Iterable[bytes]) -> bytes: dig = hashlib.sha256() - dig.update(b"\x00") # uint8_t reserved; - dig.update(cred.rp_id_hash) # uint8_t appId[32]; - dig.update(challenge) # uint8_t chal[32]; - dig.update(cred.id) # uint8_t keyHandle[64]; - dig.update(pubkey) # uint8_t pubKey[65]; - - # sign the digest and convert to der + for segment in data: + dig.update(segment) sig = nist256p1.sign(_U2F_ATT_PRIV_KEY, dig.digest(), False) - sig = der.encode_seq((sig[1:33], sig[33:])) + return der.encode_seq((sig[1:33], sig[33:])) + + +def msg_register_sign(challenge: bytes, cred: U2fCredential) -> bytes: + pubkey = cred.public_key() + + sig = basic_attestation_sign((b"\x00", cred.rp_id_hash, challenge, cred.id, pubkey)) # pack to a response buf, resp = make_struct( @@ -1272,16 +1259,8 @@ def msg_authenticate_sign( ctr = cred.next_signature_counter() ctrbuf = ustruct.pack(">L", ctr) - # hash input data together with counter - dig = hashlib.sha256() - dig.update(rp_id_hash) # uint8_t appId[32]; - dig.update(flags) # uint8_t flags; - dig.update(ctrbuf) # uint8_t ctr[4]; - dig.update(challenge) # uint8_t chal[32]; - - # sign the digest and convert to der - sig = nist256p1.sign(cred.private_key(), dig.digest(), False) - sig = der.encode_seq((sig[1:33], sig[33:])) + # sign the input data together with counter + sig = cred.sign((rp_id_hash, flags, ctrbuf, challenge)) # pack to a response buf, resp = make_struct(resp_cmd_authenticate(len(sig))) @@ -1384,11 +1363,18 @@ def cbor_make_credential(req: Cmd, dialog_mgr: DialogManager) -> Optional[Cmd]: return cmd_error(req.cid, _ERR_CHANNEL_BUSY) return None - # Check that the relying party supports ECDSA P-256 with SHA-256. We don't support any other algorithms. + # Check that the relying party supports ECDSA with SHA-256 or EdDSA. We don't support any other algorithms. pub_key_cred_params = param[_MAKECRED_CMD_PUB_KEY_CRED_PARAMS] - if _COSE_ALG_ES256 not in algorithms_from_pub_key_cred_params( - pub_key_cred_params - ): + for alg in algorithms_from_pub_key_cred_params(pub_key_cred_params): + if alg == common.COSE_ALG_ES256: + cred.algorithm = alg + cred.curve = common.COSE_CURVE_P256 + break + elif alg == common.COSE_ALG_EDDSA: + cred.algorithm = alg + cred.curve = common.COSE_CURVE_ED25519 + break + else: return cbor_error(req.cid, _ERR_UNSUPPORTED_ALGORITHM) # Get options. @@ -1465,31 +1451,26 @@ def cbor_make_credential(req: Cmd, dialog_mgr: DialogManager) -> Optional[Cmd]: return None +def use_self_attestation(rp_id_hash: bytes) -> bool: + from apps.webauthn import knownapps + + app = knownapps.by_rp_id_hash(rp_id_hash) + if app is not None and app.use_self_attestation is not None: + return app.use_self_attestation + else: + return _DEFAULT_USE_SELF_ATTESTATION + + def cbor_make_credential_sign( client_data_hash: bytes, cred: Fido2Credential, user_verification: bool ) -> bytes: - from apps.webauthn import knownapps - - privkey = cred.private_key() - pubkey = nist256p1.publickey(privkey, False) - flags = _AUTH_FLAG_UP | _AUTH_FLAG_AT if user_verification: flags |= _AUTH_FLAG_UV # Encode the authenticator data (Credential ID, its public key and extensions). - credential_pub_key = cbor.encode( - { - _COSE_ALG_KEY: _COSE_ALG_ES256, - _COSE_KEY_TYPE_KEY: _COSE_KEY_TYPE_EC2, - _COSE_CURVE_KEY: _COSE_CURVE_P256, - _COSE_X_COORD_KEY: pubkey[1:33], - _COSE_Y_COORD_KEY: pubkey[33:], - } - ) - att_cred_data = ( - _AAGUID + len(cred.id).to_bytes(2, "big") + cred.id + credential_pub_key + _AAGUID + len(cred.id).to_bytes(2, "big") + cred.id + cred.public_key() ) extensions = b"" @@ -1507,27 +1488,18 @@ def cbor_make_credential_sign( + extensions ) - app = knownapps.by_rp_id_hash(cred.rp_id_hash) - if app is not None and app.use_self_attestation is not None: - use_self_attestation = app.use_self_attestation + if use_self_attestation(cred.rp_id_hash): + sig = cred.sign((authenticator_data, client_data_hash)) + attestation_statement = {"alg": cred.algorithm, "sig": sig} else: - use_self_attestation = _DEFAULT_USE_SELF_ATTESTATION - - # Compute the attestation signature of the authenticator data. - if not use_self_attestation: - privkey = _U2F_ATT_PRIV_KEY - - dig = hashlib.sha256() - dig.update(authenticator_data) - dig.update(client_data_hash) - sig = nist256p1.sign(privkey, dig.digest(), False) - sig = der.encode_seq((sig[1:33], sig[33:])) + sig = basic_attestation_sign((authenticator_data, client_data_hash)) + attestation_statement = { + "alg": common.COSE_ALG_ES256, + "sig": sig, + "x5c": _U2F_ATT_CERT, + } # Encode the authenticatorMakeCredential response data. - attestation_statement = {"alg": _COSE_ALG_ES256, "sig": sig} - if not use_self_attestation: - attestation_statement["x5c"] = [_U2F_ATT_CERT] - return cbor.encode( { _MAKECRED_RESP_FMT: "packed", @@ -1657,16 +1629,16 @@ def cbor_get_assertion_hmac_secret( cred: Credential, hmac_secret: dict ) -> Optional[bytes]: key_agreement = hmac_secret[1] # The public key of platform key agreement key. - # NOTE: We should check the key_agreement[_COSE_ALG_KEY] here, but to avoid compatibility issues we don't, + # NOTE: We should check the key_agreement[COSE_KEY_ALG] here, but to avoid compatibility issues we don't, # because there is currently no valid value which describes the actual key agreement algorithm. if ( - key_agreement[_COSE_KEY_TYPE_KEY] != _COSE_KEY_TYPE_EC2 - or key_agreement[_COSE_CURVE_KEY] != _COSE_CURVE_P256 + key_agreement[common.COSE_KEY_KTY] != common.COSE_KEYTYPE_EC2 + or key_agreement[common.COSE_KEY_CRV] != common.COSE_CURVE_P256 ): return None - x = key_agreement[_COSE_X_COORD_KEY] - y = key_agreement[_COSE_Y_COORD_KEY] + x = key_agreement[common.COSE_KEY_X] + y = key_agreement[common.COSE_KEY_Y] salt_enc = hmac_secret[2] # The encrypted salt. salt_auth = hmac_secret[3] # The HMAC of the encrypted salt. if ( @@ -1739,16 +1711,11 @@ def cbor_get_assertion_sign( ) # Sign the authenticator data and the client data hash. - dig = hashlib.sha256() - dig.update(authenticator_data) - dig.update(client_data_hash) if user_presence: - privkey = cred.private_key() + sig = cred.sign((authenticator_data, client_data_hash)) else: - # Spec deviation: Use a bogus key during silent authentication. - privkey = _BOGUS_PRIV_KEY - sig = nist256p1.sign(privkey, dig.digest(), False) - sig = der.encode_seq((sig[1:33], sig[33:])) + # Spec deviation: Use a bogus signature during silent authentication. + sig = cred.bogus_signature() # Encode the authenticatorGetAssertion response data. response = { @@ -1796,16 +1763,16 @@ def cbor_client_pin(req: Cmd) -> Cmd: return cbor_error(req.cid, _ERR_UNSUPPORTED_OPTION) # Encode the public key of the authenticator key agreement key. - # NOTE: There is currently no valid value for _COSE_ALG_KEY which describes the actual - # key agreement algorithm as specified, but _COSE_ALG_ECDH_ES_HKDF_256 is allegedly + # NOTE: There is currently no valid value for COSE_KEY_ALG which describes the actual + # key agreement algorithm as specified, but COSE_ALG_ECDH_ES_HKDF_256 is allegedly # recommended by the latest draft of the CTAP2 spec. response_data = { _CLIENTPIN_RESP_KEY_AGREEMENT: { - _COSE_ALG_KEY: _COSE_ALG_ECDH_ES_HKDF_256, - _COSE_KEY_TYPE_KEY: _COSE_KEY_TYPE_EC2, - _COSE_CURVE_KEY: _COSE_CURVE_P256, - _COSE_X_COORD_KEY: _KEY_AGREEMENT_PUBKEY[1:33], - _COSE_Y_COORD_KEY: _KEY_AGREEMENT_PUBKEY[33:], + common.COSE_KEY_ALG: common.COSE_ALG_ECDH_ES_HKDF_256, + common.COSE_KEY_KTY: common.COSE_KEYTYPE_EC2, + common.COSE_KEY_CRV: common.COSE_CURVE_P256, + common.COSE_KEY_X: _KEY_AGREEMENT_PUBKEY[1:33], + common.COSE_KEY_Y: _KEY_AGREEMENT_PUBKEY[33:], } } diff --git a/core/tests/test_apps.webauthn.credential.py b/core/tests/test_apps.webauthn.credential.py index 6ac2789b2c..84d63c8349 100644 --- a/core/tests/test_apps.webauthn.credential.py +++ b/core/tests/test_apps.webauthn.credential.py @@ -31,9 +31,9 @@ class TestCredential(unittest.TestCase): creation_time = 2 public_key = ( - b"0451f0d4c307bc737c90ac605c6279f7d01e451798aa7b74df550fdb43a7760c" - b"7c02b5107fef42094d00f52a9b1e90afb90e1b9decbf15a6f13d4f882de857e2" - b"f4" + b"a501020326200121582051f0d4c307bc737c90ac605c6279f7d01e451798aa7b" + b"74df550fdb43a7760c7c22582002b5107fef42094d00f52a9b1e90afb90e1b9d" + b"ecbf15a6f13d4f882de857e2f4" ) cred_random = ( @@ -57,9 +57,7 @@ class TestCredential(unittest.TestCase): # Check credential keys. self.assertEqual(hexlify(cred.hmac_secret_key()), cred_random) - - cred_public_key = nist256p1.publickey(cred.private_key(), False) - self.assertEqual(hexlify(cred_public_key), public_key) + self.assertEqual(hexlify(cred.public_key()), public_key) if __name__ == '__main__': unittest.main()