diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0d2650..a39dbe87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ac04e9d2..e5d6ad6a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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 diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6f0ba99a..adf69703 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4d457b71..383cdb02 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SECURE-SEND.md b/docs/SECURE-SEND.md new file mode 100644 index 00000000..2a88b04a --- /dev/null +++ b/docs/SECURE-SEND.md @@ -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/`. + +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/` 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/` 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/` | +| Create page | `/tools/secure-send` | +| Read page | `/s/` | +| 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 diff --git a/docs/START-HERE.md b/docs/START-HERE.md index c2a8900e..da189555 100644 --- a/docs/START-HERE.md +++ b/docs/START-HERE.md @@ -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,