WWW.VE.MS — PASTEBOARD
pasteboard — encryption design notes
/* 

I implemented optional “encrypt in browser” for our Pasteboard so that in
encrypted mode we never need the user’s password or the note’s plaintext on
our server. The cryptography runs entirely in the visitor’s browser via the
Web Crypto API.

What I chose and why
--------------------
- AES-256-GCM for confidentiality and integrity (built-in authentication tag;
  tampering should fail at decrypt).
- PBKDF2 with SHA-256 and 100,000 iterations to derive the AES key from the
  user’s password, with a random per-paste salt so identical passwords do not
  produce identical stored blobs.
- 16-byte salt, 12-byte IV (nonce), both from crypto.getRandomValues().
- I expose the implementation in a small script so anyone can verify it matches
  what the site loads: /paste-crypto.js

What I send to our backend when “encrypt” is on
-----------------------------------------------
The POST includes only Base64-encoded salt, IV, and ciphertext (plus normal
form fields: CSRF, theme if applicable, is_encrypted flag). I strip the
plaintext from the textarea before submit after encryption succeeds. The
password fields are cleared client-side; they are not posted.

What I do on the server (PHP)
-----------------------------
I do not decrypt. I only validate that the Base64 decodes, that the salt and
IV lengths are in the ranges I expect, and that the ciphertext size is within
limits. That logic lives in vems_paste_validate_encrypted_payload() in
tools/includes/paste-lib.php. If validation passes, I store salt, IV, and
ciphertext in the database like any other paste row.

How viewing works
-----------------
The /p… page embeds the stored salt/IV/ciphertext for the browser. The user
enters the password there; I run the same PBKDF2 + AES-GCM decrypt in JS.
Wrong password → decrypt throws; I show a failure path in the UI.

What I am not claiming
----------------------
This is not a formal audit. Strong passwords and a trustworthy endpoint still
matter. Anything that can read memory or script the page before encryption or
after decryption (malware, some corporate TLS inspection setups, compromised
extensions) is outside what server-side design alone can fix.

Below is the exact client code I ship (for reviewers who want line-for-line
match with the live file).

================================================================================
Client — /paste-crypto.js
================================================================================
*/

/**
 * Browser-only AES-256-GCM + PBKDF2 (100k iter). Plaintext never sent when encrypting.
 */
(function (global) {
  'use strict';
  var PBKDF2_ITERATIONS = 100000;
  var SALT_LEN = 16;
  var IV_LEN = 12;

  function abToB64(buf) {
    var bytes = new Uint8Array(buf);
    var bin = '';
    var i;
    for (i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
    return btoa(bin);
  }

  function b64ToAb(b64) {
    var bin = atob(String(b64).replace(/\s/g, ''));
    var bytes = new Uint8Array(bin.length);
    var i;
    for (i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return bytes.buffer;
  }

  function deriveKey(password, saltBuf) {
    var enc = new TextEncoder();
    return crypto.subtle.importKey('raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']).then(function (keyMaterial) {
      return crypto.subtle.deriveKey(
        { name: 'PBKDF2', salt: saltBuf, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
        keyMaterial,
        { name: 'AES-GCM', length: 256 },
        false,
        ['encrypt', 'decrypt']
      );
    });
  }

  function encrypt(plaintext, password) {
    var salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
    var iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
    return deriveKey(password, salt).then(function (key) {
      var enc = new TextEncoder();
      return crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, enc.encode(plaintext)).then(function (ct) {
        return {
          saltB64: abToB64(salt.buffer),
          ivB64: abToB64(iv.buffer),
          cipherB64: abToB64(ct),
        };
      });
    });
  }

  function decrypt(saltB64, ivB64, cipherB64, password) {
    var salt = new Uint8Array(b64ToAb(saltB64));
    var iv = new Uint8Array(b64ToAb(ivB64));
    var cipher = b64ToAb(cipherB64);
    return deriveKey(password, salt).then(function (key) {
      return crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, cipher);
    }).then(function (pt) {
      return new TextDecoder().decode(pt);
    });
  }

  global.VemsPasteCrypto = {
    encrypt: encrypt,
    decrypt: decrypt,
    PBKDF2_ITERATIONS: PBKDF2_ITERATIONS,
  };
})(typeof window !== 'undefined' ? window : globalThis);


================================================================================
Serverside — /paste-lib.php
================================================================================

<?php
/**
 * @return array{ok:bool, err?:string, salt?:string, iv?:string, cipher?:string}
 */
function vems_paste_validate_encrypted_payload(string $saltB64, string $ivB64, string $cipherB64): array {
    $saltB64 = trim($saltB64);
    $ivB64 = trim($ivB64);
    $cipherB64 = preg_replace('/\s+/', '', $cipherB64) ?? '';
    if (strlen($cipherB64) > VEMS_PASTE_MAX_CIPHER_B64_LEN) {
        return ['ok' => false, 'err' => 'Paste payload too large.'];
    }
    $salt = base64_decode($saltB64, true);
    if ($salt === false || strlen($salt) < 8 || strlen($salt) > 64) {
        return ['ok' => false, 'err' => 'Invalid encryption salt.'];
    }
    $iv = base64_decode($ivB64, true);
    if ($iv === false || strlen($iv) !== 12) {
        return ['ok' => false, 'err' => 'Invalid encryption IV.'];
    }
    $ct = base64_decode($cipherB64, true);
    if ($ct === false || strlen($ct) < 16 || strlen($ct) > (VEMS_PASTE_MAX_PLAIN_CHARS + 64)) {
        return ['ok' => false, 'err' => 'Invalid ciphertext.'];
    }
    return ['ok' => true, 'salt' => $saltB64, 'iv' => $ivB64, 'cipher' => $cipherB64];
}

Plain paste (not password-protected). Anyone with the link can read it.