Skip to content

Commit d5273d8

Browse files
[py] Added virtual authenticator for Python Bindings (#10579)
Co-authored-by: David Burns <[email protected]> Related to #10541
1 parent d92c5a6 commit d5273d8

9 files changed

Lines changed: 909 additions & 1 deletion

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
7+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
8+
<title>Virtual Authenticator Tests</title>
9+
</head>
10+
11+
<body>
12+
13+
<h1>Virtual Authenticator Tests</h1>
14+
15+
<script>
16+
17+
18+
async function registerCredential(options={}) {
19+
options=Object.assign({
20+
authenticatorSelection: {
21+
requireResidentKey: false,
22+
userVerification: 'preferred'
23+
},
24+
rp: {
25+
id: "localhost",
26+
name: "Selenium WebDriver Test",
27+
},
28+
challenge: Int8Array.from("challenge"),
29+
pubKeyCredParams: [
30+
{type: "public-key",alg: -7},
31+
],
32+
user: {
33+
name: "name",
34+
displayName: "displayName",
35+
id: Int8Array.from([1]),
36+
},
37+
},options);
38+
try {
39+
const credential=await navigator.credentials.create({publicKey: options});
40+
return {
41+
status: "OK",
42+
credential: {
43+
id: credential.id,
44+
rawId: Array.from(new Int8Array(credential.rawId)),
45+
transports: credential.response.getTransports(),
46+
}
47+
};
48+
} catch(error) {
49+
return {status: error.toString()};
50+
}
51+
}
52+
53+
async function getCredential(credentials,options={}) {
54+
options=Object.assign({
55+
challenge: Int8Array.from("Winter is Coming"),
56+
rpId: "localhost",
57+
allowCredentials: credentials,
58+
userVerification: "preferred",
59+
},options);
60+
try {
61+
const attestation=await navigator.credentials.get({publicKey: options});
62+
return {
63+
status: "OK",
64+
attestation: {
65+
userHandle: new Int8Array(attestation.response.userHandle),
66+
},
67+
};
68+
} catch(error) {
69+
return {status: error.toString()};
70+
}
71+
}
72+
</script>
73+
</body>
74+
75+
</html>
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import functools
19+
20+
from base64 import urlsafe_b64encode, urlsafe_b64decode
21+
from enum import Enum
22+
from typing import Literal
23+
24+
25+
class Protocol(Enum):
26+
"""
27+
Protocol to communicate with the authenticator.
28+
"""
29+
CTAP2 = "ctap2"
30+
U2F = "ctap1/u2f"
31+
32+
33+
class Transport(Enum):
34+
"""
35+
Transport method to communicate with the authenticator.
36+
"""
37+
BLE = "ble"
38+
USB = "usb"
39+
NFC = "nfc"
40+
INTERNAL = "internal"
41+
42+
43+
class VirtualAuthenticatorOptions:
44+
45+
Protocol = Protocol
46+
Transport = Transport
47+
48+
def __init__(self) -> None:
49+
"""Constructor. Initialize VirtualAuthenticatorOptions object.
50+
51+
:default:
52+
- protocol: Protocol.CTAP2
53+
- transport: Transport.USB
54+
- hasResidentKey: False
55+
- hasUserVerification: False
56+
- isUserConsenting: True
57+
- isUserVerified: False
58+
"""
59+
60+
self._protocol: Literal = Protocol.CTAP2
61+
self._transport: Literal = Transport.USB
62+
self._has_resident_key: bool = False
63+
self._has_user_verification: bool = False
64+
self._is_user_consenting: bool = True
65+
self._is_user_verified: bool = False
66+
67+
@property
68+
def protocol(self) -> str:
69+
return self._protocol.value
70+
71+
@protocol.setter
72+
def protocol(self, protocol: Protocol) -> None:
73+
self._protocol = protocol
74+
75+
@property
76+
def transport(self) -> str:
77+
return self._transport.value
78+
79+
@transport.setter
80+
def transport(self, transport: Transport) -> None:
81+
self._transport = transport
82+
83+
@property
84+
def has_resident_key(self) -> None:
85+
return self._has_resident_key
86+
87+
@has_resident_key.setter
88+
def has_resident_key(self, value: bool) -> None:
89+
self._has_resident_key = value
90+
91+
@property
92+
def has_user_verification(self) -> None:
93+
return self._has_user_verification
94+
95+
@has_user_verification.setter
96+
def has_user_verification(self, value: bool) -> None:
97+
self._has_user_verification = value
98+
99+
@property
100+
def is_user_consenting(self) -> None:
101+
return self._is_user_consenting
102+
103+
@is_user_consenting.setter
104+
def is_user_consenting(self, value: bool) -> None:
105+
self._is_user_consenting = value
106+
107+
@property
108+
def is_user_verified(self) -> None:
109+
return self._is_user_verified
110+
111+
@is_user_verified.setter
112+
def is_user_verified(self, value: bool) -> None:
113+
self._is_user_verified = value
114+
115+
def to_dict(self) -> dict:
116+
return {
117+
"protocol": self.protocol,
118+
"transport": self.transport,
119+
"hasResidentKey": self.has_resident_key,
120+
"hasUserVerification": self.has_user_verification,
121+
"isUserConsenting": self.is_user_consenting,
122+
"isUserVerified": self.is_user_verified
123+
}
124+
125+
126+
class Credential:
127+
def __init__(self, credential_id: bytes, is_resident_credential: bool, rp_id: str, user_handle: bytes, private_key: bytes, sign_count: int):
128+
"""Constructor. A credential stored in a virtual authenticator.
129+
https://w3c.github.io/webauthn/#credential-parameters
130+
131+
:Args:
132+
- credential_id (bytes): Unique base64 encoded string.
133+
is_resident_credential (bool): Whether the credential is client-side discoverable.
134+
rp_id (str): Relying party identifier.
135+
user_handle (bytes): userHandle associated to the credential. Must be Base64 encoded string. Can be None.
136+
private_key (bytes): Base64 encoded PKCS#8 private key.
137+
sign_count (int): intital value for a signature counter.
138+
"""
139+
self._id = credential_id
140+
self._is_resident_credential = is_resident_credential
141+
self._rp_id = rp_id
142+
self._user_handle = user_handle
143+
self._private_key = private_key
144+
self._sign_count = sign_count
145+
146+
@property
147+
def id(self):
148+
return urlsafe_b64encode(self._id).decode()
149+
150+
@property
151+
def is_resident_credential(self) -> bool:
152+
return self._is_resident_credential
153+
154+
@property
155+
def rp_id(self):
156+
return self._rp_id
157+
158+
@property
159+
def user_handle(self):
160+
if self._user_handle:
161+
return urlsafe_b64encode(self._user_handle).decode()
162+
return None
163+
164+
@property
165+
def private_key(self):
166+
return urlsafe_b64encode(self._private_key).decode()
167+
168+
@property
169+
def sign_count(self):
170+
return self._sign_count
171+
172+
@classmethod
173+
def create_non_resident_credential(cls, id: bytes, rp_id: str, private_key: bytes, sign_count: int) -> 'Credential':
174+
"""Creates a non-resident (i.e. stateless) credential.
175+
176+
:Args:
177+
- id (bytes): Unique base64 encoded string.
178+
- rp_id (str): Relying party identifier.
179+
- private_key (bytes): Base64 encoded PKCS
180+
- sign_count (int): intital value for a signature counter.
181+
182+
:Returns:
183+
- Credential: A non-resident credential.
184+
"""
185+
return cls(id, False, rp_id, None, private_key, sign_count)
186+
187+
@classmethod
188+
def create_resident_credential(cls, id: bytes, rp_id: str, user_handle: bytes, private_key: bytes, sign_count: int) -> 'Credential':
189+
"""Creates a resident (i.e. stateful) credential.
190+
191+
:Args:
192+
- id (bytes): Unique base64 encoded string.
193+
- rp_id (str): Relying party identifier.
194+
- user_handle (bytes): userHandle associated to the credential. Must be Base64 encoded string.
195+
- private_key (bytes): Base64 encoded PKCS
196+
- sign_count (int): intital value for a signature counter.
197+
198+
:returns:
199+
- Credential: A resident credential.
200+
"""
201+
return cls(id, True, rp_id, user_handle, private_key, sign_count)
202+
203+
def to_dict(self):
204+
credential_data = {
205+
'credentialId': self.id,
206+
'isResidentCredential': self._is_resident_credential,
207+
'rpId': self.rp_id,
208+
'privateKey': self.private_key,
209+
'signCount': self.sign_count,
210+
}
211+
212+
if self.user_handle:
213+
credential_data['userHandle'] = self.user_handle
214+
215+
return credential_data
216+
217+
@classmethod
218+
def from_dict(cls, data):
219+
_id = urlsafe_b64decode(data['credentialId'])
220+
is_resident_credential = bool(data['isResidentCredential'])
221+
rp_id = str(data['rpId'])
222+
private_key = urlsafe_b64decode(data['privateKey'])
223+
sign_count = int(data['signCount'])
224+
user_handle = urlsafe_b64decode(data['userHandle']) \
225+
if data.get('userHandle', None) else None
226+
227+
return cls(_id, is_resident_credential, rp_id, user_handle, private_key, sign_count)
228+
229+
def __str__(self) -> str:
230+
return f"Credential(id={self.id}, is_resident_credential={self.is_resident_credential}, rp_id={self.rp_id},\
231+
user_handle={self.user_handle}, private_key={self.private_key}, sign_count={self.sign_count})"
232+
233+
234+
def required_chromium_based_browser(func):
235+
"""
236+
A decorator to ensure that the client used is a chromium based browser.
237+
"""
238+
@functools.wraps(func)
239+
def wrapper(self, *args, **kwargs):
240+
assert self.caps["browserName"].lower() not in ["firefox", "safari"], "This only currently works in Chromium based browsers"
241+
return func(self, *args, **kwargs)
242+
return wrapper
243+
244+
245+
def required_virtual_authenticator(func):
246+
"""
247+
A decorator to ensure that the function is called with a virtual authenticator.
248+
"""
249+
@functools.wraps(func)
250+
@required_chromium_based_browser
251+
def wrapper(self, *args, **kwargs):
252+
if not self.virtual_authenticator_id:
253+
raise ValueError(
254+
"This function requires a virtual authenticator to be set."
255+
)
256+
return func(self, *args, **kwargs)
257+
return wrapper

py/selenium/webdriver/remote/command.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,12 @@ class Command(object):
112112
GET_SHADOW_ROOT = "getShadowRoot"
113113
FIND_ELEMENT_FROM_SHADOW_ROOT = "findElementFromShadowRoot"
114114
FIND_ELEMENTS_FROM_SHADOW_ROOT = "findElementsFromShadowRoot"
115+
116+
# Virtual Authenticator
117+
ADD_VIRTUAL_AUTHENTICATOR = "addVirtualAuthenticator"
118+
REMOVE_VIRTUAL_AUTHENTICATOR = "removeVirtualAuthenticator"
119+
ADD_CREDENTIAL = "addCredential"
120+
GET_CREDENTIALS = "getCredentials"
121+
REMOVE_CREDENTIAL = "removeCredential"
122+
REMOVE_ALL_CREDENTIALS = "removeAllCredentials"
123+
SET_USER_VERIFIED = "setUserVerified"

py/selenium/webdriver/remote/remote_connection.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,21 @@ def __init__(self, remote_server_addr, keep_alive=False, resolve_ip=None, ignore
308308
Command.MINIMIZE_WINDOW:
309309
('POST', '/session/$sessionId/window/minimize'),
310310
Command.PRINT_PAGE:
311-
('POST', '/session/$sessionId/print')
311+
('POST', '/session/$sessionId/print'),
312+
Command.ADD_VIRTUAL_AUTHENTICATOR:
313+
('POST', '/session/$sessionId/webauthn/authenticator'),
314+
Command.REMOVE_VIRTUAL_AUTHENTICATOR:
315+
('DELETE', '/session/$sessionId/webauthn/authenticator/$authenticatorId'),
316+
Command.ADD_CREDENTIAL:
317+
('POST', '/session/$sessionId/webauthn/authenticator/$authenticatorId/credential'),
318+
Command.GET_CREDENTIALS:
319+
('GET', '/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials'),
320+
Command.REMOVE_CREDENTIAL:
321+
('DELETE', '/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials/$credentialId'),
322+
Command.REMOVE_ALL_CREDENTIALS:
323+
('DELETE', '/session/$sessionId/webauthn/authenticator/$authenticatorId/credentials'),
324+
Command.SET_USER_VERIFIED:
325+
('POST', '/session/$sessionId/webauthn/authenticator/$authenticatorId/uv'),
312326
}
313327

314328
def execute(self, command, params):

0 commit comments

Comments
 (0)