The habit this replaces is pasting a credential into Teams, where it stays until someone deletes the conversation. Tabled on 2026-08-21 pending a decision about scope; the decision was text only, so this is the half that survives it. BACKEND ONLY. There is no page yet, so nothing here is reachable by a person. TEXT, NOT DOCUMENTS, and that shapes everything. Dropping files removed the export-control question entirely - a password is not technical data - along with blob storage, size and MIME handling, and the need to authenticate the recipient so a document fetch could be pinned to a name. TWO DOORS. Creating needs a login AND securesend.create: anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what, and the creator is the only person these rows can ever name. Reading needs neither, because the recipient usually has no account here and the link is handed over on a call - the unguessable token IS the authorisation, which is why it is 128 bits of randomness and why the row is spent on first read. REDEEMING IS A POST, and that is the detail that would have broken this in practice. Teams, Outlook and Safe Links prefetch GET urls to build previews and scan for malware, so a GET that redeemed would let a preview window consume a secret before its recipient ever saw it. The GET route that remains reports only whether a link is still live, so the read page can say "opening this will destroy it" without spending the only view to find out. THE SIZE LIMIT IS THE WHOLE ENFORCEMENT. The key lives in the URL fragment, which browsers never transmit, so the server holds ciphertext it cannot read and cannot tell a password from a page of a drawing. MAX_CIPHERTEXT is therefore not a tuning knob: raising it quietly turns this into the document transfer it was scoped not to be. The refusal says what the limit is FOR, because somebody hitting it is usually trying to send a file and the answer is a different tool. Line counts, file signatures and refusing a pasted file belong in the browser, guide an honest person, and are bypassable by anyone willing to POST directly. NOT ENUMERABLE. Missing, expired and spent all answer identically - telling them apart would confirm to anyone holding a guessed token whether it ever named anything. A spent or expired row is deleted on the way past rather than waiting for a sweeper, because it has no reason to keep its ciphertext. Its own plugin rather than a Tech Tool: that plugin is browser-only by design, and this needs storage, expiry and a view counter. Not an asset extension either, so no assetid and no asset type - the scaffold assumes one and it was removed. FOUND BY ITS OWN TESTS: expiryhours=0 returned 201. `int(x or DEFAULT)` treats an explicit zero as absent, so "expire immediately" silently became "expire in a day". A caller who sends a number is now told when it is not allowed rather than quietly given a different one. BEFORE ANYONE RELIES ON THIS, test that your link rewriter preserves URL fragments: create a secret, send the link to yourself in Teams, click it from the message, and check the key is still after the #. If Safe Links strips it the secret is unrecoverable and the server cannot help, which is the point of the design. The fallback is passphrase mode, worth building only if that test says so. Three product gates caught what a new plugin has to declare, and each was right to: the route-parity gate wanted the endpoints described in api-inventory.json, the lean-build guard wanted the plugin listed in the universal profile the released installer builds from, and the authz sweep wanted the public redeem route named in EXEMPT_ENDPOINTS with a reason. That last one matters most - a mutating route that answers an unauthenticated caller is a decision somebody has to write down, not a default, and reading a secret is a mutation because it spends it.
162 lines
6.7 KiB
Python
162 lines
6.7 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.securesend.models import SecureSecret
|
|
from plugins.securesend.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/securesend', 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/securesend', 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/securesend/%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/securesend/%s' % token).status_code == 200
|
|
assert client.post('/api/securesend/%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/securesend/%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/securesend/%s' % token).status_code == 200
|
|
assert client.post('/api/securesend/%s' % token).status_code == 200
|
|
assert client.post('/api/securesend/%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/securesend/%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/securesend/%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/securesend/%s' % token)
|
|
peek = client.get('/api/securesend/%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/securesend/%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/securesend/%s' % token)
|
|
|
|
spent = client.post('/api/securesend/%s' % token)
|
|
never = client.post('/api/securesend/%s' % ('f' * 32))
|
|
assert spent.status_code == never.status_code == 404
|
|
assert (spent.get_json()['data']['error']['message']
|
|
== never.get_json()['data']['error']['message'])
|