Table of Contents
Open Table of Contents
Introduction
I work for the technical sales team at Cloudflare. One of my favourite parts of my job is hosting events to showcase the art of the possible with the Cloudflare platform. As I was gearing up to prepare for our next PeerPoint event in Manchester (PeerPoint being the events/gatherings we run where attendees can get their “hands dirty” with the technology) I was thinking about meaningful content that both showcased the power of the Cloudflare Developer Platform whilst also providing a good segue for the audience to start testing the platform during the following workshop.
In the age of Claudian, em-dash-riddled, pretty-but-shallow 5-minute demos, I thought it made sense to come up with something meaningful, an actual functional end-to-end application that either solves something I struggle with, or improves on an existing functionality.
So I picked a struggle. I have many, but this one was specific.
Sharing credentials.
What’s that? Credentials? In the age of zero-trust? You heretic. I know, but I am trying to solve a stupid problem.
A stupid problem
Let’s be honest. We’ve all done it.
The agent needs an API token. The model itself fights back, advising against pasting credentials in chat. Companies have built whole new auth protocols for this. Cool. But sometimes you just want to paste the damn token there, man, so the agent can rename the damn file for you.
Or it’s a colleague. You’re working on something simple, you want feedback, you give them a test account, you paste username: r00t password: 4321 into a DM. (I have done this. I have used these credentials. I have apologised.)
Disgusting.
The lack of elegance alone. So archaic, so barbaric. But we all did it.
Two stories, same problem: credentials end up as plaintext bytes in someone else’s scrollback. The agent’s chat history. The colleague’s DMs. Searchable forever by whoever gets there next.
For real secrets, you’d never manage things like this. For the get-shit-done type of work, it’s exactly the muscle memory we’ve all built.
The bad solution for a stupid problem, you ask? Don’t paste credentials in chat. Paste something else that leads to them, but isn’t.
Wouldn’t it be useful to have a pastebin that agents could easily use (even without a browser), that can’t double as a benchmark for Ad Blockers, and that just doesn’t suck? Whilst at the same time retaining a decent UX for humans?
Gone with the read
My idea was simple: instead of pasting a credential in plain text to an agent chat, I’d instead paste an ephemeral link that led the agent to that credential.
That part was easy. The real problem here is to make it zero-knowledge. To ensure the app server is never aware of what info is being passed on. The other problem was to ensure the link was ephemeral, and that it auto-destructed itself.
So that is how I started p00f, an open source zero-knowledge, ephemeral pastebin on the Cloudflare Developer Platform. It strikes the balance of both being something genuinely useful for lazy people (me) whilst at the same time being simple enough that it can be used as a starting project for a workshop during a PeerPoint event.
Cool!
I am pretending that is what you said to yourself when you read that. Assuming you even read this post up to this point. If so, I salute you.
How does it work
The whole pitch lives in a single character. The #.
A poof link looks like this:
https://p00f.me/c/<id>#<key>
That # is doing all the work. The <id> is what the server uses to look up your ciphertext. The bit after the # is the URL fragment, and that fragment is the decryption key. Here is the punchline: browsers never send the fragment over the network. It is right there in the spec. When you click a poof link, your browser hits https://p00f.me/c/<id> and the #<key> part just sits in your address bar, on your machine, doing nothing on the wire.
Which means the server cannot decrypt what it stores. Not “we promise we won’t”. Cannot. There is nothing on the server side to decrypt with.
That is the trick. Everything else is plumbing.
Here is the plumbing:
The creator generates a random key locally, encrypts the content, and uploads only ciphertext. The server stores it in a per-poof Durable Object, spilling to R2 if over 1 MB, and returns a clip id. The creator builds the link with the key in the URL fragment and hands it over. The recipient fetches the ciphertext, the server spends one reveal and burns when the budget is empty, and the recipient decrypts locally with the key from the fragment. On burn, the Durable Object row and any R2 blob are deleted.
The encryption happens on your device. Web Crypto in the browser, the same engine in the CLI, the same engine in @p00f/core for any agent that calls the library directly. There is no “send to server, server encrypts” step, because the plaintext never gets a chance to leave. By the time anything goes on the wire, it is already ciphertext. The whole engine lives in src/shared/ of the repo (crypto.ts, link.ts, protocol.ts, core.ts) and the fact that it is shared is the point. One core, many shells. The web app, the CLI, and any future shell (a Raycast extension, a VS Code thingy - I have a thing for those - who knows) all encrypt and decrypt the exact same way.
The Worker is the front door. The Durable Object is the safe. Every poof gets its very own Durable Object. That DO holds the encrypted metadata, the TTL alarm, and the reveal-budget counter. And because there is only ever one writer per DO, two reveals trying to happen at the same time can’t race past zero. Spending a reveal is atomic. When the budget hits zero, the poof burns… or better said, the poof… poofs? Anyway, when the TTL alarm fires, the poof poofs. No garbage collector cron job somewhere hidden away in the platform; the burn is built into the object that holds the poof.
R2 is just for the big stuff. Content up to 1 MB stays inline in the Durable Object itself. Above that, the ciphertext spills to R2 and the DO keeps a random key pointing at the object. Either way, the bytes living anywhere on Cloudflare are the same client-side ciphertext, so R2 sees an opaque blob and nothing else. When a poof poofs, the DO row is deleted and a best-effort delete fires for any R2 object. Anything missed gets swept up by a lifecycle rule after 65 days, longer than the maximum lifetime of any poof. So even a missed delete leaves only key-less ciphertext, which the rule deletes anyway.
This lifecycle rule does need to be manually set up within the R2 bucket settings, but it’s not necessarily a trust me bro situation. As explained above, the only thing R2 will hold is ciphertext anyway.
Revealed content renders inside a sandboxed, opaque-origin iframe. If someone poofs you a page full of malicious JavaScript, you don’t want that JavaScript reaching back out and stealing the key off the page that’s holding it. The iframe is walled off from the parent. The key stays with the parent. The hostile JS just sits there, fuming.
Another way to look at the same thing is to ask: what does each side ever actually hold?
YOUR DEVICE / YOUR AGENT p00f SERVERS (Worker + DO)
what stays here, always what they ever hold
....................... ....................
the plaintext content ciphertext (an opaque blob)
the decryption key a random 128-bit clip id
the exact size, filename, and kind a coarse size bucket
the URL #fragment (it carries the key) reveals remaining
whether a PIN / captcha is required
a TTL, to run the burn
the key lives in the link's #fragment and is never sent to the
server, so p00f physically cannot decrypt what it stores.
The right column is, deliberately, the whole list. That’s all p00f has. Subpoena it, hack it, be the operator, and the most you can pull off the infrastructure is a ciphertext blob and the bookkeeping it needs to delete/poof the poof. No plaintext. No key. No file name. None of that ever showed up there in the first place. It lives on the two devices that asked for the poof.
The whole stack runs on Cloudflare. Workers serve the app and the API, a per-poof Durable Object holds each clip and runs its burn timer, R2 takes the large stuff. The fact that the encryption is client-side is what lets me say “runs entirely on Cloudflare” without it being a slightly creepy claim about who can read your stuff. The platform holds the ciphertext. Your device holds the meaning. The two never meet in the same place. Like a good idea for a Cold War spy book.
The creator UX
The UI was kept as minimalist as possible. I am avoiding pastebin’s design language at all costs.
When you first visit p00f, you are greeted with a Cloudflare Turnstile widget. You can’t create a poof without solving the challenge. I kept the Turnstile configuration as non-disruptive as possible.
The main reason it exists is for the human creator to prove they are human. For agents, we have the p00f CLI which works over the same core.
Once the Turnstile challenge is solved, the widget auto-disappears and the user is able to create a poof. There is a set of default settings implicitly applied, but clicking more options reveals a set of configurable settings which can be saved for future use (it saves the preferences into a cookie stored in the browser). The current options are ideas I came up with, keen to hear your feedback on what could be added or removed.
Finally, when a poof is created, the user is given a shareable link, which can be shared with an agent or a human.
The agent creator
We covered the UI flow, meant for humans. If you’d rather not click anything, or if you are a clanker, the CLI does the same job in one line:
$ printf '%s' "$OPENAI_API_KEY" | npx @p00f/cli --ttl 1h --reads 1
https://p00f.me/c/q8Zr2nF1#3sP_Kb...the-key-stays-in-the-fragment
A ready-to-share link comes out, and the link gets copied to your clipboard for free. Same wire format as the browser, same client-side encryption, same key living only in the #fragment. It also works with files (npx @p00f/cli secrets.env), takes the same options the UI does (--ttl, --reads, --pin, --reveal-anchored, --require-turnstile), and prints the owner-token to stderr so you can burn early if you change your mind. npm i -g @p00f/cli upgrades npx @p00f/cli into a simple poof.
The recipient UX
When a human receiver opens the link, they see a minimalist interface.
If the poof was created with the default settings, they will see the number of reveals left, a countdown until self-destruction, and the button to reveal the poof.
Once the reveal button is clicked, the poof is revealed, but the countdown continues by design.
If someone tries to access the same link after either the timer ran out or the reveal limit was reached, they will see a message indicating that the poof… well… poofed.
Of course, with curated, randomized funny-to-me-potentially-cringy-to-you messages, always. You can see all the possible phrases I came up with in the source code.
That is what a human sees. Agents get a different door.
The agent receiver
When a poof link lands in an agent chat, the polite move is not to open it in a browser. p00f publishes an llms.txt at the root of the site, which walks any half-decent agent through the reveal contract: how to check whether the poof is Turnstile-gated (which would make it browser-only, in which case back off), how to inspect the TTL and reveal budget without spending one, and how to actually decrypt using @p00f/core. The recommended path, right there in llms.txt, is the CLI:
npx @p00f/cli get https://p00f.me/c/<id>#<key>
That’s it.
And yes, a human can absolutely use the CLI too and bypass their own Turnstile. That is completely fine. The point of Turnstile was never to gatekeep humans off p00f. It was to gatekeep agents off a UI that was built for humans, but having the option to enforce a Turnstile on the receiver is a decent deterrent for poofs that are meant to be consumed by humans and not agents.
I also built a simple media renderer, so if an image is shared, the receiver sees the image, and if a video is shared, the receiver sees a player to watch the video from the browser.
It also adapts for mobile usage with a convenient Share button to make it easy to share the poof with anyone.
Back to the initial point
p00f is open-source on GitHub. Going back to the beginning of this post, I created it to build a compelling story around the Cloudflare Developer Platform, something I can use as a tangible, useful outcome for a presentation, but the idea is to go even further.
In events like PeerPoint, we like to host workshops where attendees can try out different Cloudflare products. However, if they need a starting point, what better way than to have a minimal app that already exists, that they can just start contributing to right then and there, whilst at the same time getting a feel for the Cloudflare Developer Platform. Just clone it, or fork it and make it their own.
I’ll give this a try at PeerPoint Manchester next week. Who knows? Maybe we’ll come back from it with a few PRs. 🙂