// Client-side crypto for secure send. // // THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the // URL fragment, and read back out of the fragment on the other side. Browsers // do not transmit fragments, so it reaches the recipient's browser and nowhere // else - not the server, not its logs, not a proxy in between. That is what // makes the stored ciphertext useless to anyone who reads the database. // // AES-GCM because it authenticates as well as encrypts: a tampered ciphertext // fails to decrypt rather than producing plausible rubbish. // // Web Crypto only, no dependency. It is available on every browser the fleet // runs and requires a secure context, which is the right constraint anyway - // the page and its scripts still cross the network even though the key does not. const ALGORITHM = 'AES-GCM' const KEY_BITS = 256 const IV_BYTES = 12 function toBase64(bytes) { let binary = '' for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte) return btoa(binary) } function fromBase64(text) { const binary = atob(text) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) return bytes } // base64url for the fragment: '+' and '/' are legal in a fragment but survive // copy-paste and link rewriting far less reliably than the url-safe alphabet. function toBase64Url(bytes) { return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } function fromBase64Url(text) { const padded = text.replace(/-/g, '+').replace(/_/g, '/') return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4)) } export function cryptoAvailable() { return typeof crypto !== 'undefined' && !!crypto.subtle } /** Encrypt text. Returns the payload for the server AND the key for the link. */ export async function sealSecret(plaintext) { const key = await crypto.subtle.generateKey( { name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt']) const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)) const ciphertext = await crypto.subtle.encrypt( { name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext)) const raw = await crypto.subtle.exportKey('raw', key) return { ciphertext: toBase64(ciphertext), iv: toBase64(iv), // Goes in the fragment. Never sent anywhere. fragmentKey: toBase64Url(raw), } } /** Decrypt what the server handed back, using the key from the fragment. */ export async function openSecret({ ciphertext, iv, fragmentKey }) { const key = await crypto.subtle.importKey( 'raw', fromBase64Url(fragmentKey), { name: ALGORITHM, length: KEY_BITS }, false, ['decrypt']) const plaintext = await crypto.subtle.decrypt( { name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext)) return new TextDecoder().decode(plaintext) } // -- Keeping this from becoming a document transfer --------------------------- // // The server can only bound SIZE, because it cannot read what it holds. These // run here, where the plaintext is still visible, and are advisory: they guide // somebody who is about to do the wrong thing without realising. A determined // person can POST directly, which is why the size limit on the server is the // control that actually enforces. // Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the // GCM tag, so the browser refuses before a round trip rather than after one. export const MAX_PLAINTEXT = 2800 const MAX_LINES = 20 // A pasted file, recognised by how it starts. Cheap, high signal. const FILE_SIGNATURES = [ ['%PDF', 'a PDF'], ['PK\x03\x04', 'a zip or Office file'], ['{\\rtf', 'an RTF document'], ['\x89PNG', 'a PNG image'], // JPEG magic bytes as escapes rather than literals: the naming gate bans // non-ASCII source, and these are bytes rather than text anyway. ['\xff\xd8\xff', 'a JPEG image'], ['data:', 'a data URI'], ] /** Why this text should not be sent, or null when it is fine. */ export function refuseReason(text) { const value = text || '' if (!value.trim()) return 'Nothing to send yet.' if (value.length > MAX_PLAINTEXT) { return `That is ${value.length} characters. This is for passwords and short ` + `text, not documents - the limit is ${MAX_PLAINTEXT}.` } for (const [signature, description] of FILE_SIGNATURES) { if (value.startsWith(signature)) { return `That looks like ${description} pasted as text. Secure send is for ` + 'credentials, not file transfer.' } } const lines = value.split('\n').length if (lines > MAX_LINES) { return `That is ${lines} lines. A password is one - this looks like a ` + 'document, and secure send is not a file transfer.' } return null } /** * Reject a paste that carries FILES rather than text. * * Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied * file - without anyone having to read a rule about it. */ export function pasteCarriesFiles(event) { const items = event?.clipboardData?.items if (!items) return false return Array.from(items).some(item => item.kind === 'file') }