1#!/usr/bin/env python3
2import argparse
3import base64
4import hashlib
5import http.client
6import json
7import os
8from pathlib import Path
9import socket
10import sqlite3
11import subprocess
12import tempfile
13import time
14import uuid
15from cryptography.hazmat.primitives import hashes
16from cryptography.hazmat.primitives.asymmetric import ec
17from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
18
19
20def b64(value):
21 return base64.urlsafe_b64encode(value).decode().rstrip('=')
22
23
24def 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
36def 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
251if __name__ == '__main__':
252 main()