Secret Share Share a secret

A secret-sharing protocol that works when the other person isn't there

The problem with "just send it"

"Can you send me the API key?" has no good default answer. Slack and email persist plaintext forever on someone else's servers. One-time-secret sites are better, but most of them encrypt server-side — you're trusting them not to look. Real E2E tools (Signal, age, PGP) work great if both people already have them set up, which is exactly the situation you're not in when a contractor needs a database password in the next five minutes.

I wanted: open a page, get a code you could read over the phone, and a guarantee that the server operator — me — cannot read the secret. Here's the design, and the three or four decisions that turned out to be interesting.

One code, two jobs

A share code looks like this:

XKQ2-M7PT-tiger-ocean-cable-ruby-drum

It's really two things concatenated. The first 8 characters are a random 40-bit mailbox id — the only part the server ever sees. It routes messages and names the storage; it contributes zero key entropy. The five words are drawn uniformly from the EFF large wordlist (7,776 words, rejection-sampled so there's no modulo bias) and are worth 64.6 bits of entropy. They never leave the browser.

The browser turns the words into keys like this:

ikm = Argon2id(words, salt = "secret-share/v1/" + mailboxId,
               m = 64 MiB, t = 3, p = 1)

then HKDF-SHA256 fans ikm out into four independent values: an AES-256-GCM key for the parked blob, root material for live sessions, a claim tag, and a sender tag. The server stores only SHA-256 hashes of the two tags — so it can check "does this receiver know the code?" without ever holding anything that derives a key.

Two small details here cost me the most thought per byte:

Park first, then try for the direct handoff

The obvious P2P design — connect both browsers, transfer, done — fails the moment the sender closes their laptop. The obvious dead-drop design — upload ciphertext, receiver downloads it — works asynchronously but means ciphertext always sits on a server. I ended up doing both, in a specific order I call park-first:

  1. The sender's browser encrypts the secret (AES-256-GCM, random IV, AAD bound to the mailbox id so a blob can't be replayed into another mailbox) and parks the ciphertext on the server immediately.
  2. The sender's tab stays connected to a per-mailbox signaling room.
  3. If the receiver shows up while the sender is still online, the two browsers negotiate a WebRTC DataChannel and the secret is re-sent directly, encrypted again under a fresh per-session key. On confirmed delivery, the parked copy is deleted.
  4. If the sender is gone, the receiver presents the claim tag and gets the parked ciphertext. The server deletes it in the same transaction.

Park-first means the sender can close the tab at any point after step 1 and the handoff still completes. The live path is an upgrade, not a requirement. (There's also a direct-only mode for the paranoid, where nothing is parked and both parties must be online.)

The signaling server can't MITM the live path

Live-path key material never touches the server. Each peer contributes a random salt, and both are mixed into the session keys:

HKDF(sessionIkm, salt = senderSalt || receiverSalt)
  -> kFrame   (AES-256-GCM for DataChannel frames)
  -> kConfirm (HMAC-SHA256)

Before a single secret byte flows, each side sends an HMAC over the handshake transcript — role, mailbox id, both salts — under kConfirm, and verifies the peer's. The signaling server relays the WebRTC offers and answers, but it never learns the words, so it can't compute kConfirm. An active MITM at signaling gets a DataChannel that fails key confirmation and delivers nothing.

DataChannel frames use deterministic IVs: a 4-byte direction tag plus an 8-byte sequence number, with the AAD binding both. Deterministic IVs under AES-GCM are a classic footgun, but they're safe here for a boring reason: kFrame is unique per session (fresh salts from both sides), and each (direction, sequence) pair is used at most once within it. The per-session salts also mean a reused code never reuses an IV — though read-once makes code reuse mostly moot anyway.

Why Durable Objects and not KV

Read-once is a transactional property: "return the blob and delete it" must be atomic, and "5 failed attempts burns the drop" needs a counter that can't race. Cloudflare KV is eventually consistent — a deleted secret could still be served from another edge location for up to a minute. That's not a tail-latency quirk; it's a hole in the core guarantee.

So every mailbox is one SQLite-backed Durable Object: the parked blob, the attempt counter, the TTL alarm, and the live signaling room all live in one single-threaded object where read-and-delete is just a transaction. Read-once is enforced at every layer — one claim, five failed attempts burn the drop, a delivered live transfer is never re-offered, and TTL alarms sweep whatever's left. A pleasant side effect: SQLite-backed DOs are on Cloudflare's free plan, so the whole thing — SPA, API, WebSocket signaling, storage — is one Worker costing nothing at current traffic.

The part I can't engineer away

Honesty section. This is a web app, which means you trust the JavaScript we serve at the moment you use it. Every browser-crypto product has this property; most bury it. The mitigations are on the roadmap — the source is already public for inspection, with reproducible builds, subresource integrity, and a CLI client to follow.

What the design does guarantee: a full server compromise yields ciphertext and tag hashes. Turning those into a secret costs an Argon2id evaluation per guess against ~65 bits of per-mailbox-salted entropy. Share links carry the code only in the URL fragment (never sent over HTTP), with Referrer-Policy: no-referrer and Cache-Control: no-store throughout.

If you spot a hole in any of the above, I genuinely want to hear it — the security policy has the contact, and protocol criticism is a gift.

Try it: end-to-end encrypted, read-once, no account.

Share a secret →