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