Files
shopdb-flask/plugins/tools/frontend/securesend.js

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')
}