Security model
The relay handles one thing per request: a base64 blob it cannot read. The key needed to read that blob is generated on your machine, delivered in a part of the URL that browsers never transmit, and used once.
XSalsa20-Poly1305 key in URL fragment only one-time no account, no escrow
The one-sentence version
The value is encrypted in the browser with a key generated on your machine. The
key travels in the URL fragment, which browsers never send to a server. The
server holds ciphertext until the CLI decrypts it locally and writes it to
.env. The request is then dead.
What each party can see
| Party | Can see | Cannot see |
|---|---|---|
| The human's browser | The plaintext they typed, and the key from the fragment. | Nothing relevant — this is the trusted endpoint. |
| Your local CLI and server | The plaintext, after decrypting it locally, and the random port the server bound to. | — |
The bore.pub relay |
The ciphertext blob and its size, the timing of the request, the ephemeral remote port, and the request path. | The value, and the key. The relay never receives the fragment, so it has nothing to decrypt with. |
| A network observer | That a plain HTTP request occurred, and its rough size. | The value or the key. The payload is already encrypted before it leaves the browser. |
The path is not a secret
PinchPass derives the request path from the secret's name: it lowercases the
name, strips anything outside a–z,
0–9 and _, and joins multiple names with
underscores. Asking for GEMINI_API_KEY produces
/claim/gemini_api_key.
That path is guessable, and it is not a bearer token. Anyone who knows the name can find the endpoint while the request is live. This is a deliberate trade-off, and it is safe because the path is not what protects the value:
- Submitting something requires producing ciphertext under a key you do not have.
- A hostile or mistaken submission simply fails to decrypt, and the CLI
reports
decryption failed (tampered payload?). - The secret never travels in the path, the query string, or a header.
Treat the name as public — pick names that say what the credential is, because the human sees them on the form — and rely on the key to protect the value.
The crypto
| Element | Value |
|---|---|
| Cipher | XSalsa20-Poly1305 (authenticated encryption) |
| Browser implementation | TweetNaCl.js, embedded inline in the served page. No CDN, no external script, no WebCrypto requirement. |
| Go implementation | golang.org/x/crypto/nacl/secretbox — the project's only
dependency. |
| Key | 32 random bytes from crypto/rand, base64url-encoded (43
characters), delivered in the URL fragment and nowhere else. |
| Nonce | 24 bytes, from nacl.randomBytes(24) in the browser, fresh
for every submission. |
| Payload | The nonce concatenated with the secretbox output, base64-encoded into the
POST body as value=…. |
| Minimum accepted blob | 41 bytes — 24-byte nonce, 16-byte MAC, at least 1 byte of ciphertext.
Anything shorter is rejected with 400. |
| Multiple secrets | One JSON object of name to value, encrypted as a single payload. The CLI rejects any key that was not requested. |
| Fragment validation | The page refuses to submit unless the fragment matches
^#k=([A-Za-z0-9_-]{43})$, then disables the button and explains
that the key is missing. |
Why plain HTTP is acceptable
The payload is encrypted before it leaves the browser, so the wire carries
ciphertext that neither the relay nor a network observer can open. That is why a
plain http:// link is safe for the value itself, and why PinchPass
does not require HTTPS, a certificate, or WebCrypto — it works on a LAN, on a
bare bore.pub URL, and on a machine with an outdated TLS stack.
Be precise about what that buys you. HTTPS would add nothing for confidentiality of the value, but it would hide metadata from a network observer: that a request happened at all, its size, and its timing. Those are visible on plain HTTP. If that metadata matters to you, run the request over a channel you already trust.
One-time and time-limited
Confidentiality comes from the cipher. What limits the damage of a link that escapes is its lifetime:
- The first accepted submission claims the request. Every later
request to that URL — GET or POST — returns
404with a link-expired page. - The TTL defaults to 30 minutes and is set with
-ttl. When it lapses, the server shuts itself down, the CLI exits non-zero, and nothing is written. - The server binds to
127.0.0.1by default, so without-tunnelthe form is not reachable from the network at all. - Only one route exists.
/claim/<path>serves the form, accepts a POST, or returns405. Everything else returns404. - The HTTP server is bounded: a 10-second read-header timeout, 30-second read and idle timeouts, and a 10-second write timeout.
What this does not protect against
PinchPass moves one value, once, from a browser to a file. It is not a secrets manager, a KMS, or a vault, and it has no recovery story.
- A compromised machine. The key sits in the browser's memory
while the form is open, and the plaintext lands in
.env. Malware or a malicious extension on either end defeats the whole scheme. - The plaintext on disk. The file is written
0600, but the secret is still sitting in your project directory, and so is any editor buffer, backup, or sync client that touches it. - Losing the URL. There is no escrow and no recovery. The key exists only in the URL fragment, so if the link is lost before use, the human must be asked again.
- A linker that strips fragments. Some chat clients,
shorteners, and link previewers drop everything after
#. The value then cannot be encrypted, the form disables submit, and a new link is needed. - Anyone who holds the full URL can submit. They cannot read the result, but they can occupy the one-time slot. That is a denial of service, not a disclosure — the real user simply requests again.
- No audit trail. No account, no dashboard, no log of who submitted what. If you need those, this is the wrong tool.
- Non-sensitive values. Routing a public string through encryption adds ceremony for nothing. Ask for it directly.
Verify it yourself
None of the above requires trusting this page. Every claim is checkable in a few minutes, against the running tool and the source:
-
Run a request with
-jsonand read theurlfield. Confirm the key appears after a#, and that the path is just your lowercased secret name:shellpinchpass request GEMINI_API_KEY -json | head -20 -
While the request is live, fetch the form and read its source. Confirm TweetNaCl is embedded inline — there is no external script tag — and that the key is read from
location.hash:shellcurl -s http://127.0.0.1:<port>/claim/gemini_api_key | grep -c 'location.hash' -
Open the link, watch the network tab, and submit. The only request is a
POSTwhose body isvalue=followed by base64. There is no header, query parameter, or field carrying your plaintext or the key. -
Send the same request again with
curl, or reload the page. Both return404with the link-expired page, proving the request is spent. -
Start a request with
-ttl 1, submit nothing, and wait. The process exits non-zero and.envis untouched. -
Read the code that does the work:
server/handlers.gofor the form and the submit handler,server/server.gofor the path derivation and lifecycle,cmd/request.gofor the key generation and local decryption, andtunnel/bore.gofor the relay client. It is a small codebase, and the crypto is all in the standard library and onex/cryptopackage.
The full surface is listed in the CLI reference, and the source is at github.com/rubybear-lgtm/PinchPass.