137 lines
5.0 KiB
JavaScript
137 lines
5.0 KiB
JavaScript
// 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')
|
|
}
|