Lock a file for a friend using their password — without ever knowing it, and without being able to reopen the file yourself.
You have a file. A friend has a password only they know. friend-lock lets you encrypt the file so that only your friend can ever open it — not you, not anyone the file passes through. The locked file even looks like an ordinary photo.
It's a single HTML file that runs entirely in your browser. No install, no server, no account, nothing uploaded anywhere.
The natural first thought is: my friend hashes their password, gives me the hash, I encrypt with it. But with any symmetric scheme, whatever locks also unlocks — if you can encrypt with it, you can decrypt with it too. That defeats the whole point.
The primitive that actually separates "can lock" from "can unlock" is public-key cryptography, and that's what friend-lock uses.
-
Your friend creates a lock code. They pick a password. friend-lock stretches it with Argon2id into a seed and deterministically derives an X25519 key pair. You receive the public key (bundled as a short "lock code"). The private key is never stored — it only ever exists for a moment while your friend re-types the password to unlock.
-
You lock the file. friend-lock zips your file(s) and encrypts them to your friend's public key using libsodium's sealed box (
crypto_box_seal). A sealed box uses a throwaway ephemeral key that is immediately discarded — so even you, right after locking, cannot open it again. Only your friend can. -
Your friend unlocks. They open the locked file, type their password, friend-lock re-derives the same private key, and the original file(s) come back.
friend ──(password)──▶ Argon2id ──▶ X25519 key pair
│ public key
▼
you ──(file + public key)──▶ sealed box ──▶ locked .jpg ✗ you can't reopen
│
friend ──(locked .jpg + password)──────────────▶ original file ✓
The locked output is a real, viewable .jpg: a blueprint-style card with a
notice and a QR code pointing back here. The encrypted data is appended after
the image's end marker, which every normal photo viewer ignores. Double-click it
and you see a picture; drop it into friend-lock and it decrypts.
⚠️ Send it as a file, not as an in-chat photo. WhatsApp, Telegram, iMessage, Instagram, etc. re-compress images and would strip the hidden data. Send the.jpgas an email/Drive/USB attachment, or use the "Download raw.flock" option for those channels — both unlock identically.
Open dist/friend-lock.html in any modern browser (Chrome, Edge, Firefox,
Safari). That one file is everything. It works offline straight from disk, and
later it can be hosted as a static page (e.g. GitHub Pages) with no changes.
- Create lock code — your friend does this once and sends you the code.
- Lock a file — you paste the code, add file(s) or a folder, and download the locked
.jpg. - Unlock — your friend drops the file in, types their password, gets the original back.
This tool optimizes for convenience, not maximum security. Be aware:
- Everything rests on the password. The private key is derived from it, so a stolen locked file is open to offline password guessing. Argon2id makes each guess slow, but a weak password is still weak. Choose a strong passphrase.
- There is no password reset. Forget the password → the file is gone forever. That's the design.
- The lock code is safe to share — it contains only a public key and a (non-secret) salt.
- Confidentiality + tamper-detection come from the sealed box, but the sender is anonymous: your friend can't cryptographically prove you were the locker.
- Running the single file offline is the most private option — nothing can leave your machine.
| Purpose | Primitive |
|---|---|
| Password stretch | Argon2id (crypto_pwhash, interactive params) |
| Key pair | X25519, deterministically seeded from the password |
| Encryption | Sealed box (crypto_box_seal, X25519 + XSalsa20-Poly1305) |
| Packaging | ZIP (preserves names, folders, multiple files) |
All via libsodium (the -sumo build).
lock code = base64url( u8 version | u32 ops | u32 mem | 16B salt | 32B pubkey )
.flock blob = "FLK1" | u8 version | u32 ops | u32 mem | 16B salt | sealed ciphertext
.jpg carrier = <valid JPEG cover> | .flock blob | "FLCKPLD1" | u64 payloadLength
The salt and KDF parameters travel inside the file so your friend only needs to remember the password.
npm install # dev deps (libs to vendor, Playwright for tests)
npm run vendor # copy library files into vendor/
npm run build # inline everything into dist/friend-lock.html
npm test # end-to-end round-trip test in headless ChromiumSource layout:
friendlock.js— the crypto/format core (no UI); the part worth auditing.index.html— the four-tab UI (loadsvendor/*.js+friendlock.js).technology.html— technical explainer page (how it works, disclaimer).privacy.html— plain-language privacy note.404.html— friendly not-found page.build.mjs— inlines all local scripts into the singledist/friend-lock.htmland rewrites the sibling-page links to absoluteSITE_URLlinks.vendor/— pinned copies of libsodium, JSZip, and the QR generator.
The whole thing is static, so it hosts anywhere for free and never runs code on a server (which keeps it cheap, spike-tolerant, and GDPR-friendly).
GitHub Pages (simplest): the repo is ready — index.html at the root loads
vendor/ and friendlock.js via relative paths, and a .nojekyll file keeps
Pages from touching anything.
- Repo Settings → Pages.
- Source: Deploy from a branch →
main/root, Save. - Site goes live at
https://smyrnakis.github.io/friend-lock/.
Cloudflare Pages (recommended long-term): connect the repo, framework preset
None, build command empty, output directory /. You get a global CDN and can
later add Turnstile / rate limiting. Neither host meters bandwidth on the free
tier, so a traffic spike is absorbed by the CDN and can never generate a bill.
When you move to a custom domain, update SITE_URL in build.mjs so the
standalone single-file's links point at the right place.
- Point the cover QR at the live site once the domain is settled.
- Optional hardening: Cloudflare Turnstile, user-selectable passphrase strength, IP rate limiting.
MIT