Skip to content

Text Encryptor — TypeScript source

Encrypt or decrypt text with a password using AES-256-GCM. Share the ciphertext safely - only someone with the password can read it.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Password-based text encryption via the Web Crypto API (SubtleCrypto). Pure
// logic - no React, no DOM. Async only because SubtleCrypto is. Requires a
// secure context (https or localhost).
//
// Format: PBKDF2 (SHA-256, 100k iterations, 16-byte random salt) derives an
// AES-256 key from the password; AES-GCM encrypts with a 12-byte random IV.
// The Base64 output packs salt + IV + ciphertext (+ GCM tag), so every
// encryption is unique and self-contained - decrypt needs only the string and
// the password.
//
// Mirrors src/lib/hmac.ts (SubtleCrypto surface) and the PBKDF2 + AES-256-GCM
// pattern the file-encryptor uses, applied to UTF-8 strings.

const PBKDF2_ITERATIONS = 100_000;
const SALT_BYTES = 16;
const IV_BYTES = 12;
/** Smallest valid packed payload: salt + IV + one AES-GCM block (tag). */
const MIN_BYTES = SALT_BYTES + IV_BYTES + 16;

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';

/** Uint8Array -> canonical Base64 (pure codec - no btoa, so it runs anywhere). */
function bytesToBase64(bytes: Uint8Array): string {
  let out = '';
  for (let i = 0; i < bytes.length; i += 3) {
    const b0 = bytes[i]!;
    const b1 = i + 1 < bytes.length ? bytes[i + 1]! : null;
    const b2 = i + 2 < bytes.length ? bytes[i + 2]! : null;
    out += ALPHABET[b0 >> 2];
    out += ALPHABET[((b0 & 0x03) << 4) | (b1 === null ? 0 : b1 >> 4)];
    out += b1 === null ? '=' : ALPHABET[((b1 & 0x0f) << 2) | (b2 === null ? 0 : b2 >> 6)];
    out += b2 === null ? '=' : ALPHABET[b2 & 0x3f];
  }
  return out;
}

/** Base64 -> Uint8Array. Accepts surrounding whitespace; throws on any other
 *  non-alphabet character, wrong length, or misplaced padding. */
function base64ToBytes(b64: string): Uint8Array {
  const clean = b64.replace(/\s+/g, '');
  if (clean.length === 0) throw new Error('Invalid Base64: input is empty');
  if (clean.length % 4 !== 0) throw new Error('Invalid Base64: length must be a multiple of 4');
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(clean)) {
    const bad = [...clean].find((c) => !ALPHABET.includes(c) && c !== '=');
    throw new Error(`Invalid Base64: unexpected character "${bad ?? '?'}"`);
  }
  let outLength = (clean.length / 4) * 3;
  if (clean.endsWith('==')) outLength -= 2;
  else if (clean.endsWith('=')) outLength -= 1;
  const bytes = new Uint8Array(outLength);
  let p = 0;
  for (let i = 0; i < clean.length; i += 4) {
    const c0 = ALPHABET.indexOf(clean[i]!);
    const c1 = ALPHABET.indexOf(clean[i + 1]!);
    const c2 = clean[i + 2] === '=' ? -1 : ALPHABET.indexOf(clean[i + 2]!);
    const c3 = clean[i + 3] === '=' ? -1 : ALPHABET.indexOf(clean[i + 3]!);
    if (p < outLength) bytes[p++] = (c0 << 2) | (c1 >> 4);
    if (c2 !== -1 && p < outLength) bytes[p++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
    if (c2 !== -1 && c3 !== -1 && p < outLength) bytes[p++] = ((c2 & 0x03) << 6) | c3;
  }
  return bytes;
}

/** PBKDF2-SHA256 (100k iterations) -> non-extractable AES-256-GCM key. */
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
  const material = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(password),
    'PBKDF2',
    false,
    ['deriveKey'],
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
}

/**
 * Encrypt UTF-8 `plaintext` under `password` and return the Base64 string
 * packing salt + IV + AES-256-GCM ciphertext. Throws if either input is empty.
 */
export async function encryptText(plaintext: string, password: string): Promise<string> {
  if (plaintext === '') throw new Error('Nothing to encrypt: input is empty');
  if (password === '') throw new Error('Password must not be empty');
  const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const key = await deriveKey(password, salt);
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(plaintext),
  );
  const packed = new Uint8Array(SALT_BYTES + IV_BYTES + ciphertext.byteLength);
  packed.set(salt, 0);
  packed.set(iv, SALT_BYTES);
  packed.set(new Uint8Array(ciphertext), SALT_BYTES + IV_BYTES);
  return bytesToBase64(packed);
}

/**
 * Decrypt a Base64 string produced by `encryptText` back to plaintext. Throws
 * on empty input/password, invalid Base64, a payload too short to be one of
 * ours, or a GCM authentication failure (wrong password / tampered data).
 */
export async function decryptText(ciphertext: string, password: string): Promise<string> {
  if (ciphertext.trim() === '') throw new Error('Nothing to decrypt: input is empty');
  if (password === '') throw new Error('Password must not be empty');
  const packed = base64ToBytes(ciphertext.trim());
  if (packed.length < MIN_BYTES) {
    throw new Error('Ciphertext too short - not a valid salt + IV + AES-GCM payload');
  }
  const salt = packed.slice(0, SALT_BYTES);
  const iv = packed.slice(SALT_BYTES, SALT_BYTES + IV_BYTES);
  const data = packed.slice(SALT_BYTES + IV_BYTES);
  const key = await deriveKey(password, salt);
  try {
    const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data);
    return new TextDecoder().decode(plain);
  } catch {
    throw new Error('Decryption failed - wrong password or corrupted ciphertext');
  }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →