Files
shopdb-flask/docs/SECURE-SEND.md
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

158 lines
7.0 KiB
Markdown

<!-- docmeta
purpose: explanation
audience: operator, adopter, developer
status: current
summary: How the one-view expiring credential link works, what the server cannot see, and who may create or read one.
loadwith:
-->
# Secure Send
Hand somebody a password without leaving it in a chat window.
Secure Send makes a link that carries one credential, dies when it is opened,
and expires on its own if nobody opens it. It lives in **Tech Tools** at
`/tools/secure-send`, and the person receiving it opens `/s/<token>`. It is the
one tool in that section that is not purely browser-side, because a link that
dies on first read needs somewhere to keep the ciphertext until it is claimed.
## What the server can and cannot see
The secret is encrypted **in the sender's browser** with AES-GCM, and the key
travels in the **URL fragment** - the part after the `#`. Browsers never send a
fragment to a server, so the key reaches the recipient's browser and nowhere
else: not this server, not its logs, not a proxy in between.
What the `securesendsecrets` table holds is a random token, base64 ciphertext,
an IV, and the rules for the row's life. Reading the database yields nothing.
That cuts both ways, and it is worth being explicit about it:
- **Nobody can recover a secret that goes missing.** Not an administrator, not
the database owner. If the link is lost, the secret is lost. Create another.
- **The server cannot tell a password from a page of a drawing.** It cannot
look at what it holds, so it cannot enforce a rule about content. The only
lever it has is SIZE, which is the next section.
- **The audit trail records the fact, never the content.** Who created a
secret, when, and when it was read are all recorded. What was in it is not
recoverable by anyone.
## This is for credentials, not documents
The ciphertext limit is **4096 characters** (about 2800 characters of plaintext,
which the create page enforces first with a friendlier message). That limit is
not a tuning knob. In a design where the server cannot read what it stores,
size is the entire server-side control over what this feature is for, and
raising it quietly turns a password tool into an unaudited document transfer.
Everything else the create page does - counting lines, spotting file
signatures, refusing a pasted file - runs in the browser. It guides an honest
person and is bypassable by anyone willing to POST directly. The size limit is
the part that holds.
If somebody needs to move a file, the answer is a different tool, not a bigger
number here.
## Who may create, who may read
**Two different doors, on purpose.**
**Creating** normally needs a login plus the `tools.secrets` permission (admins
hold it by default; grant it to other roles from Settings > Users & Roles). The
login is not a formality: anonymous creation would make this an unattributable
write endpoint that stores arbitrary ciphertext, and the creator is the only
person these rows can ever name.
**Reading needs neither an account nor a permission.** The recipient usually has
no account here, and the link is normally handed over on a call or in a chat.
The unguessable token IS the authorisation, which is why it is 128 bits of
randomness and why the row is spent on the first successful read.
### Guest creation
A site may open creation to people with no account with the
`securesend_allow_guests` setting (Settings > Tools > Secure Send). It is
**off by default**, because a guest secret has no creator and the audit trail
can then say when and from where but never who. The create page itself is served
without a login either way, so a guest at a site that allows it is not stopped
by a sign-in wall; the server, not the page, decides whether the write lands.
When it is on, guest creation is rate limited to 10 links per address per 10
minutes. That is not a security boundary - a determined person has more
addresses - but it stops an open endpoint being an easy place to park data or
fill a disk.
The setting is read straight from the settings row rather than the cache, and
**fails closed**: if it cannot be read at all, the answer is no. An error must
never be the thing that opens an unauthenticated write endpoint.
Receiving a secret never needs an account either way.
## Lifetime
| Control | Default | Range |
|---------|---------|-------|
| Expiry | 24 hours | 1 to 168 hours |
| Views | 1 | 1 to 100 |
Both apply, whichever comes first. A spent row is deleted outright, including
its ciphertext.
Expired secrets are swept opportunistically - whenever anybody creates a secret,
and whenever a dead link is clicked - rather than on a schedule. A secret nobody
ever opens would otherwise be removed by nothing, and an air-gapped site is not
going to have a cron job anybody remembers to add.
Missing, expired and spent all answer identically. Distinguishing them would
confirm to anyone holding a guessed token whether it ever named anything.
## Redeeming is a POST
`POST /api/tools/secrets/<token>` hands over the ciphertext and spends the view.
A GET would be wrong here: chat and mail clients prefetch links to build
previews, and a one-view secret consumed by a preview window is a secret the
recipient never gets to see.
There is a `GET /api/tools/secrets/<token>` as well, but it reports availability
only and never spends a view. The read page asks it first so it can say "this
secret is waiting, and opening it will destroy it" instead of consuming the only
view to find out whether there was anything there.
## Before anybody relies on it
**Send a link to yourself through whatever your site uses to pass it on -
Teams, Outlook, a chat client - and check the key is still there after the `#`.**
Some mail and chat security products rewrite links. If one strips or mangles the
fragment, the secret is unrecoverable and the server cannot help, which is the
whole point of the design. Find that out on a test secret rather than on a
production credential.
## Getting the link right
The link is built from the site's configured `site_base_url`, not from the
address the sender's browser happens to be using. Somebody creating a secret may
have reached the app by an IP, a short hostname, or a name that only resolves
inside one VLAN, while the recipient has to open the link from wherever they
are. Blank falls back to the browsing origin.
A site served under a subpath (`/shopdb`, `/ops`) needs that subpath reachable
in the link. Setting it as part of `site_base_url` works, and so does leaving it
out; the mount is added only when it is not there already.
## Where it lives
| Piece | Path |
|-------|------|
| Plugin | `plugins/tools/` |
| Table | `securesendsecrets` |
| API | `POST /api/tools/secrets`, `POST` and `GET /api/tools/secrets/<token>` |
| Create page | `/tools/secure-send` |
| Read page | `/s/<token>` |
| Setting | `securesend_allow_guests` (public, boolean, category `tools`) |
| Permission | `tools.secrets` |
The `tools` settings section is in [CONFIG.md](CONFIG.md); the response envelope
these routes use is in [llms.txt](llms.txt) and in the generated spec at
`/api/docs`.