"""Handing over a credential by link: who may create, who may read, what dies. The interesting tests here are the refusals. A secret that leaks, outlives its window, or is consumed by a link preview before its recipient ever sees it, all fail in ways nobody notices until somebody needs the password. """ import pytest from plugins.tools.models import SecureSecret from plugins.tools.api.routes import MAX_CIPHERTEXT CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw==' IV = 'MTIzNDU2Nzg5MDEy' def _create(client, headers, **overrides): body = {'ciphertext': CIPHER, 'iv': IV} body.update(overrides) return client.post('/api/tools/secrets', json=body, headers=headers) # ------------------------------------------------------------------- creating def test_creating_requires_a_login(client): """Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what.""" resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV}) assert resp.status_code in (401, 403, 422) def test_a_logged_in_user_with_the_permission_can_create(client, auth_headers): resp = _create(client, auth_headers) assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300] data = resp.get_json()['data'] assert data['token'] assert len(data['token']) == 32 # 128 bits of randomness, as hex # The receipt must NOT hand the payload back: that would put the secret in a # second place for no reason. assert 'ciphertext' not in data def test_two_secrets_never_share_a_token(client, auth_headers): first = _create(client, auth_headers).get_json()['data']['token'] second = _create(client, auth_headers).get_json()['data']['token'] assert first != second # -------------------------------------------------------------- size limiting def test_something_document_sized_is_refused(client, auth_headers): """The ONLY server-side control a zero-knowledge design leaves available. The key never reaches the server, so it cannot tell a password from a page of a drawing - it can only bound how much it will hold. """ resp = _create(client, auth_headers, ciphertext='A' * (MAX_CIPHERTEXT + 1)) assert resp.status_code == 400 body = resp.get_data(as_text=True) assert 'too large' in body.lower() # The message has to explain what the limit is FOR: somebody hitting it is # usually trying to send a file, and the answer is a different tool. assert 'document' in body.lower() def test_a_credential_sized_secret_is_fine(client, auth_headers): assert _create(client, auth_headers, ciphertext='A' * 200).status_code in (200, 201) # --------------------------------------------------------------- reading once def test_anyone_can_read_it_without_logging_in(client, auth_headers): """The recipient usually has no account here. The unguessable token is the authorisation.""" token = _create(client, auth_headers).get_json()['data']['token'] resp = client.post('/api/tools/secrets/%s' % token) assert resp.status_code == 200 assert resp.get_json()['data']['ciphertext'] == CIPHER def test_the_second_read_finds_nothing(client, auth_headers): token = _create(client, auth_headers).get_json()['data']['token'] assert client.post('/api/tools/secrets/%s' % token).status_code == 200 assert client.post('/api/tools/secrets/%s' % token).status_code == 404 def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers): """A secret that has been read has no reason to keep its ciphertext.""" token = _create(client, auth_headers).get_json()['data']['token'] client.post('/api/tools/secrets/%s' % token) assert SecureSecret.query.filter_by(token=token).first() is None def test_more_than_one_view_can_be_allowed(client, auth_headers): token = _create(client, auth_headers, views=2).get_json()['data']['token'] assert client.post('/api/tools/secrets/%s' % token).status_code == 200 assert client.post('/api/tools/secrets/%s' % token).status_code == 200 assert client.post('/api/tools/secrets/%s' % token).status_code == 404 # ------------------------------------------------------- link previews (Teams) def test_a_get_does_not_spend_the_secret(client, auth_headers): """THE case that would break this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware. If a GET redeemed the secret, the preview would consume it and the recipient would open the link to find it already used. Redeeming is a POST for exactly this reason. """ token = _create(client, auth_headers).get_json()['data']['token'] peek = client.get('/api/tools/secrets/%s' % token) assert peek.status_code == 200 assert peek.get_json()['data']['available'] is True assert 'ciphertext' not in peek.get_json()['data'] # Still there for the person it was meant for. assert client.post('/api/tools/secrets/%s' % token).status_code == 200 def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers): token = _create(client, auth_headers).get_json()['data']['token'] client.post('/api/tools/secrets/%s' % token) peek = client.get('/api/tools/secrets/%s' % token) assert peek.get_json()['data']['available'] is False # ------------------------------------------------------------------- expiry def test_an_expired_secret_is_gone(client, db, auth_headers): from datetime import datetime, timedelta, timezone token = _create(client, auth_headers).get_json()['data']['token'] secret = SecureSecret.query.filter_by(token=token).first() secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(minutes=1)) db.session.commit() assert client.post('/api/tools/secrets/%s' % token).status_code == 404 assert SecureSecret.query.filter_by(token=token).first() is None def test_an_absurd_expiry_is_refused(client, auth_headers): assert _create(client, auth_headers, expiryhours=10000).status_code == 400 assert _create(client, auth_headers, expiryhours=0).status_code == 400 # ------------------------------------------------------------ not enumerable def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers): """Distinguishing them would confirm to anyone holding a guessed token whether it ever named anything.""" token = _create(client, auth_headers).get_json()['data']['token'] client.post('/api/tools/secrets/%s' % token) spent = client.post('/api/tools/secrets/%s' % token) never = client.post('/api/tools/secrets/%s' % ('f' * 32)) assert spent.status_code == never.status_code == 404 assert (spent.get_json()['data']['error']['message'] == never.get_json()['data']['error']['message'])