| 1 | #!/usr/bin/env python3 |
| 2 | import argparse |
| 3 | import base64 |
| 4 | import hashlib |
| 5 | import http.client |
| 6 | import json |
| 7 | import os |
| 8 | from pathlib import Path |
| 9 | import socket |
| 10 | import sqlite3 |
| 11 | import subprocess |
| 12 | import tempfile |
| 13 | import time |
| 14 | import uuid |
| 15 | from cryptography.hazmat.primitives import hashes |
| 16 | from cryptography.hazmat.primitives.asymmetric import ec |
| 17 | from cryptography.hazmat.primitives.kdf.argon2 import Argon2id |
| 18 | |
| 19 | |
| 20 | def b64(value): |
| 21 | return base64.urlsafe_b64encode(value).decode().rstrip('=') |
| 22 | |
| 23 | |
| 24 | def cbor(value): |
| 25 | def header(kind, size): |
| 26 | if size < 24: return bytes([kind * 32 + size]) |
| 27 | width = 1 if size < 256 else 2 if size < 65536 else 4 |
| 28 | return bytes([kind * 32 + {1: 24, 2: 25, 4: 26}[width]]) + size.to_bytes(width, 'big') |
| 29 | if isinstance(value, int): return header(0 if value >= 0 else 1, value if value >= 0 else -value - 1) |
| 30 | if isinstance(value, bytes): return header(2, len(value)) + value |
| 31 | if isinstance(value, str): return header(3, len(value.encode())) + value.encode() |
| 32 | if isinstance(value, dict): return header(5, len(value)) + b''.join(cbor(k) + cbor(v) for k, v in value.items()) |
| 33 | raise TypeError(type(value)) |
| 34 | |
| 35 | |
| 36 | def main(): |
| 37 | parser = argparse.ArgumentParser() |
| 38 | parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard')) |
| 39 | args = parser.parse_args() |
| 40 | origin, file, rp = 'https://snowglobe.paperclover.net', 'https://file.paperclover.net', 'auth.paperclover.net' |
| 41 | name, password, actor = 'auth-test', uuid.uuid4().hex, str(uuid.uuid4()) |
| 42 | group = str(uuid.uuid4()) |
| 43 | salt = os.urandom(16) |
| 44 | digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode()) |
| 45 | private = ec.generate_private_key(ec.SECP256R1()) |
| 46 | public = private.public_key().public_numbers() |
| 47 | key = cbor({1: 2, 3: -7, -1: 1, -2: public.x.to_bytes(32, 'big'), -3: public.y.to_bytes(32, 'big')}) |
| 48 | credential_id = os.urandom(32) |
| 49 | export = {'rpId': rp, 'roles': [{'id': group, 'name': 'infra-admin'}], 'users': [{ |
| 50 | 'id': actor, 'username': name, 'enabled': True, 'email': 'auth-test@example.invalid', 'emailVerified': True, |
| 51 | 'firstName': 'Auth', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [group], |
| 52 | 'credentials': [ |
| 53 | {'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1, 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5, |
| 54 | 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}}, |
| 55 | 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}}, |
| 56 | {'id': str(uuid.uuid4()), 'type': 'webauthn-passwordless', 'userLabel': 'imported', 'createdDate': 1, |
| 57 | 'credentialData': {'credentialId': base64.b64encode(credential_id).decode(), 'credentialPublicKey': b64(key), 'counter': 0, 'transports': ['internal']}} |
| 58 | ]}]} |
| 59 | with tempfile.TemporaryDirectory(prefix='dashboard-auth-') as temporary: |
| 60 | data = Path(temporary).resolve() |
| 61 | proof = uuid.uuid4().hex + uuid.uuid4().hex |
| 62 | (data / 'proof').write_text(proof) |
| 63 | (data / 'source.json').write_text(json.dumps(export)) |
| 64 | environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data), |
| 65 | 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': rp, 'STUDIO_FILE_ORIGIN': file, |
| 66 | 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'} |
| 67 | binary = str(args.binary.resolve()) |
| 68 | imported = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) |
| 69 | assert imported.returncode == 0, imported.stderr |
| 70 | assert json.loads(imported.stdout) == {'accounts': 1, 'credentials': 2} |
| 71 | again = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True) |
| 72 | assert again.returncode == 0 |
| 73 | changed = json.loads(json.dumps(export)); changed['users'][0]['username'] = 'different' |
| 74 | (data / 'different.json').write_text(json.dumps(changed)) |
| 75 | rejected = subprocess.run([binary, '--import-accounts', str(data / 'different.json')],env=environment,capture_output=True) |
| 76 | assert rejected.returncode != 0 |
| 77 | with socket.socket() as available: |
| 78 | available.bind(('127.0.0.1', 0)); port = available.getsockname()[1] |
| 79 | environment['PORT'] = str(port) |
| 80 | log = (data / 'server.log').open('wb') |
| 81 | server = None |
| 82 | |
| 83 | def start(): |
| 84 | nonlocal server |
| 85 | server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log) |
| 86 | deadline = time.monotonic() + 15 |
| 87 | while True: |
| 88 | try: |
| 89 | with socket.create_connection(('127.0.0.1', port), timeout=.1): break |
| 90 | except OSError: |
| 91 | assert server.poll() is None, (data / 'server.log').read_text()[-2000:] |
| 92 | if time.monotonic() > deadline: raise AssertionError('dashboard did not start') |
| 93 | time.sleep(.05) |
| 94 | |
| 95 | def stop(): |
| 96 | server.terminate(); server.wait(timeout=10) |
| 97 | |
| 98 | def request(path, method='GET', body=None, cookies=None, status=200, extra=None, host=origin, include_headers=False): |
| 99 | headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'} |
| 100 | if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'}) |
| 101 | if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items()) |
| 102 | headers.update(extra or {}) |
| 103 | connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15) |
| 104 | connection.request(method, path, body=json.dumps(body) if body is not None else None, headers=headers) |
| 105 | response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close() |
| 106 | assert response.status == status, (path, response.status, content[:200], (data / "server.log").read_text()[-1000:]) |
| 107 | if cookies is not None and 'set-cookie' in fields: |
| 108 | cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie |
| 109 | key, value = cookie.split(';', 1)[0].split('=', 1); cookies[key] = value |
| 110 | return json.loads(content) if not include_headers and fields.get('content-type', '').startswith('application/json') and content else fields |
| 111 | |
| 112 | cookies = {} |
| 113 | start() |
| 114 | try: |
| 115 | request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'}) |
| 116 | request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'}) |
| 117 | csrf = request('/auth/status', cookies=cookies)['csrf'] |
| 118 | login = {'csrf': csrf, 'username': name, 'password': password, 'next': '/users'} |
| 119 | request('/auth/password', 'POST', login, cookies, 403, {'Origin': 'https://evil.example'}) |
| 120 | request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403) |
| 121 | request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401) |
| 122 | assert request('/auth/password', 'POST', login, cookies)['next'] == '/users' |
| 123 | fields = request('/auth/password', 'POST', {**login, 'remember': False}, cookies=cookies, status=200, include_headers=True) |
| 124 | assert 'Max-Age=' not in fields['set-cookie'] |
| 125 | fields = request('/auth/password', 'POST', {**login, 'remember': True}, cookies=cookies, status=200, include_headers=True) |
| 126 | assert 'Max-Age=2592000' in fields['set-cookie'] |
| 127 | assert 'admin' in request('/api/me', cookies=cookies)['sections'] |
| 128 | request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'}) |
| 129 | assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/' |
| 130 | # Import's password format must verify with the original Keycloak parameters. |
| 131 | with sqlite3.connect(data / 'accounts.sqlite') as db: |
| 132 | phc = json.loads(db.execute("SELECT data FROM credentials WHERE kind='password'").fetchone()[0])['phc'] |
| 133 | assert '$m=7168,t=5,p=1$' in phc |
| 134 | other = {}; csrf2 = request('/auth/status', cookies=other)['csrf'] |
| 135 | begun = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other) |
| 136 | assert begun['options']['publicKey']['rpId'] == rp |
| 137 | |
| 138 | def assertion(begin, origin_value=origin, flags=29, counter=1, handle=actor.encode()): |
| 139 | client = json.dumps({'type': 'webauthn.get', 'challenge': begin['options']['publicKey']['challenge'], 'origin': origin_value, 'crossOrigin': False}).encode() |
| 140 | authenticator = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + counter.to_bytes(4, 'big') |
| 141 | signature = private.sign(authenticator + hashlib.sha256(client).digest(), ec.ECDSA(hashes.SHA256())) |
| 142 | return {'id': b64(credential_id), 'rawId': b64(credential_id), 'type': 'public-key', |
| 143 | 'response': {'authenticatorData': b64(authenticator), 'clientDataJSON': b64(client), 'signature': b64(signature), 'userHandle': b64(handle)}} |
| 144 | |
| 145 | signed = {'csrf': csrf2, 'token': begun['token'], 'credential': assertion(begun)} |
| 146 | request('/auth/passkey/finish', 'POST', signed, other) |
| 147 | request('/auth/passkey/finish', 'POST', signed, other, 403) |
| 148 | for options in [{'origin_value': 'https://evil.example'}, {'flags': 25}, {'flags': 21}, {'counter': 1}, {'handle': uuid.uuid4().bytes}]: |
| 149 | begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other) |
| 150 | request('/auth/passkey/finish', 'POST', {'csrf': csrf2, 'token': begin['token'], 'credential': assertion(begin, **({'counter': 2} | options))}, other, 401) |
| 151 | begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other) |
| 152 | tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged') |
| 153 | request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':tampered}, other, 401) |
| 154 | for handle in [None, uuid.uuid4().bytes]: |
| 155 | begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2}, other) |
| 156 | assert not begin['options']['publicKey'].get('allowCredentials') |
| 157 | credential = assertion(begin, counter=2, handle=handle or actor.encode()) |
| 158 | if handle is None: credential['response'].pop('userHandle') |
| 159 | request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':credential}, other, 401) |
| 160 | begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'remember':False}, other) |
| 161 | assert not begin['options']['publicKey'].get('allowCredentials') |
| 162 | request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':assertion(begin, counter=2)}, other) |
| 163 | assert request('/api/me', cookies=other)['name'] == name |
| 164 | registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies) |
| 165 | new_id = os.urandom(32) |
| 166 | client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode() |
| 167 | authenticator = hashlib.sha256(rp.encode()).digest() + bytes([93]) + bytes(4) + bytes(16) + len(new_id).to_bytes(2, 'big') + new_id + key |
| 168 | credential = {'id': b64(new_id), 'rawId': b64(new_id), 'type': 'public-key', 'response': { |
| 169 | 'clientDataJSON': b64(client), 'attestationObject': b64(cbor({'fmt': 'none', 'authData': authenticator, 'attStmt': {}})), 'transports': ['internal']}} |
| 170 | request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential, 'label': 'new passkey'}, cookies, 204) |
| 171 | request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential}, cookies, 403) |
| 172 | file_cookies = {} |
| 173 | handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file) |
| 174 | flow = file_cookies['__Host-snow-flow'] |
| 175 | assert request('/auth/status?flow=' + flow, cookies={})['service'] == 'copyparty' |
| 176 | callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location'] |
| 177 | request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file) |
| 178 | finished = request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=302, host=file) |
| 179 | assert finished['location'] == file + '/clover/Public/' |
| 180 | request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=403, host=file) |
| 181 | request('/auth/file/check', cookies=file_cookies, status=204, host=file) |
| 182 | request('/auth/file/check', cookies=cookies, status=401, host=file) |
| 183 | request('/api/me', cookies=file_cookies, status=401) |
| 184 | request('/auth/file/sign-in?rd=https://evil.example/', status=400, host=file) |
| 185 | invitation = request('/api/users', 'POST', {'profile': {'username': 'invited', 'email': '', 'firstName': 'Invited', 'lastName': 'Person'}, 'groups': [], 'setup': {'kind': 'invite'}}, cookies, 201) |
| 186 | setup = invitation['url'].split('setup=', 1)[1] |
| 187 | newcomer = {}; new_csrf = request('/auth/status?setup=' + setup, cookies=newcomer)['csrf'] |
| 188 | request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer) |
| 189 | request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410) |
| 190 | request('/api/users', cookies=newcomer, status=403) |
| 191 | directory = request('/api/users', cookies=cookies) |
| 192 | groups = {g['name']: g['id'] for g in directory['groups']} |
| 193 | assert 'ai' in groups |
| 194 | second = request('/api/users', 'POST', {'profile': {'username': 'bulk-second', 'email': '', 'firstName': 'Bulk', 'lastName': 'Second'}, 'groups': [groups['metrics']], 'setup': {'kind': 'invite'}}, cookies, 201) |
| 195 | targets = [invitation['id'], second['id']] |
| 196 | bulk = {'users': targets, 'add': [groups['ai']], 'remove': []} |
| 197 | request('/api/users/groups', 'PUT', bulk, newcomer, 403) |
| 198 | request('/api/users/groups', 'PUT', bulk, cookies, 403, {'Origin': 'https://evil.example'}) |
| 199 | request('/api/users/groups', 'PUT', bulk, cookies, 204) |
| 200 | memberships = {u['id']: {g['name'] for g in u['groups']} for u in request('/api/users', cookies=cookies)['users']} |
| 201 | assert memberships[invitation['id']] == {'ai'} |
| 202 | assert memberships[second['id']] == {'ai', 'metrics'} |
| 203 | assert 'ai' in request('/api/me', cookies=newcomer)['sections'] |
| 204 | request('/api/mcp', cookies=newcomer) |
| 205 | request('/api/users/groups', 'PUT', {**bulk, 'add': [groups['media']], 'users': [invitation['id'], str(uuid.uuid4())]}, cookies, 404) |
| 206 | request('/api/users/groups', 'PUT', {**bulk, 'add': [groups['media']], 'users': [second['id'], actor], 'remove': [group]}, cookies, 400) |
| 207 | request('/api/users/groups', 'PUT', {**bulk, 'remove': [groups['ai']]}, cookies, 400) |
| 208 | request('/api/users/groups', 'PUT', {**bulk, 'add': ['missing-group']}, cookies, 400) |
| 209 | guest_id = str(uuid.uuid4()) |
| 210 | with sqlite3.connect(data / 'accounts.sqlite') as db: |
| 211 | guest = {'username': 'bulk-guest', 'kind': 'guest', 'enabled': True, 'requiredActions': []} |
| 212 | db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (guest_id, json.dumps(guest))) |
| 213 | request('/api/users/groups', 'PUT', {**bulk, 'users': [invitation['id'], guest_id], 'add': [groups['media']]}, cookies, 400) |
| 214 | memberships = {u['id']: {g['name'] for g in u['groups']} for u in request('/api/users', cookies=cookies)['users']} |
| 215 | assert memberships[invitation['id']] == {'ai'} and memberships[second['id']] == {'ai', 'metrics'} |
| 216 | request('/api/users/groups', 'PUT', {**bulk, 'add': [], 'remove': [groups['ai']]}, cookies, 204) |
| 217 | assert 'ai' not in request('/api/me', cookies=newcomer)['sections'] |
| 218 | request('/api/ai', cookies=newcomer, status=403) |
| 219 | request('/api/mcp', cookies=newcomer, status=403) |
| 220 | request('/api/users/' + actor, 'PATCH', {'enabled': False}, cookies, 400) |
| 221 | request('/api/users/' + actor + '/groups/' + group, 'DELETE', {}, cookies, 400) |
| 222 | replacement = request('/api/users/' + invitation['id'] + '/setup-link', 'POST', {}, cookies)['url'] |
| 223 | request('/api/users/' + invitation['id'] + '/setup-link', 'DELETE', {}, cookies, 204) |
| 224 | request('/auth/status?' + replacement.split('?', 1)[1], cookies={}, status=410) |
| 225 | request('/api/users/' + invitation['id'], 'PATCH', {'enabled': False}, cookies, 204) |
| 226 | request('/api/me', cookies=newcomer, status=401) |
| 227 | stop(); start() |
| 228 | request('/api/me', cookies=cookies) |
| 229 | request('/auth/file/check', cookies=file_cookies, status=204, host=file) |
| 230 | shale_session = {}; shale_csrf = request('/auth/status', cookies=shale_session)['csrf'] |
| 231 | request('/auth/password', 'POST', {**login, 'csrf': shale_csrf}, shale_session) |
| 232 | captured = shale_session.copy() |
| 233 | request('/auth/shale/sign-out', cookies=shale_session, status=403) |
| 234 | request('/auth/shale/sign-out', cookies=shale_session, status=403, extra={'Referer': 'https://evil.example/'}) |
| 235 | request('/auth/shale/sign-out', cookies=shale_session, status=403, extra={'Referer': 'https://shale.paperclover.net.evil.example/'}) |
| 236 | request('/auth/shale/sign-out', 'POST', {}, shale_session, 405, {'Referer': 'https://shale.paperclover.net/'}) |
| 237 | request('/api/me', cookies=shale_session) |
| 238 | ended = request('/auth/shale/sign-out', cookies=shale_session, status=303, extra={'Referer': 'https://shale.paperclover.net/'}) |
| 239 | assert ended['location'] == 'https://shale.paperclover.net/' and shale_session['__Host-snow-session'] == '' |
| 240 | request('/api/me', cookies=captured, status=401) |
| 241 | request('/api/me', cookies=cookies) |
| 242 | request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204) |
| 243 | request('/api/me', cookies=cookies, status=401) |
| 244 | request('/auth/file/check', cookies=file_cookies, status=401, host=file) |
| 245 | print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'ai_group_access': 'passed', 'bulk_group_atomicity': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed', 'shale_logout_and_cookie_replay': 'passed'})) |
| 246 | finally: |
| 247 | if server and server.poll() is None: stop() |
| 248 | log.close() |
| 249 | |
| 250 | |
| 251 | if __name__ == '__main__': |
| 252 | main() |