|
| 1 | +import os |
| 2 | +import sys |
| 3 | + |
| 4 | +from binascii import b2a_hex |
| 5 | +from ..logger import create_logger |
| 6 | + |
| 7 | +logger = create_logger() |
| 8 | + |
| 9 | +try: |
| 10 | + from fido2.ctap2 import Ctap2, ClientPin |
| 11 | + from fido2.ctap import CtapError |
| 12 | + from fido2.hid import CtapHidDevice, get_descriptor, open_connection |
| 13 | + from fido2.cose import ES256 |
| 14 | + |
| 15 | + has_fido2 = True |
| 16 | +except ImportError: |
| 17 | + has_fido2 = False |
| 18 | + |
| 19 | + |
| 20 | +class Fido2Operations: |
| 21 | + @classmethod |
| 22 | + def find_device(cls, credential_id, rp_id="org.borgbackup.fido2"): |
| 23 | + if not has_fido2: |
| 24 | + raise ValueError("No FIDO2 support found. Install the 'fido2' module.") |
| 25 | + for d in CtapHidDevice.list_devices(): |
| 26 | + ctap2 = Ctap2(d) |
| 27 | + |
| 28 | + # It's not our device |
| 29 | + if "hmac-secret" not in ctap2.info.extensions: |
| 30 | + continue |
| 31 | + |
| 32 | + # According to CTAP 2.1 specification, to do pre-flight we |
| 33 | + # need to set up option to false with optionally |
| 34 | + # pinUvAuthParam in assertion[1]. But for authenticator |
| 35 | + # that doesn't support user presence, once up option is |
| 36 | + # present, the authenticator may return |
| 37 | + # CTAP2_ERR_UNSUPPORTED_OPTION[2]. So we simplely omit |
| 38 | + # the option in that case. |
| 39 | + # Reference: |
| 40 | + # 1: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#pre-flight |
| 41 | + # 2: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetAssertion |
| 42 | + # (in step 5) |
| 43 | + options = None |
| 44 | + if ctap2.info.options.get("up", True): |
| 45 | + options = {"up": False} |
| 46 | + try: |
| 47 | + ctap2.get_assertion( |
| 48 | + rp_id=rp_id, |
| 49 | + client_data_hash=b"\x00" * 32, |
| 50 | + allow_list=[{"type": "public-key", "id": credential_id}], |
| 51 | + extensions=None, |
| 52 | + options=options, |
| 53 | + pin_uv_param=None, |
| 54 | + pin_uv_protocol=None, |
| 55 | + event=None, |
| 56 | + on_keepalive=None, |
| 57 | + ) |
| 58 | + except CtapError as e: |
| 59 | + if CtapError.ERR.NO_CREDENTIALS == e.code: |
| 60 | + continue |
| 61 | + raise e |
| 62 | + logger.info(f"Found the FIDO2 device matching the credential: {d.descriptor.path}.") |
| 63 | + return d.descriptor.path |
| 64 | + else: |
| 65 | + logger.error("No matching FIDO2 device found.") |
| 66 | + |
| 67 | + def __init__(self, device=None, pin=None): |
| 68 | + if not has_fido2: |
| 69 | + raise ValueError("No FIDO2 support found. Install the 'fido2' module.") |
| 70 | + if not device: |
| 71 | + raise ValueError("FIDO2 device not specified.") |
| 72 | + self._device_path = device |
| 73 | + self._pin = pin |
| 74 | + |
| 75 | + descriptor = get_descriptor(self._device_path) |
| 76 | + hid_device = CtapHidDevice(descriptor, open_connection(descriptor)) |
| 77 | + self._ctap2 = Ctap2(hid_device) |
| 78 | + self._client_pin = ClientPin(self._ctap2) |
| 79 | + |
| 80 | + # TODO: verify that the device supports hmac-secret |
| 81 | + # if not 'hmac-secret' in self._ctap2.info.extensions: |
| 82 | + # # Oh no! |
| 83 | + |
| 84 | + # Defaults are per table in 5.4 in FIDO2 spec |
| 85 | + self.has_rk = self._ctap2.info.options.get("rk", False) |
| 86 | + self.has_client_pin = self._ctap2.info.options.get("clientPin", False) |
| 87 | + self.has_up = self._ctap2.info.options.get("up", True) |
| 88 | + self.has_uv = self._ctap2.info.options.get("uv", False) |
| 89 | + |
| 90 | + def _hmac_secret_input(self, salt1): |
| 91 | + key_agreement, self._shared_secret = self._client_pin._get_shared_secret() |
| 92 | + salt_enc = self._client_pin.protocol.encrypt(self._shared_secret, salt1) |
| 93 | + salt_auth = self._client_pin.protocol.authenticate(self._shared_secret, salt_enc) |
| 94 | + return {1: key_agreement, 2: salt_enc, 3: salt_auth, 4: self._client_pin.protocol.VERSION} |
| 95 | + |
| 96 | + def _hmac_secret_output(self, data): |
| 97 | + decrypted = self._client_pin.protocol.decrypt(self._shared_secret, data) |
| 98 | + return decrypted[:32] |
| 99 | + |
| 100 | + def _get_assertion(self, salt, credential_id, rp_id="org.borgbackup.fido2"): |
| 101 | + return self._ctap2.get_assertion( |
| 102 | + rp_id=rp_id, |
| 103 | + client_data_hash=b"\x00" * 32, |
| 104 | + allow_list=[{"type": "public-key", "id": credential_id}], |
| 105 | + extensions={"hmac-secret": self._hmac_secret_input(salt)}, |
| 106 | + options=None, |
| 107 | + pin_uv_param=None, |
| 108 | + pin_uv_protocol=self._client_pin.protocol.VERSION, |
| 109 | + event=None, |
| 110 | + on_keepalive=None, |
| 111 | + ) |
| 112 | + |
| 113 | + def use_hmac_hash(self, salt, credential_id): |
| 114 | + |
| 115 | + # TODO: replace with… |
| 116 | + print("\nTouch your authenticator device now...\n", file=sys.stderr) |
| 117 | + assertion = self._get_assertion(salt, credential_id) |
| 118 | + if not assertion.auth_data.extensions.get("hmac-secret"): |
| 119 | + raise Exception("Failed to get assertion with hmac-secret") |
| 120 | + |
| 121 | + secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"]) |
| 122 | + return secret |
| 123 | + |
| 124 | + def generate_hmac_hash(self, user, rp_id="org.borgbackup.fido2"): |
| 125 | + # TODO: decide whether to use or not credentialProtectionPolicy |
| 126 | + if self._pin: |
| 127 | + pin_token = self._client_pin.get_pin_token(self._pin, ClientPin.PERMISSION.MAKE_CREDENTIAL, rp_id) |
| 128 | + pin_auth = self._client_pin.protocol.authenticate(pin_token, b"\x00" * 32) |
| 129 | + elif self.has_client_pin: |
| 130 | + raise ValueError("PIN required but not provided") |
| 131 | + |
| 132 | + if not (self.has_rk or self.has_uv): |
| 133 | + cred_options = None |
| 134 | + else: |
| 135 | + cred_options = {} |
| 136 | + if self.has_rk: |
| 137 | + cred_options["rk"] = False |
| 138 | + if self.has_uv: |
| 139 | + cred_options["uv"] = False |
| 140 | + |
| 141 | + print("\nTouch your authenticator device now...\n", file=sys.stderr) |
| 142 | + result = self._ctap2.make_credential( |
| 143 | + client_data_hash=b"\x00" * 32, |
| 144 | + rp={"id": rp_id, "name": "Borg Repository"}, |
| 145 | + user={"id": user, "name": b2a_hex(user).decode("ascii")}, |
| 146 | + key_params=[{"type": "public-key", "alg": ES256.ALGORITHM}], |
| 147 | + exclude_list=None, |
| 148 | + extensions={"hmac-secret": True}, |
| 149 | + options=cred_options, |
| 150 | + pin_uv_param=pin_auth, |
| 151 | + pin_uv_protocol=self._client_pin.protocol.VERSION, |
| 152 | + event=None, |
| 153 | + on_keepalive=None, |
| 154 | + ) |
| 155 | + |
| 156 | + if result.auth_data.extensions.get("hmac-secret") is None: |
| 157 | + raise Exception("Failed to create credential with hmac-secret") |
| 158 | + logger.info("New credential created with the hmac-secret extension.") |
| 159 | + |
| 160 | + credential_id = result.auth_data.credential_data.credential_id |
| 161 | + |
| 162 | + salt = os.urandom(32) |
| 163 | + print("\nTouch your authenticator device now...\n", file=sys.stderr) |
| 164 | + assertion = self._get_assertion(salt, credential_id) |
| 165 | + |
| 166 | + if not assertion.auth_data.extensions.get("hmac-secret"): |
| 167 | + raise Exception("Failed to get assertion with hmac-secret") |
| 168 | + logger.info("An assertion with hmac-secret value created.") |
| 169 | + |
| 170 | + secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"]) |
| 171 | + |
| 172 | + return credential_id, salt, secret |
0 commit comments