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 →