9845867gave it its own plugin because Tech Tools is browser-only and this needs storage. That was the right reading of the code and the wrong answer for the person using it: Tech Tools is where you go for a utility, and a tool nobody can find is a tool nobody uses. So Tech Tools now has a blueprint, a table and a migration chain where it had none. Its docstring and manifest say why, because "every tool here runs in the browser" was load-bearing documentation and is no longer true. WHAT MOVED /api/securesend -> /api/tools/secrets securesend.create -> tools.secrets securesend0001baseline -> tools0001securesend PLUGIN_TABLE_OWNERS, EXPECTED_HEAD_REVISION, EXEMPT_ENDPOINTS, the universal profile and the api-inventory surface all follow it. THE OLD "NO BACKEND" TEST IS NARROWED, NOT DELETED. It asserted get_blueprint() is None, which is the sort of guard that gets removed when it becomes inconvenient. It now asserts the blueprint serves EXACTLY the three secret routes, with their methods - so a future tool that quietly grows an endpoint or a table has to edit this test, and therefore make the decision on purpose rather than by drift. Asserting methods rather than paths also pins the detail this design turns on: redeeming is POST-ONLY. Chat and mail clients prefetch GET urls to build previews, and a preview that could redeem would consume a one-view secret before its recipient opened the link. Previously that was a comment; now it fails a test. UNINSTALLING THE OLD PLUGIN COULD NOT BE DONE WITH THE PLUGIN TOOLING. `flask plugin uninstall securesend` loads a plugin before uninstalling it, so a plugin whose directory has been deleted cannot be removed by the command that manages plugins - the registry entry had to be edited by hand. Worth fixing separately; it is the same registry-versus-code divergence the device work hit from the other side. MIGRATING A CHAIN THAT HAS ALREADY RUN needs the old table and its version row dropped first, because the relocated chain has no stamp and tries to create a table that exists. Only relevant to anyone who installed securesend during the hour it existed - it did reach the publication repo in4242028- and the fix is DROP TABLE securesendsecrets plus DROP TABLE alembic_version_securesend before running plugin upgrade-all. 1829 tests green. Still backend only: there are no pages yet, so nothing here is reachable by a person.
162 lines
6.8 KiB
Python
162 lines
6.8 KiB
Python
"""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'])
|