The cipher.ink source code, annotated
The JavaScript that seals and opens every message, annotated section by section and rendered from the files your browser actually receives.
The code your browser runs to seal and open a message is the proof behind every claim on this site, so you should be able to read it. It's plain JavaScript with no build step, no minifier and no framework. The files below are served exactly as you see them, 147 KB of them on the compose page, and the whole of the cryptography is the 158 lines of crypto.js. This page isn't a copy. It's rendered from the served files on every request, so it can't drift from the code that actually runs.
If you came to check us, look for three things. Nothing in crypto.js touches the network. The only things compose.js uploads are the sealed payload, the encrypted attachments and a hash of the receipt token. And the Content-Security-Policy stops the page loading or sending anything anywhere else, apart from the feedback tab's own server, feedback.cipher.ink, which I also run. The security page lists what the server does see, and how it works walks through the design without code.
Check that you're reading what the browser gets
Each hash below was computed from the file on this server when you loaded the page. Compute the same hash on what your browser downloads and compare. In a terminal:
curl -s https://cipher.ink/assets/crypto.js | shasum -a 256You can also paste the output into the hash comparer, or save the files and let the file checksum verifier do the whole table. If a hash here ever differs from the file you download, something between us and you has changed it.
Verify it yourself, against the source. The app is published at github.com/dustball/cipher.ink (AGPL-3.0), one commit per release. Each release has a SHA256SUMS file listing every static file the site serves at that version, so you can check what your browser received against the repository instead of against this page:
curl -s https://cipher.ink/assets/crypto.js | shasum -a 256
grep ' assets/crypto.js$' SHA256SUMSThe file checksum verifier also reads a SHA256SUMS file. This proves the code served today is the code published. It can't prove what will be served tomorrow, and that's true of every website.
| File | Bytes | SHA-256 |
|---|---|---|
crypto.js | 9,577 | 6ac739d3f261ca7c5af26d705e3d0a5bba254062f1ca7c1cc4c054a8379cae8a |
app.js | 10,239 | f6a17a7b5aa4b4fb34631d77fdb091b7b61abe6675b28871d2416782e80cf8ea |
view.js | 10,673 | 4f08ef4e7d03f874dd57fe433b4f962ce8d61fa9cb46e9e88198009467ee9017 |
compose.js | 18,087 | d37cc7c61b12f39f1287cb116bbb59959c06b3d7e6e105643dcf5f7c1728c6df |
editor.js | 21,325 | fbf0e1e6a4a401677bcca41ba560e1113abd3e0c5b85c01b97af2e9806e719b2 |
mine.js | 8,452 | ab668d192bb47dd863de780da885f47eb698166277a5d157883090e294b41d7a |
contact.js | 2,028 | a5d38de13bcd449cf7604f72fc9bde50e5f7b3a5d690391ce1a16797bf2f64de |
theme.js | 638 | e0f886be95481c37bf93ba4212032894aeb16e421fe046cfb7d1f6876cad2df0 |
purify.min.js | 21,496 | c0845096a7c4a6741f362ac506c94c1c7d27dc603bcc1bf64a587f76f2dbe3a1 |
qrcode.js | 56,694 | 18ae399f81182bc9de916e9c77b195df20cc58d6f2d55a62b085a299f1bf1780 |
purify.min.js is DOMPurify and qrcode.js is qrcode-generator, the two libraries the product depends on. Their versions and licenses are on Credits. Everything else is ours.
crypto.js, section by section
The file's own header says what it does and what it never does:
cipher.ink crypto: everything that seals or opens a message is in this one file.
What it does: AES-256-GCM through the browser's own Web Crypto API. The key is either derived from a passphrase (PBKDF2-SHA256, 600,000 rounds, a fresh random salt) or is 32 random bytes that travel in the link's #fragment, which browsers never send to a server.
What it never does: send anything anywhere. There is no network code in this file. The server receives only the sealed result, and never a passphrase, a link key, a file name or a word of the message.
Also here: a read-only decryptor for the messages sealed between 2016 and 2026 (AES-256-OFB), so old links still open. Nothing new is ever written in that format.
Annotated, section by section, at https://cipher.ink/code
(function (root) {
'use strict';
var C = root.crypto, S = C.subtle;
var te = new TextEncoder();
var ITER = 600000;Base64url
Bytes become text that is safe in a URL: "+" and "/" are swapped for "-" and "_", and the "=" padding is dropped. The secret link's key, every salt and IV, and the ciphertext itself are written this way. The chunked loop keeps String.fromCharCode from choking on large files.
function b64u(bytes) {
var s = '', b = new Uint8Array(bytes);
for (var i = 0; i < b.length; i += 0x8000) s += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function unb64u(str) {
str = String(str).replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
var s = atob(str), b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
}Random bytes
Crypto.getRandomValues is the browser's cryptographically secure generator, the same source it uses for TLS. Math.random is never used for anything secret: the 2016 version of this site did, and that was a mistake.
function rand(n) { var b = new Uint8Array(n); C.getRandomValues(b); return b; }A fair random integer
The obvious random % n favours the low numbers whenever n does not divide 2^32, because the top slice of values is shorter than the others. So any draw from that uneven slice is thrown away and drawn again (rejection sampling), and every outcome gets exactly the same number of chances. The passphrase dice use this.
function randInt(n) {
var lim = Math.floor(4294967296 / n) * n, x = new Uint32Array(1);
do C.getRandomValues(x); while (x[0] >= lim);
return x[0] % n;
}From a passphrase to a key
The same words must give the same key on every device, so the passphrase is first put into Unicode NFC form ("é" typed as one character or as an "e" plus an accent becomes the same bytes). PBKDF2-HMAC-SHA256 then turns it into an AES-256 key: 600,000 rounds of HMAC, mixed with a random salt. Each round must wait for the one before it, so every guess an attacker makes costs as much as opening the message does for you. The key is created non-extractable: JavaScript can use it but cannot read its bytes back out.
function norm(p) { return String(p).normalize('NFC'); }
async function passKey(pass, salt, iter) {
var base = await S.importKey('raw', te.encode(norm(pass)), 'PBKDF2', false, ['deriveKey']);
return S.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt: salt, iterations: iter },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}A key from a secret link
A secret-link key is already 256 random bits, so it needs no stretching: it goes straight in as an AES key.
function rawKey(bytes) {
return S.importKey('raw', bytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}Sealing a message
Seal(obj, {passphrase} | {link:true, linkKey?}, files?) → {payload, linkKey?, blobs[]} A secret link normally gets a fresh random key; passing linkKey seals under that key instead (the Secret Santa seals every wishlist and note under its owner's list key, which travels inside the cards). One random 12-byte IV per encryption, and AES-GCM, which both hides the message and signs it: a 128-bit tag is appended to the ciphertext, so any change to it, or a wrong key, makes decryption fail instead of returning garbage. Passphrase mode records the iteration count and salt in the payload (they are not secret); link mode records nothing about the key. Attachments (files: [{name, type, bytes: Uint8Array}]) are encrypted with the same key and each with its own IV; their names, types, sizes and IVs go into obj.att, so they are sealed inside the message too. The server gets the payload and the encrypted file bytes, nothing else.
async function seal(obj, opt, files) {
var iv = rand(12), p = { v: 2 }, key, linkKey = null, blobs = [];
if (opt.link) {
var kb = rand(32);
if (opt.linkKey) kb = unb64u(opt.linkKey);
key = await rawKey(kb);
linkKey = b64u(kb);
p.m = 'k';
} else {
var salt = rand(16);
key = await passKey(opt.passphrase, salt, ITER);
p.m = 'p'; p.it = ITER; p.s = b64u(salt);
}
if (files && files.length) {
obj.att = [];
for (var i = 0; i < files.length; i++) {
var fiv = rand(12);
blobs.push(new Uint8Array(await S.encrypt({ name: 'AES-GCM', iv: fiv }, key, files[i].bytes)));
obj.att.push({ name: files[i].name, type: files[i].type, size: files[i].bytes.length, iv: b64u(fiv) });
}
}
p.n = b64u(iv);
p.c = b64u(await S.encrypt({ name: 'AES-GCM', iv: iv }, key, te.encode(JSON.stringify(obj))));
return { payload: JSON.stringify(p), linkKey: linkKey, blobs: blobs };
}Opening a message
Open(payloadString, {passphrase} | {linkKey}) → {data, key}, or throws Error('wrong'). The key is rebuilt the same way it was made. If the passphrase or link key is wrong, or the payload was altered, the GCM tag does not verify and decrypt() rejects; nothing is ever rendered from a failed decryption.
async function open(payload, opt) {
var p = typeof payload === 'string' ? JSON.parse(payload) : payload, key;
if (p.v !== 2) throw new Error('format');
if (p.m === 'k') key = await rawKey(unb64u(opt.linkKey));
else key = await passKey(opt.passphrase, unb64u(p.s), p.it);
var pt;
try { pt = await S.decrypt({ name: 'AES-GCM', iv: unb64u(p.n) }, key, unb64u(p.c)); }
catch (e) { throw new Error('wrong'); }
return { data: JSON.parse(new TextDecoder().decode(pt)), key: key };
}Opening an attachment
An attachment is fetched as encrypted bytes and opened with the key open() returned and the IV listed in data.att[n], so only someone who could open the message can open its files.
async function openFile(key, ivB64, bytes) {
return new Uint8Array(await S.decrypt({ name: 'AES-GCM', iv: unb64u(ivB64) }, key, bytes));
}The 2016 format: OFB through CBC
Messages sealed before October 2026 used AES-256-OFB from a JavaScript library, with the key made from the lowercased passphrase and the message's own id as the IV. The Web Crypto API has no OFB mode, but there is a way round it: OFB's keystream is E(IV), E(E(IV)), E(E(E(IV))), …, and AES-CBC applied to a run of zero blocks produces exactly that, because each zero block XORs into the previous ciphertext block and changes nothing. So CBC-encrypting zeros under the IV *is* the keystream, and XORing it with the old ciphertext decrypts it. OFB has no integrity check, so a wrong passphrase cannot be detected by a failing tag. Instead the output is accepted only if it is clean UTF-8 text without control characters, which random bytes almost never are.
async function legacyTry(bytes, ivt, pass) {
var kb = te.encode(('x'.repeat(32) + pass).slice(-32));
if (kb.length !== 32) return null;
var key = await S.importKey('raw', kb, { name: 'AES-CBC' }, false, ['encrypt']);
var blocks = Math.ceil(bytes.length / 16);
var ks = new Uint8Array(await S.encrypt({ name: 'AES-CBC', iv: te.encode(ivt) }, key, new Uint8Array(blocks * 16)));
var out = new Uint8Array(bytes.length);
for (var i = 0; i < bytes.length; i++) out[i] = bytes[i] ^ ks[i];
var s;
try { s = new TextDecoder('utf-8', { fatal: true }).decode(out); } catch (e) { return null; }
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) return null;
return s.replace(/\s+$/, '');
}
async function legacyOpen(bytes, ivt, pass) {
pass = String(pass).replace(/\n/g, '');
var tries = [pass.toLowerCase().substr(0, 32)];
if (pass.substr(0, 32) !== tries[0]) tries.push(pass.substr(0, 32));
for (var i = 0; i < tries.length; i++) {
var r = await legacyTry(bytes, ivt, tries[i]);
if (r !== null) return { html: r };
}
throw new Error('wrong');
}SHA-256
The page uses it to send the server only a hash of the receipt token, never the token.
async function sha256hex(str) {
var h = new Uint8Array(await S.digest('SHA-256', te.encode(str)));
return Array.prototype.map.call(h, function (x) { return ('0' + x.toString(16)).slice(-2); }).join('');
}
root.Cipher = { seal: seal, open: open, openFile: openFile, legacyOpen: legacyOpen, b64u: b64u, unb64u: unb64u,
rand: rand, randInt: randInt, sha256hex: sha256hex, ITER: ITER };Unsealing, in view.js
This is the heart of the reading page. It's the one place a passphrase or link key is used, and you can see that it goes only to Cipher.open, in this tab.
What pressing Unseal does
Unsealing. Pressing the button does everything, in this order:
- take the passphrase, or the link key from the URL fragment;
- get the sealed payload. A message that is not burn-after-reading already has it in the page. A burn message is asked for from the server, and handing it over is the moment the server destroys its copy;
- decrypt in this tab with Cipher.open. A wrong passphrase or key fails the GCM tag check, and nothing is shown;
- only after a successful decryption, tell the server the message was read, using the token that was sealed inside it. A link preview or a scanner cannot do this, because it cannot decrypt, so it never counts as a read;
- show the message (reveal), after sanitising it again with App.sanitize.
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (btn.disabled) return;
err.textContent = '';
var pass = passEl.value;
if (D.mode === 'k' && !linkKey) {
linkKey = keyFrom($('#keyin').value);
if (!linkKey) return fail('That doesn’t contain a key. It is the long part after the #.');
}
if (D.mode === 'p' && !pass) { passEl.focus(); return fail('Enter the passphrase you were given.'); }
btn.disabled = true; btn.classList.add('busy');
btn.querySelector('.lbl').textContent = D.mode === 'p' && D.kind === 'v2' ? 'Deriving key…' : 'Unsealing…';
if (!payload && D.kind === 'v2') {
try {
var r = await App.post('open', { id: D.id });
payload = r.payload; burned = r.burned;
} catch (x) {
if (x.status === 404) return gone();
return fail(x.message || 'Could not reach the server.');
}
}
var obj, key = null;
try {
if (D.kind === 'legacy') obj = await Cipher.legacyOpen(JSON.parse(D.enc), D.id, pass);
else { var o = await Cipher.open(payload, D.mode === 'k' ? { linkKey: linkKey } : { passphrase: pass }); obj = o.data; key = o.key; }
} catch (x) {
if (D.mode === 'k') { linkKey = null; $('#keyrow').hidden = false; return fail('That key doesn’t open this message. Check you copied the whole link.'); }
return fail(burned
? 'Not that one. The server copy is already destroyed, so it lives only in this tab now. Keep trying, and don’t close it.'
: 'That passphrase doesn’t open it.');
}
// A Secret Santa card opened through its /m/ link: hand over to the card page, which records the open itself.
if (obj && obj.app === 'santa' && D.mode === 'k') { location.replace(D.base + 'santa/' + D.id + '#' + linkKey); return; }
// Read receipt: only a successful unseal counts (link-preview bots can't decrypt). Burn messages
// were counted when the server handed them over. The sender previewing their own message (it is in
// this browser's My messages) is not a read.
var mine = App.sent.list().some(function (x) { return x.id === D.id; });
if (D.kind === 'v2' && !burned && obj.d && !mine) App.post('opened', { id: D.id, token: obj.d }).catch(function () {});
reveal(obj, key);
});Sanitizing a message, in app.js
A message is HTML written by whoever sealed it, so we treat it as hostile on the way in and again on the way out. These few lines are the allowlist.
The allowlist
Messages are HTML written by whoever sealed them, so they are sanitised on the way in (paste, seal) and again on the way out (render). The CSP is the wall behind this. The allowlist below is everything a message may contain: these tags, and the href attribute on links, and nothing else. No style, no class, no images, no scripts, no event handlers. The hook then removes the address from any link that is not http(s) or mailto (which kills javascript: links), and makes the rest open in a new tab with noopener, noreferrer and nofollow.
That's 22 tags and one attribute, counted from this file when the page was built. rendering HTML you do not trust explains why each tag is there.
var TAGS = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'h1', 'h2', 'h3',
'blockquote', 'ul', 'ol', 'li', 'pre', 'code', 'a', 'hr', 'div'];
if (root.DOMPurify) {
DOMPurify.addHook('afterSanitizeAttributes', function (n) {
if (n.tagName !== 'A') return;
var h = n.getAttribute('href') || '';
if (!/^(https?:|mailto:)/i.test(h)) n.removeAttribute('href');
else { n.setAttribute('target', '_blank'); n.setAttribute('rel', 'noopener noreferrer nofollow'); }
});
}
App.sanitize = function (html) {
if (!root.DOMPurify) throw new Error('sanitizer missing');
return DOMPurify.sanitize(html, { ALLOWED_TAGS: TAGS, ALLOWED_ATTR: ['href'] });
};The Content-Security-Policy
Every page this site sends carries this header, set in lib.php on the server. It's the second wall behind the sanitizer: even if hostile HTML got through, a browser enforcing this policy wouldn't run its script and couldn't send anything to another site.
The header, as sent
Reading it: default-src 'none' forbids everything not listed. Styles and fonts may come only from this site, and scripts only from this site and feedback.cipher.ink, the feedback tab's server, which I also run. Images may come only from this site, data: and blob: (decrypted attachments). Connections, including fetch, go only to this site and that feedback server, and the page can't be framed. There's no 'unsafe-inline' anywhere, which is why no page here contains inline script or style. The CSP article goes through every directive.
// No inline script, nothing from another origin but the feedback tab's server ($fb, in script-src and connect-src only).
// Decrypted messages are rendered as HTML, so this is the second wall behind DOMPurify. blob: is for decrypted image
// attachments. worker-src: the tools hash big files and run the sieve in Web Workers (same-origin files only).
header("Content-Security-Policy: default-src 'none'; script-src 'self'$fb; style-src 'self'; " .
"font-src 'self'; img-src 'self' data: blob:; connect-src 'self'$fb; worker-src 'self'; base-uri 'none'; " .
"form-action 'self'; manifest-src 'self'; frame-ancestors 'none'");The other files
compose.js is the compose page: the lock options, the passphrase dice, attachments and the call to Cipher.seal. editor.js is the writing surface that replaced CKEditor in 2026. mine.js is the My messages drawer, which keeps your list of sent messages in your own browser and never sends it anywhere. contact.js is the contact form, the one place on the site where what you type isn't encrypted, and the page says so. Each of them starts with a header saying what it does and what it never does. Open them in your browser's developer tools or fetch them with curl from https://cipher.ink/assets/.
The server side, api.php and lib.php, isn't rendered here, but it's in the repository. What you can verify from outside is what it receives: the requests in your browser's network tab are the whole conversation. The security page's table lists what the server sees and keeps.