How encrypted sharing works
Every builder on this site runs in your browser. Sharing is the one action that sends anything anywhere, so this page describes exactly what leaves the page, what the server can see, and what it cannot. Every claim below names the file and the function that enforces it, so you can check it rather than take our word for it. The repository is the authority; if this page and the code ever disagree, the code is right and this page is a bug.
The short version
- You press Share and confirm a warning that names what the configuration could reveal.
- Your browser strips known credential fields, validates what is left, and encrypts it with a brand-new AES-256-GCM key it generated locally.
- Only the ciphertext, the nonce, and four metadata fields — protocol version, tool, schema version, and chosen expiry — are uploaded. The key is not uploaded.
- The share service stores that encrypted envelope under a random identifier and returns a short link.
- The key is appended to that link after a
#. Browsers never send the part after#to a server. - The recipient's browser fetches the ciphertext by identifier and decrypts it locally with the key from the fragment.
The practical consequence is stated plainly further down: anyone holding the complete link can read the configuration.
The key stays in the fragment
The browser generates a 256-bit AES-GCM key per share, encrypts with a 96-bit random nonce and a 128-bit authentication tag, and binds the protocol version, tool name, and schema version into the ciphertext as additional authenticated data, so those three fields cannot be swapped without decryption failing. That is encrypt() in the encrypted-share module under serve/shared/.
The envelope that is uploaded has exactly six fields — protocol, tool, schema, iv, ciphertext, expiresIn — and the key is returned separately from it. The upload in create() in the share-client module posts that envelope and nothing else, then builds the link you copy as the short URL plus #k= plus the key. A URL fragment is never placed in the request line and never transmitted, so the key does not appear in the request the gateway receives, in any log, or in any analytics event. docs/TRACKING.md records the same boundary.
On the receiving side, fragmentKey() in the share landing page's script reads location.hash, immediately calls history.replaceState() to drop the key out of the address bar, and then requests only the identifier from /api/config-shares/<id>.
The service stores ciphertext it cannot read
validate_envelope() in share-gateway/app.py is the whole of what the service understands about a share. It checks that the field set is exactly those six names, that protocol and schema equal the versions it supports, that tool is on the enabled allowlist, that expiresIn is one of three fixed values, that iv base64url-decodes to exactly 12 bytes, and that ciphertext base64url-decodes to between 16 and 49,152 bytes. Then it returns the ciphertext's length. There is no decryption key in the envelope, no key parameter on the endpoint, and no code path in the service that calls a cipher.
Storage is deliberately thin. The envelope goes into an in-memory Valkey instance started with --save '' --appendonly no, so the ciphertext is never written to disk, and the gateway's Valkey account is restricted by ACL to +get +set +del +ping on keys matching share:envelope:* and nothing else. Both are in docker-compose.yml.
What the operator can see
The gateway emits one structured log line per request. On a create that line carries a request identifier, the operation, the result, the tool name, the protocol and schema versions, the chosen expiry, the ciphertext's byte count, and a latency figure. On a fetch it carries a request identifier, the operation, the result, and latency — not even the share identifier. No key, no field name, no field value, and no ciphertext is logged. You can read the whole of it in the do_POST and do_GET handlers of share-gateway/app.py; the field dictionary is built in one place and logged in a finally block.
The default Python HTTP request log, which would have contained the request path and therefore the share identifier, is suppressed by overriding log_message() to do nothing. On the proxy side, the mdm.tools site block in the Caddyfile has no log directive at all — the only log directive in the file belongs to the analytics host and is scoped by log_skip to the Umami login endpoint, for the fail2ban jail.
Analytics never receives share content. The site's Umami script records a <tool>-share event when you press the button; builder contents, ciphertext, keys, and complete share URLs are not part of it.
The recipient's redirect asks not to be tracked
Share links are minted on share.mdm.tools, a redirect host, and the gateway appends ?no-track=1 to every short URL it returns and marks the link crawlable: false. The redirector honours that parameter because DISABLE_TRACK_PARAM: no-track is set in docker-compose.yml. Share links carry no campaign attribution and are never given UTM parameters.
Stated honestly: no-track=1 is a request carried in the query string, not a property of the link. If someone re-types or edits the link without that parameter, the redirector records the click like any other. Suppression is per-request or global in this software; there is no per-link switch to set instead. The reasoning, and why the trade-off was accepted rather than papered over, is written up in docs/TRACKING.md.
Abuse controls
These exist to keep the share service from becoming free anonymous storage or an amplifier. They are listed with their real values because a control described vaguely cannot be checked.
| Control | What it does | Where it lives |
|---|---|---|
| Same-origin only | A create is refused unless Origin is exactly https://mdm.tools and Sec-Fetch-Site is same-origin. Another site cannot mint shares through your browser. |
do_POST, share-gateway/app.py |
| Trusted proxy only | trusted_client() refuses any connection whose peer address is outside the container network the proxy runs on, and takes the client identity from the first X-Forwarded-For hop only. Anything it cannot resolve raises, and the request is refused rather than allowed. |
trusted_client(), share-gateway/app.py |
| Rate limits | Per client: creates get a 10-token bucket refilling one token every 6 seconds (10 per minute sustained); fetches get a 60-token bucket refilling 2 per second. Across all clients, creates share a 60-token bucket refilling 1 per second. The per-client table is capped at 4,096 entries so it cannot itself be grown into a memory problem. | RateLimits, share-gateway/app.py |
| Size ceilings | Three of them, narrowing inward: the proxy caps a create body at 96 KB, the gateway rejects any Content-Length above 65,536 bytes, and the ciphertext itself must be at most 49,152 bytes. Your browser refuses to upload an over-size share before it asks the service at all. |
Caddyfile; MAX_BODY and MAX_CIPHERTEXT in share-gateway/app.py; encrypt() in the encrypted-share module |
| Allowlisted tools only | The service knows nine builders but accepts shares from a smaller ENABLED_TOOLS set; the browser enforces the same list before encrypting. Today that is four of the nine: DockBuilder, Firewall, Login Window, and Notifications. This is a staged rollout, and each expansion is a reviewed code change on both sides. |
ENABLED_TOOLS, share-gateway/app.py and the share-client module |
| Unguessable identifiers | A share identifier is 16 bytes from the operating system's cryptographic random source — 128 bits — rendered as 22 base64url characters. The proxy route, the gateway route, and the landing page all pin that exact shape. | do_POST and SHARE_ID, share-gateway/app.py |
| Uniform error bodies | Every failure the gateway answers returns the identical body — Request could not be completed plus a request identifier — whether the cause was a bad envelope, a cross-site attempt, an unknown identifier, an over-size body, a rate limit, or an internal fault. A request matching neither the create nor the fetch route never reaches the gateway: the proxy answers it with its own plain Not found. |
generic_error(), share-gateway/app.py; @share_api_path, Caddyfile |
| Method and path allowlist | Create is POST with a JSON content type; fetch is GET. PUT, DELETE, and PATCH return not-found, and the proxy 404s any other path under the share API. |
Caddyfile; Handler, share-gateway/app.py |
| Credential fields removed before encryption | The browser omits known credential-shaped fields — passwords, secrets, tokens, keys, and their common spellings — from the state, then asserts none survived, and refuses to encrypt if any did. | serve/shared/share-security.js, called from encrypt() in the encrypted-share module |
Stated honestly: the uniform error body does not hide the status code, so a fetch for an identifier that exists is distinguishable from one that does not. That is not the barrier against enumeration — 128 bits of identifier entropy and the fetch rate limit are. And credential-field removal is pattern matching over field names: it is advisory, not a guarantee. Free text, hostnames, internal paths, application identifiers, and organization names are all legitimate configuration values, and they are shared along with everything else.
A share link is a bearer secret
This is the property to hold onto. Anyone who has the complete link, fragment included, can decrypt the configuration. There is no account, no password, no second factor, and no per-recipient authorization. The link is the credential. Forwarding it, pasting it into a ticket, or putting it in a channel with a wide membership hands over the configuration to everyone who can read that.
Encryption here protects the configuration in transit and at rest on the server. It does not authenticate who is opening it, and it does not vouch for whoever sent it to you. If a link arrives unexpectedly, the honest reading is the same as for any unexpected attachment.
How long a share lasts
Expiry is chosen at share time from three fixed values — 1 day, 7 days, or 30 days — and the builders default to 7 days. Both halves of the share expire:
- The stored envelope is written with a Valkey TTL equal to that expiry, so the record removes itself.
- The short link is created with a matching
validUntil, and a scheduled cleanup job (scripts/cleanup-expired-shares.sh, run by a systemd timer) deletes expired short URLs.
Two things can end a share sooner. The ciphertext store holds no data on disk, so restarting the share-valkey container, or the whole stack, drops every stored share. Restarting share-gateway alone does not: the store is a separate Compose service and keeps its records while the gateway comes back. And the store runs under a 64 MB memory cap with a volatile-ttl eviction policy, so under memory pressure the shares closest to expiry are evicted first. A share expiring early is normal; a share outliving its stated expiry is not.
There is no delete-after-read and no way to revoke a link you have already sent. If a link went somewhere it should not have, treat the configuration as disclosed and change what needs changing.
Where each claim is enforced
The claims above are worth exactly as much as the code behind them, so here is that code in one list. The files are share-gateway/app.py (the whole service, in one standard-library file), the encrypted-share module in serve/shared/ (the cryptography), the share-client module beside it (what the builder uploads and what it puts after the #), serve/shared/share-security.js (credential-field removal), the mdm.tools block of the Caddyfile (routing, size ceilings, and headers), and docs/TRACKING.md (the analytics boundary and the no-track=1 trade-off).
Site-wide privacy and the client-side model are summarized on the About page.