Files
shopdb-flask/plugins/securesend/README.md
cproudlock 98458672eb
Some checks failed
CI / backend (push) Failing after 7m16s
CI / naming (push) Failing after 7m5s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m10s
Hand somebody a password without leaving it in a chat window
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.
2026-08-21 13:44:13 -04:00

72 lines
3.2 KiB
Markdown

# Securesend plugin
One-view expiring links for handing someone a credential. The secret is
encrypted in the browser and the key never reaches this server.
Replaces the habit it exists to kill: pasting a password into a chat message,
where it stays until somebody deletes the conversation.
## What it is not
**Not a document transfer.** Text only, with a hard size limit. See "The size
limit is the whole enforcement" below - that is a deliberate scope, not a
missing feature.
**Not a Tech Tools page.** Tools is browser-only by design (`get_blueprint` and
`get_models` are both empty there); this needs storage, expiry and a view
counter, so it is its own plugin.
**Not an asset.** A shared credential is a short-lived event, not something the
site inventories, so there is no assetid and no asset type.
## Two doors
| Route | Auth | Why |
|---|---|---|
| `POST /api/securesend` | login + `securesend.create` | Anonymous creation would be an untraceable way to move data out of a system that otherwise records who did what |
| `POST /api/securesend/<token>` | none | The recipient usually has no account here. The unguessable token IS the authorisation |
| `GET /api/securesend/<token>` | none | Reports whether a link is still live. Spends nothing |
## Redeeming is a POST, and that is not an accident
Teams, Outlook and Defender 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.
The GET route exists so the read page can say "a secret is waiting, and opening
it will destroy it" without spending the only view to find out.
## The size limit is the whole enforcement
The AES key lives in the URL fragment, which browsers never transmit, so the
server holds ciphertext it cannot read. It therefore cannot tell a password from
a page of a drawing, and `MAX_CIPHERTEXT` is not a tuning knob - it is the only
control a zero-knowledge design leaves on the server side.
Everything else (line counts, file signatures, refusing a pasted file) belongs in
the browser, guides an honest person, and is bypassable by anyone willing to POST
directly. Raising the limit quietly turns this into the document transfer it was
scoped not to be.
## What the audit trail can and cannot say
It can say who created a secret, how big it was, when it was read and how often.
It cannot say what was in it - by design. That is the right trade for a
credential and the wrong one for a controlled document, which is the other reason
files are out of scope.
## Not enumerable
The token is 128 bits of randomness rendered as hex, never a sequence. Missing,
expired and spent all answer identically, so a guessed token learns nothing about
whether it ever named anything.
## Before rolling it out
**Test that your link rewriter preserves URL fragments.** Create a secret, send
the link to yourself in Teams, and click it from the message. If Safe Links
strips the `#`, the recipient gets a link with no key and the secret is
unrecoverable - the server cannot help, which is the point of the design. The
fallback if that happens is passphrase mode, where the key is derived from a
passphrase shared out of band.