Secure send: the pages a person actually touches
This commit is contained in:
136
plugins/tools/frontend/securesend.js
Normal file
136
plugins/tools/frontend/securesend.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// 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')
|
||||
}
|
||||
Reference in New Issue
Block a user