Skip to content

Text Encryptor — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Text Encryptor — password-based text encryption (PBKDF2 + AES-256-GCM).
// Language: Kotlin (JVM 17+), standard library + javax.crypto (JVM native crypto).
// Ported from src/lib/text-encryptor.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: a Base64
// string encrypted by one side decrypts on the other.
//
// 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.

import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec

private const val PBKDF2_ITERATIONS = 100_000
private const val SALT_BYTES = 16
private const val IV_BYTES = 12

/** Smallest valid packed payload: salt + IV + one AES-GCM block (tag). */
private const val MIN_BYTES = SALT_BYTES + IV_BYTES + 16

private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

private val random = SecureRandom()

/** ByteArray -> canonical Base64 (pure codec, mirroring the TS implementation). */
fun bytesToBase64(bytes: ByteArray): String {
    val out = StringBuilder()
    var i = 0
    while (i < bytes.size) {
        val b0 = bytes[i].toInt() and 0xff
        val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xff else null
        val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xff else null
        out.append(ALPHABET[b0 shr 2])
        out.append(ALPHABET[((b0 and 0x03) shl 4) or ((b1 ?: 0) shr 4)])
        out.append(if (b1 == null) '=' else ALPHABET[((b1 and 0x0f) shl 2) or ((b2 ?: 0) shr 6)])
        out.append(if (b2 == null) '=' else ALPHABET[b2 and 0x3f])
        i += 3
    }
    return out.toString()
}

/**
 * Base64 -> ByteArray. Accepts surrounding whitespace; throws on any other
 * non-alphabet character, wrong length, or misplaced padding.
 */
fun base64ToBytes(b64: String): ByteArray {
    val clean = b64.replace(Regex("\\s+"), "")
    if (clean.isEmpty()) throw IllegalArgumentException("Invalid Base64: input is empty")
    if (clean.length % 4 != 0) throw IllegalArgumentException("Invalid Base64: length must be a multiple of 4")
    val bad = clean.firstOrNull { ALPHABET.indexOf(it) < 0 && it != '=' }
    if (bad != null) throw IllegalArgumentException("Invalid Base64: unexpected character \"$bad\"")
    var outLength = (clean.length / 4) * 3
    if (clean.endsWith("==")) outLength -= 2 else if (clean.endsWith("=")) outLength -= 1
    val bytes = ByteArray(outLength)
    var p = 0
    var i = 0
    while (i < clean.length) {
        val c0 = ALPHABET.indexOf(clean[i])
        val c1 = ALPHABET.indexOf(clean[i + 1])
        val c2 = if (clean[i + 2] == '=') -1 else ALPHABET.indexOf(clean[i + 2])
        val c3 = if (clean[i + 3] == '=') -1 else ALPHABET.indexOf(clean[i + 3])
        if (p < outLength) bytes[p++] = ((c0 shl 2) or (c1 shr 4)).toByte()
        if (c2 != -1 && p < outLength) bytes[p++] = (((c1 and 0x0f) shl 4) or (c2 shr 2)).toByte()
        if (c2 != -1 && c3 != -1 && p < outLength) bytes[p++] = (((c2 and 0x03) shl 6) or c3).toByte()
        i += 4
    }
    return bytes
}

/** PBKDF2-SHA256 (100k iterations) -> AES-256-GCM key. */
private fun deriveKey(password: String, salt: ByteArray): SecretKeySpec {
    val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
    val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, 256)
    return SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
}

/**
 * Encrypt UTF-8 `plaintext` under `password` and return the Base64 string
 * packing salt + IV + AES-256-GCM ciphertext. Throws if either input is empty.
 */
fun encryptText(plaintext: String, password: String): String {
    if (plaintext.isEmpty()) throw IllegalArgumentException("Nothing to encrypt: input is empty")
    if (password.isEmpty()) throw IllegalArgumentException("Password must not be empty")
    val salt = ByteArray(SALT_BYTES).also(random::nextBytes)
    val iv = ByteArray(IV_BYTES).also(random::nextBytes)
    val key = deriveKey(password, salt)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
    val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
    val packed = ByteArray(SALT_BYTES + IV_BYTES + ciphertext.size)
    salt.copyInto(packed, 0)
    iv.copyInto(packed, SALT_BYTES)
    ciphertext.copyInto(packed, 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).
 */
fun decryptText(ciphertext: String, password: String): String {
    if (ciphertext.trim().isEmpty()) throw IllegalArgumentException("Nothing to decrypt: input is empty")
    if (password.isEmpty()) throw IllegalArgumentException("Password must not be empty")
    val packed = base64ToBytes(ciphertext.trim())
    if (packed.size < MIN_BYTES) {
        throw IllegalArgumentException("Ciphertext too short - not a valid salt + IV + AES-GCM payload")
    }
    val salt = packed.copyOfRange(0, SALT_BYTES)
    val iv = packed.copyOfRange(SALT_BYTES, SALT_BYTES + IV_BYTES)
    val data = packed.copyOfRange(SALT_BYTES + IV_BYTES, packed.size)
    val key = deriveKey(password, salt)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
    return try {
        cipher.doFinal(data).toString(Charsets.UTF_8)
    } catch (e: Exception) {
        throw IllegalArgumentException("Decryption failed - wrong password or corrupted ciphertext", e)
    }
}

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 →