Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
@@ -0,0 +1,90 @@
|
||||
"""Drives headless Chrome over the DevTools protocol with a stdlib-only WebSocket client, to run real WebAuthn ceremonies."""
|
||||
import base64, json, os, socket, struct, sys, time, urllib.request
|
||||
|
||||
class CDP:
|
||||
def __init__(self, url):
|
||||
host, rest = url[len("ws://"):].split("/", 1)
|
||||
name, port = host.split(":")
|
||||
self.sock = socket.create_connection((name, int(port)))
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.sock.sendall((f"GET /{rest} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response:
|
||||
response += self.sock.recv(4096)
|
||||
assert b" 101 " in response.split(b"\r\n")[0], response
|
||||
self.buffer = response.split(b"\r\n\r\n", 1)[1]
|
||||
self.next_id = 0
|
||||
|
||||
def _read(self, count):
|
||||
while len(self.buffer) < count:
|
||||
chunk = self.sock.recv(65536)
|
||||
if not chunk:
|
||||
raise ConnectionError("closed")
|
||||
self.buffer += chunk
|
||||
data, self.buffer = self.buffer[:count], self.buffer[count:]
|
||||
return data
|
||||
|
||||
def _frame(self):
|
||||
first, second = self._read(2)
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack(">H", self._read(2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack(">Q", self._read(8))[0]
|
||||
return first & 0x0F, first & 0x80, self._read(length)
|
||||
|
||||
def _message(self):
|
||||
payload = b""
|
||||
while True:
|
||||
opcode, final, data = self._frame()
|
||||
if opcode in (0x0, 0x1, 0x2):
|
||||
payload += data
|
||||
if final:
|
||||
return json.loads(payload)
|
||||
|
||||
def send(self, method, **params):
|
||||
self.next_id += 1
|
||||
data = json.dumps({"id": self.next_id, "method": method, "params": params}).encode()
|
||||
mask = os.urandom(4)
|
||||
header = bytes([0x81])
|
||||
if len(data) < 126:
|
||||
header += bytes([0x80 | len(data)])
|
||||
elif len(data) < 65536:
|
||||
header += bytes([0x80 | 126]) + struct.pack(">H", len(data))
|
||||
else:
|
||||
header += bytes([0x80 | 127]) + struct.pack(">Q", len(data))
|
||||
self.sock.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
||||
while True:
|
||||
message = self._message()
|
||||
if message.get("id") == self.next_id:
|
||||
if "error" in message:
|
||||
raise RuntimeError(f"{method}: {message['error']}")
|
||||
return message.get("result", {})
|
||||
|
||||
def evaluate(self, expression):
|
||||
result = self.send("Runtime.evaluate", expression=expression, awaitPromise=True, returnByValue=True)
|
||||
if "exceptionDetails" in result:
|
||||
raise RuntimeError(result["exceptionDetails"].get("exception", {}).get("description", str(result["exceptionDetails"])))
|
||||
return result["result"].get("value")
|
||||
|
||||
def wait_for(self, expression, seconds=10):
|
||||
deadline = time.time() + seconds
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if self.evaluate(expression):
|
||||
return True
|
||||
except RuntimeError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
def connect(port):
|
||||
for _ in range(50):
|
||||
try:
|
||||
targets = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json"))
|
||||
page = next(t for t in targets if t["type"] == "page")
|
||||
return CDP(page["webSocketDebuggerUrl"])
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
sys.exit("Chrome's DevTools endpoint never answered")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Runs real passkey ceremonies against a locally running Website, in headless Chrome with a virtual authenticator.
|
||||
|
||||
swift run Website & # serves http://localhost:8080 over the in-memory database
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --remote-debugging-port=9333 \\
|
||||
--user-data-dir="$(mktemp -d)" about:blank &
|
||||
python3 Tests/Browser/passkeys.py
|
||||
|
||||
Needs a fresh server: it joins accounts that must not exist yet. Stdlib only.
|
||||
"""
|
||||
import os, sys, time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from cdp import connect
|
||||
|
||||
BASE = "http://localhost:8080"
|
||||
chrome = connect(9333)
|
||||
chrome.send("Page.enable")
|
||||
chrome.send("Runtime.enable")
|
||||
chrome.send("WebAuthn.enable")
|
||||
authenticator = chrome.send("WebAuthn.addVirtualAuthenticator", options={
|
||||
"protocol": "ctap2", "transport": "internal", "hasResidentKey": True,
|
||||
"hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True,
|
||||
})["authenticatorId"]
|
||||
|
||||
def open_sign_in(return_path):
|
||||
chrome.send("Page.navigate", url=f"{BASE}/sign-in?return={return_path}")
|
||||
assert chrome.wait_for("document.querySelector('[data-passkey]') && !document.querySelector('[data-passkey]').hidden"), \
|
||||
"the passkey button never appeared"
|
||||
|
||||
def press(email):
|
||||
chrome.evaluate(f"document.querySelector('#email').value = {email!r}; document.querySelector('[data-passkey]').click(); true")
|
||||
|
||||
def status():
|
||||
return chrome.evaluate("document.querySelector('[data-passkey-status]')?.textContent ?? ''")
|
||||
|
||||
def credentials():
|
||||
return chrome.send("WebAuthn.getCredentials", authenticatorId=authenticator)["credentials"]
|
||||
|
||||
results = []
|
||||
def check(name, ok, detail=""):
|
||||
results.append(ok)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' · ' + str(detail) if detail else ''}")
|
||||
|
||||
# 1. An unknown email registers a passkey and creates the account, then returns to the intent.
|
||||
open_sign_in("/search")
|
||||
press("sam.okafor@example.com")
|
||||
landed = chrome.wait_for("location.pathname === '/search'")
|
||||
check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname"))
|
||||
check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
held = credentials()
|
||||
check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held")
|
||||
|
||||
# 2. Signed out, the same passkey signs in with no email at all.
|
||||
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
||||
open_sign_in("/grading-guide")
|
||||
press("")
|
||||
landed = chrome.wait_for("location.pathname === '/grading-guide'")
|
||||
check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "")
|
||||
check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML"))
|
||||
check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"])
|
||||
|
||||
# 3. By email, the ceremony names the account's credential.
|
||||
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
||||
open_sign_in("/")
|
||||
press("Sam.Okafor@Example.com")
|
||||
check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status())
|
||||
|
||||
# 4. A demo account has no passkey, and none can be added to it.
|
||||
chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)")
|
||||
open_sign_in("/")
|
||||
press("mara.v@example.com")
|
||||
refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')")
|
||||
check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status())
|
||||
check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in")
|
||||
|
||||
sys.exit(0 if all(results) else 1)
|
||||
Reference in new issue
Block a user