diff --git a/plugins/tools/frontend/api.js b/plugins/tools/frontend/api.js new file mode 100644 index 00000000..5d5ed13c --- /dev/null +++ b/plugins/tools/frontend/api.js @@ -0,0 +1,23 @@ +// Tech Tools API client. +// +// Only secure send needs one - every other tool runs entirely in the browser +// and talks to nothing. +import api from '@/api' + +export const toolsApi = { + createSecret(payload) { + return api.post('/tools/secrets', payload) + }, + + // Is the link still live? Spends nothing, so it is safe for the read page to + // ask before anyone clicks. + peekSecret(token) { + return api.get(`/tools/secrets/${token}`) + }, + + // POST, not GET, and that is deliberate: chat and mail clients prefetch GET + // urls to build previews, and a preview must not consume a one-view secret. + redeemSecret(token) { + return api.post(`/tools/secrets/${token}`) + }, +} diff --git a/plugins/tools/frontend/routes.js b/plugins/tools/frontend/routes.js index e17ac47e..3af4a1c0 100644 --- a/plugins/tools/frontend/routes.js +++ b/plugins/tools/frontend/routes.js @@ -23,4 +23,26 @@ export default [ component: () => import('./views/CodeGenerator.vue'), meta: { requiresAuth: true, plugin: 'tools' } }, + { + path: 'tools/secure-send', + name: 'tools-secure-send', + component: () => import('./views/SecureSendCreate.vue'), + // Creating a secret needs a login AND tools.secrets. The route guard covers + // the login; the server enforces the permission, and the page surfaces its + // refusal rather than pretending the form worked. + meta: { requiresAuth: true, plugin: 'tools' } + }, +] + +// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account +// here, so this page carries no navigation and asks for no login - the +// unguessable token in the path, and the key in the fragment, are what let them +// read it. Short path because the whole link gets pasted into a chat message. +export const toplevel = [ + { + path: '/s/:token', + name: 'secure-send-read', + component: () => import('./views/SecureSendRead.vue'), + meta: { plugin: 'tools' } + }, ] diff --git a/plugins/tools/frontend/securesend.js b/plugins/tools/frontend/securesend.js new file mode 100644 index 00000000..e2ced454 --- /dev/null +++ b/plugins/tools/frontend/securesend.js @@ -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') +} diff --git a/plugins/tools/frontend/tools.js b/plugins/tools/frontend/tools.js index aa93420b..95127ee0 100644 --- a/plugins/tools/frontend/tools.js +++ b/plugins/tools/frontend/tools.js @@ -24,11 +24,21 @@ export const TOOLS = [ standalone: true, keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'], }, + { + id: 'secure-send', + name: 'Secure Send', + description: 'Hand somebody a password by a link that works once and then destroys it.', + category: 'security', + route: '/tools/secure-send', + keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire', + 'one time', 'burn', 'pusher'], + }, ] export const CATEGORY_LABELS = { labels: 'Labels & Printing', convert: 'Conversion', + security: 'Secrets & Access', network: 'Network', } diff --git a/plugins/tools/frontend/views/SecureSendCreate.vue b/plugins/tools/frontend/views/SecureSendCreate.vue new file mode 100644 index 00000000..dd308d72 --- /dev/null +++ b/plugins/tools/frontend/views/SecureSendCreate.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/plugins/tools/frontend/views/SecureSendRead.vue b/plugins/tools/frontend/views/SecureSendRead.vue new file mode 100644 index 00000000..e562f3c0 --- /dev/null +++ b/plugins/tools/frontend/views/SecureSendRead.vue @@ -0,0 +1,154 @@ + + + + +