Skip to content

Commit e83874a

Browse files
committed
Add support for WebAuthN authentication with U2F security keys
This commit adds support for a new WebAuthN signature algorithm for U2F security keys, allowing non-admin Windows users to use these keys for authentication. Previously, U2F security keys worked on Windows, but only when the user was an Administrator. This new WebAuthN signature algorithm will be selected automatically when running as a non-admin on Windows. It can also be requested explicitly. For it to work, though, the server needs to be configured to accept this signature algorithm. At the moment, it is disabled by default on OpenSSH. Here's the necessary config to enable it: PubkeyAcceptedAlgorithms=+webauthn-sk-ecdsa-sha2-nistp256@openssh.com
1 parent 51b7846 commit e83874a

File tree

9 files changed

+284
-77
lines changed

9 files changed

+284
-77
lines changed

asyncssh/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3463,6 +3463,9 @@ def _choose_signature_alg(self, keypair: _ClientHostKey) -> bool:
34633463

34643464
if self._server_sig_algs:
34653465
for alg in keypair.sig_algorithms:
3466+
if keypair.use_webauthn and not alg.startswith(b'webauthn-'):
3467+
continue
3468+
34663469
if alg in self._sig_algs and alg in self._server_sig_algs:
34673470
keypair.set_sig_algorithm(alg)
34683471
return True

asyncssh/public_key.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ class SSHKey:
249249
pem_name: bytes = b''
250250
pkcs8_oid: Optional[ObjectIdentifier] = None
251251
use_executor: bool = False
252+
use_webauthn: bool = False
252253

253254
def __init__(self, key: Optional[CryptoKey] = None):
254255
self._key = key
@@ -2058,7 +2059,8 @@ def __init__(self, algorithm: bytes, sig_algorithm: bytes,
20582059
public_data: bytes, comment: _Comment,
20592060
cert: Optional[SSHCertificate] = None,
20602061
filename: Optional[bytes] = None,
2061-
use_executor: bool = False):
2062+
use_executor: bool = False,
2063+
use_webauthn: bool = False):
20622064
self.key_algorithm = algorithm
20632065
self.key_public_data = public_data
20642066

@@ -2067,6 +2069,7 @@ def __init__(self, algorithm: bytes, sig_algorithm: bytes,
20672069
self._filename = filename
20682070

20692071
self.use_executor = use_executor
2072+
self.use_webauthn = use_webauthn
20702073

20712074
if cert:
20722075
if cert.key.public_data != self.key_public_data:
@@ -2250,8 +2253,9 @@ def __init__(self, key: SSHKey, pubkey: Optional[SSHKey] = None,
22502253
comment = None
22512254

22522255
super().__init__(key.algorithm, key.algorithm, key.sig_algorithms,
2253-
key.sig_algorithms, key.public_data, comment, cert,
2254-
key.get_filename(), key.use_executor)
2256+
key.sig_algorithms, key.public_data, comment,
2257+
cert, key.get_filename(), key.use_executor,
2258+
key.use_webauthn)
22552259

22562260
self._key = key
22572261

@@ -3856,7 +3860,6 @@ def load_resident_keys(pin: str, *, application: str = 'ssh:',
38563860
38573861
"""
38583862

3859-
application = application.encode('utf-8')
38603863
flags = SSH_SK_USER_PRESENCE_REQD if touch_required else 0
38613864
reserved = b''
38623865

asyncssh/sk.py

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2019-2022 by Ron Frederick <ronf@timeheart.net> and others.
1+
# Copyright (c) 2019-2024 by Ron Frederick <ronf@timeheart.net> and others.
22
#
33
# This program and the accompanying materials are made available under
44
# the terms of the Eclipse Public License v2.0 which accompanies this
@@ -20,6 +20,8 @@
2020

2121
"""U2F security key handler"""
2222

23+
from base64 import urlsafe_b64encode
24+
import ctypes
2325
from hashlib import sha256
2426
import hmac
2527
import time
@@ -54,6 +56,12 @@ def _decode_public_key(alg: int, public_key: Mapping[int, object]) -> bytes:
5456
return b'\x04' + result + cast(bytes, public_key[-3])
5557

5658

59+
def _verify_rp_id(_rp_id: str, _origin: str):
60+
"""Allow any relying party name -- SSH encodes the application here"""
61+
62+
return True
63+
64+
5765
def _ctap1_poll(poll_interval: float, func: Callable[..., _PollResult],
5866
*args: object) -> _PollResult:
5967
"""Poll until a CTAP1 response is received"""
@@ -69,29 +77,28 @@ def _ctap1_poll(poll_interval: float, func: Callable[..., _PollResult],
6977

7078

7179
def _ctap1_enroll(dev: 'CtapHidDevice', alg: int,
72-
application: bytes) -> Tuple[bytes, bytes]:
80+
application: str) -> Tuple[bytes, bytes]:
7381
"""Enroll a new security key using CTAP version 1"""
7482

7583
ctap1 = Ctap1(dev)
7684

7785
if alg != SSH_SK_ECDSA:
7886
raise ValueError('Unsupported algorithm')
7987

80-
app_hash = sha256(application).digest()
88+
app_hash = sha256(application.encode('utf-8')).digest()
8189
registration = _ctap1_poll(_CTAP1_POLL_INTERVAL, ctap1.register,
8290
_dummy_hash, app_hash)
8391

8492
return registration.public_key, registration.key_handle
8593

8694

87-
def _ctap2_enroll(dev: 'CtapHidDevice', alg: int, application: bytes,
95+
def _ctap2_enroll(dev: 'CtapHidDevice', alg: int, application: str,
8896
user: str, pin: Optional[str],
8997
resident: bool) -> Tuple[bytes, bytes]:
9098
"""Enroll a new security key using CTAP version 2"""
9199

92100
ctap2 = Ctap2(dev)
93101

94-
application = application.decode('utf-8')
95102
rp = {'id': application, 'name': application}
96103
user_cred = {'id': user.encode('utf-8'), 'name': user}
97104
key_params = [{'type': 'public-key', 'alg': alg}]
@@ -118,13 +125,31 @@ def _ctap2_enroll(dev: 'CtapHidDevice', alg: int, application: bytes,
118125
return _decode_public_key(alg, cdata.public_key), cdata.credential_id
119126

120127

121-
def _ctap1_sign(dev: 'CtapHidDevice', message_hash: bytes, application: bytes,
128+
def _win_enroll(alg: int, application: str, user: str) -> Tuple[bytes, bytes]:
129+
"""Enroll a new security key using Windows WebAuthn API"""
130+
131+
client = WindowsClient(application, verify=_verify_rp_id)
132+
133+
rp = {'id': application, 'name': application}
134+
user_cred = {'id': user.encode('utf-8'), 'name': user}
135+
key_params = [{'type': 'public-key', 'alg': alg}]
136+
options = {'rp': rp, 'user': user_cred, 'challenge': b'',
137+
'pubKeyCredParams': key_params}
138+
139+
result = client.make_credential(options)
140+
cdata = result.attestation_object.auth_data.credential_data
141+
142+
# pylint: disable=no-member
143+
return _decode_public_key(alg, cdata.public_key), cdata.credential_id
144+
145+
146+
def _ctap1_sign(dev: 'CtapHidDevice', message_hash: bytes, application: str,
122147
key_handle: bytes) -> Tuple[int, int, bytes]:
123148
"""Sign a message with a security key using CTAP version 1"""
124149

125150
ctap1 = Ctap1(dev)
126151

127-
app_hash = sha256(application).digest()
152+
app_hash = sha256(application.encode('utf-8')).digest()
128153

129154
auth_response = _ctap1_poll(_CTAP1_POLL_INTERVAL, ctap1.authenticate,
130155
message_hash, app_hash, key_handle)
@@ -137,13 +162,12 @@ def _ctap1_sign(dev: 'CtapHidDevice', message_hash: bytes, application: bytes,
137162

138163

139164
def _ctap2_sign(dev: 'CtapHidDevice', message_hash: bytes,
140-
application: bytes, key_handle: bytes,
165+
application: str, key_handle: bytes,
141166
touch_required: bool) -> Tuple[int, int, bytes]:
142167
"""Sign a message with a security key using CTAP version 2"""
143168

144169
ctap2 = Ctap2(dev)
145170

146-
application = application.decode('utf-8')
147171
allow_creds = [{'type': 'public-key', 'id': key_handle}]
148172
options = {'up': touch_required}
149173

@@ -160,10 +184,38 @@ def _ctap2_sign(dev: 'CtapHidDevice', message_hash: bytes,
160184
return auth_data.flags, auth_data.counter, assertion.signature
161185

162186

163-
def sk_enroll(alg: int, application: bytes, user: str,
164-
pin: Optional[str], resident: bool) -> Tuple[bytes, bytes]:
187+
def _win_sign(data: bytes, application: str,
188+
key_handle: bytes) -> Tuple[int, int, bytes, bytes]:
189+
"""Sign a message with a security key using Windows WebAuthn API"""
190+
191+
client = WindowsClient(application, verify=_verify_rp_id)
192+
193+
creds = [{'type': 'public-key', 'id': key_handle}]
194+
options = {'challenge': data, 'rpId': application,
195+
'allowCredentials': creds}
196+
197+
result = client.get_assertion(options).get_response(0)
198+
auth_data = result.authenticator_data
199+
200+
return auth_data.flags, auth_data.counter, \
201+
result.signature, bytes(result.client_data)
202+
203+
204+
def sk_webauthn_prefix(data: bytes, application: str) -> bytes:
205+
"""Calculate a WebAuthn request prefix"""
206+
207+
return b'{"type":"webauthn.get","challenge":"' + \
208+
urlsafe_b64encode(data).rstrip(b'=') + b'","origin":"' + \
209+
application.encode('utf-8') + b'"'
210+
211+
212+
def sk_enroll(alg: int, application: str, user: str, pin: Optional[str],
213+
resident: bool) -> Tuple[bytes, bytes]:
165214
"""Enroll a new security key"""
166215

216+
if sk_use_webauthn:
217+
return _win_enroll(alg, application, user)
218+
167219
try:
168220
dev = next(CtapHidDevice.list_devices())
169221
except StopIteration:
@@ -187,22 +239,35 @@ def sk_enroll(alg: int, application: bytes, user: str,
187239
dev.close()
188240

189241

190-
def sk_sign(message_hash: bytes, application: bytes, key_handle: bytes,
191-
flags: int) -> Tuple[int, int, bytes]:
242+
def sk_sign(data: bytes, application: str, key_handle: bytes, flags: int,
243+
is_webauthn: bool = False) -> Tuple[int, int, bytes, bytes]:
192244
"""Sign a message with a security key"""
193245

194246
touch_required = bool(flags & SSH_SK_USER_PRESENCE_REQD)
195247

248+
if is_webauthn and sk_use_webauthn:
249+
return _win_sign(data, application, key_handle)
250+
251+
if is_webauthn:
252+
data = sk_webauthn_prefix(data, application) + b'}'
253+
254+
message_hash = sha256(data).digest()
255+
196256
for dev in CtapHidDevice.list_devices():
197257
try:
198-
return _ctap2_sign(dev, message_hash, application,
199-
key_handle, touch_required)
258+
flags, counter, sig = _ctap2_sign(dev, message_hash, application,
259+
key_handle, touch_required)
260+
261+
return flags, counter, sig, data
200262
except CtapError as exc:
201263
if exc.code != CtapError.ERR.NO_CREDENTIALS:
202264
raise ValueError(str(exc)) from None
203265
except ValueError:
204266
try:
205-
return _ctap1_sign(dev, message_hash, application, key_handle)
267+
flags, counter, sig = _ctap1_sign(dev, message_hash,
268+
application, key_handle)
269+
270+
return flags, counter, sig, data
206271
except ApduError as exc:
207272
if exc.code != APDU.WRONG_DATA:
208273
raise ValueError(str(exc)) from None
@@ -212,11 +277,11 @@ def sk_sign(message_hash: bytes, application: bytes, key_handle: bytes,
212277
raise ValueError('Security key credential not found')
213278

214279

215-
def sk_get_resident(application: bytes, user: Optional[str],
280+
def sk_get_resident(application: str, user: Optional[str],
216281
pin: str) -> Sequence[_SKResidentKey]:
217282
"""Get keys resident on a security key"""
218283

219-
app_hash = sha256(application).digest()
284+
app_hash = sha256(application.encode('utf-8')).digest()
220285
result: List[_SKResidentKey] = []
221286

222287
for dev in CtapHidDevice.list_devices():
@@ -262,13 +327,18 @@ def sk_get_resident(application: bytes, user: Optional[str],
262327

263328

264329
try:
265-
from fido2.hid import CtapHidDevice
330+
from fido2.client import WindowsClient
266331
from fido2.ctap import CtapError
267332
from fido2.ctap1 import Ctap1, APDU, ApduError
268333
from fido2.ctap2 import Ctap2, ClientPin, PinProtocolV1
269334
from fido2.ctap2 import CredentialManagement
335+
from fido2.hid import CtapHidDevice
270336

271337
sk_available = True
338+
339+
sk_use_webauthn = WindowsClient.is_available() and \
340+
hasattr(ctypes, 'windll') and \
341+
not ctypes.windll.shell32.IsUserAnAdmin()
272342
except (ImportError, OSError, AttributeError): # pragma: no cover
273343
sk_available = False
274344

0 commit comments

Comments
 (0)