Write down the tool that was built, not the one that was tabled

Secure Send shipped on 21 August and nothing outside the code caught up. The
roadmap still listed it under Deferred as "tabled", and gave as the reason that
the tools plugin is browser-only by design with get_blueprint and get_models
both empty - which stopped being true in the same day's commits that gave it a
blueprint, a table, a migration chain, a permission and a setting. A reader
following the roadmap would have concluded the feature does not exist.

The generated API docs were fine: api-inventory.json and openapi.json both
carry /api/tools/secrets, so the contract gates did their job. What was missing
was every word written for a person, and that is the half where the security
properties live.

docs/SECURE-SEND.md is the new page. The parts worth having in prose rather
than inferring from the code: that a lost secret is lost to everybody including
an administrator, which is the same property as "a compromised database yields
nothing" seen from the other side; that the 4096-character limit is the entire
server-side enforcement, because a server that cannot read what it holds cannot
have an opinion about content, so raising it turns a password tool into an
unaudited file transfer; that redeeming is a POST because preview prefetch in
chat and mail clients would otherwise spend a one-view secret before the
recipient ever sees it; and that somebody should send a link to themselves
through their own mail or chat client and confirm the key survives after the #
before a real credential goes through it, because a product that rewrites links
can strip the fragment and nothing on this side can help.

securesend_allow_guests was in the database, on a settings page, and in no
document. It is now in CONFIG.md with what it actually does - an unauthenticated
write endpoint, rate limited per address, failing closed - rather than only its
one-line description.

Also corrected along the way: site_base_url was documented as scheme + host
while the code deliberately tolerates a subpath in it, and the bundled plugin
table in PLUGINS.md had no row for tools at all.

Still missing after this: PLUGINS.md has no row for backups either.
This commit is contained in:
cproudlock
2026-08-24 08:37:25 -04:00
parent 81575e46ca
commit ad9210b84a
6 changed files with 224 additions and 15 deletions

View File

@@ -12,6 +12,30 @@ ADR-007 and ADR-002.
### Added
- **Secure Send: hand somebody a password without leaving it in a chat
window.** A link in Tech Tools that carries one credential, dies when it is
opened, and expires on its own if nobody opens it. The secret is encrypted in
the sender's browser and the key rides in the URL fragment, which browsers
never transmit, so the server stores ciphertext it cannot read and a
compromised database yields nothing. Nobody can recover a lost secret either,
including an administrator, which is the same property seen from the other
side. Redeeming is a POST because 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 sees; a GET reports availability only. Expiry defaults to
24 hours (maximum a week) and views to one, whichever comes first, and a spent
row is deleted. Creating needs a login and `tools.secrets`; reading needs
neither, because the recipient usually has no account and the 128-bit token is
the authorisation. The 4096-character ciphertext limit is deliberately the
whole server-side enforcement: a zero-knowledge server cannot tell a password
from a document, so size is the only control it has, and raising it turns this
into the file transfer it was scoped not to be. Documented in
`docs/SECURE-SEND.md`.
- **A site can let people without an account create a Secure Send link.**
New `securesend_allow_guests` setting (Settings > Tools > Secure Send), off by
default because a guest secret has no creator and the audit trail can then say
when and from where but never who. Guest creation is rate limited per address
and the setting fails closed if it cannot be read, so an error can never be
what opens an unauthenticated write endpoint.
- **The code generator can print a back side.** A badge card is two-sided and
the generator only ever emitted one page per card. The Back side section
turns on a second page per card carrying either the same code again, a fixed
@@ -84,6 +108,14 @@ ADR-007 and ADR-002.
### Changed
- **Tech Tools no longer asks people to sign in for tools that need no
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
`requiresAuth`. The label generator runs entirely in the browser - no API, no
data, nothing to protect - so gating it only ever cost a guest the use of it,
and gating the Secure Send page contradicted the guest setting that lets
somebody with no account create a link. The server still decides who may
create a secret: making a page reachable is not the same as allowing the
action, and the two were conflated.
- **Settings on the left, labels on the right.** The controls were a narrow
column with the preview trailing underneath, so the labels being adjusted were
off the bottom of the screen. The preview is now a column of its own that

View File

@@ -138,7 +138,7 @@ read back through the API.
| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. |
| `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). |
| `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). |
| `site_base_url` | (empty) | Public base URL (scheme + host) for QR codes and absolute links. Blank = use the browsing origin. |
| `site_base_url` | (empty) | Public base URL for QR codes and absolute links. Scheme + host, optionally including the subpath a site is served under (`https://host/shopdb`). Blank = use the browsing origin. |
| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. |
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
@@ -322,6 +322,21 @@ intersected with the owner's live permissions at use time, and suspends the
admin bypass, so it is denied on role-gated (admin-only) endpoints and on import
mode. Use an unscoped token for admin-only work and imports.
### tools
| Key | Default | Notes |
|-----|---------|-------|
| `securesend_allow_guests` | `false` | Let people with no account create Secure Send links. Public (readable before login) so the create page can show a guest the form instead of a sign-in wall. Off by default: a guest secret has no creator, so the audit trail can say when and from where but never who. Receiving a secret never needs an account either way. |
Turning this on makes creation an unauthenticated write, bounded at 10 links per
address per 10 minutes. A signed-in user still needs the `tools.secrets`
permission, so a member without it cannot create one while a guest can - opening
the door to guests is a decision about the door, not about revoking what a role
holds. The setting fails closed if it cannot be read.
See [SECURE-SEND.md](SECURE-SEND.md) for the whole feature, including why the
server cannot recover a secret and what the size limit is doing.
### identifiers (dynamic)
One boolean key per asset identifier per asset type, keyed

View File

@@ -20,6 +20,7 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| `knowledgebase` | Knowledge Base articles linking to external resources | Lightweight article store. |
| `printedparts` | 3D-printed parts inventory | Kiosk checkout / check-in. Ships `default_enabled: false`. |
| `slides` | Slides for the lobby display and shop-floor screensaver | Upload / reorder / delete per surface. Management gated on the `slides.manage` permission, grantable to non-admin curators; display routes are public. |
| `tools` | Technician utilities: barcode and QR label generation, unit conversions, Secure Send | Mostly browser-side by design, so an air-gapped bench gets them with no network. Secure Send is the exception and is why this plugin has a blueprint, a table and a migration chain: see [SECURE-SEND.md](SECURE-SEND.md). |
| `warranty` | Asset warranty tracking | Manual entry now, Dell / Lenovo / HP provider lookups later. Derived coverage status with report buckets. |
## Plugin permissions

View File

@@ -39,20 +39,19 @@ The last big milestone before 1.0 is the legacy-ASP data import plus a productio
- Pip-installable plugins (deferred per ADR-003 v2). Filesystem distribution stays the v1 model.
- Event bus on `BasePlugin` (removed per ADR-001; add via new ADR if a real use case appears).
- Frontend rebuild beyond Vue 3 + Pinia + Vite (the existing stack is fine).
- Secret and file sharing as a Tech Tool (tabled 2026-08-21). A one-view,
expiring link for handing someone a credential, in place of a chat message or
a sticky note. NOT a Tools-plugin candidate as it stands: that plugin is
browser-only by design (`get_blueprint` and `get_models` are both empty) and
this needs storage, expiry and an audit trail, so it would be its own plugin.
Decisions already reached, to save rediscovering them: the sender is always
authenticated; a FILE recipient should be logged in too, which costs nothing
on an intranet-only server where everyone with the link already has an
account; separate permissions for sharing a secret and sharing a file, because
the risk classes differ; and zero-knowledge encryption is right for a
credential but questionable for a controlled document, where "the server
cannot say what was in it" is a liability rather than a feature. Export
control should confirm internal link sharing in writing before it carries
drawings or NC programs.
- **File** sharing by expiring link. The secret half of this shipped as Secure
Send in the `tools` plugin ([SECURE-SEND.md](SECURE-SEND.md)); the file half
was dropped deliberately and stays out of scope. Dropping it removed the
export-control question entirely - a password is not technical data - along
with blob storage and the need to authenticate the recipient. What would have
to be settled before it comes back, so nobody rediscovers it: a FILE recipient
should be logged in too, which costs nothing on an intranet-only server where
everyone with the link already has an account; sharing a file wants its own
permission separate from sharing a secret, because the risk classes differ;
and zero-knowledge encryption is right for a credential but questionable for a
controlled document, where "the server cannot say what was in it" is a
liability rather than a feature. Export control should confirm internal link
sharing in writing before anything here carries drawings or NC programs.
## What 1.0.0 means

155
docs/SECURE-SEND.md Normal file
View File

@@ -0,0 +1,155 @@
# 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 expires and 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.
Making the create page reachable is not the same as allowing the action. The
page is served without a login so a guest at a site that allows guest creation
is not stopped by a sign-in wall; the server still decides.
### 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.
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` |
## See also
- [CONFIG.md](CONFIG.md) - the `tools` settings section
- [PLUGINS.md](PLUGINS.md) - the bundled plugin set
- [API-REFERENCE.md](API-REFERENCE.md) - the response envelope these routes use

View File

@@ -50,6 +50,13 @@ artifact comes from.
3. [API-REFERENCE](API-REFERENCE.md) for prose, [COLLECTOR-INTEGRATION](COLLECTOR-INTEGRATION.md)
if you are feeding data in from a PC.
## I have to give somebody a password
[SECURE-SEND](SECURE-SEND.md) - a one-view expiring link, instead of a chat
message or a sticky note. Read the "Before anybody relies on it" section first:
there is one thing to test at your site before the first real credential goes
through it.
## I am trying to understand why something is built this way
[The ADRs](adr/ADR-001-asset-as-platform-contract.md). They are the decision record,