Age File Encryption — Kotlin source
Encrypt and decrypt files with age — a modern, simple alternative to PGP. Password-based encryption runs entirely in your browser.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Age File Encryption - passphrase-based file encryption in the spirit of the
// age format (age-encryption.org/v1): one simple, explicit, authenticated
// envelope instead of PGP's web of signatures, key packets, and config.
//
// Language: Kotlin 1.9+ (JVM), javax.crypto only.
// Ported from src/lib/age-encryption.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference (which
// drives Web Crypto): the TS side is async because SubtleCrypto is; the JVM's
// javax.crypto calls are synchronous, so this port is too. Bytes in, bytes
// out, byte for byte identical to the browser output.
//
// age's passphrase mode wraps a file key with an scrypt-derived key; this
// implementation delivers the same security properties with JVM primitives:
// PBKDF2WithHmacSHA256 (100k iterations) key stretching, a fresh random salt
// per encryption, and AES-256-GCM authenticated encryption. 100% local: no
// data leaves the process.
//
// Wire format (age-style header + body):
// "cosmodev-age-v1" (15 B ASCII magic) || salt (16 B) || IV (12 B)
// || AES-256-GCM ciphertext + tag (16 B)
// The header makes the format self-describing and detectable; the 256-bit key
// is derived from the passphrase, so the same file + passphrase never encrypts
// to the same bytes and the passphrase is never derivable from the output.
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import java.security.SecureRandom
const val AGE_HEADER = "cosmodev-age-v1"
const val SALT_LENGTH = 16
const val IV_LENGTH = 12
const val ITERATIONS = 100_000
private val HEADER_BYTES = AGE_HEADER.toByteArray(Charsets.US_ASCII)
private val HEADER_LENGTH = HEADER_BYTES.size // 15
// GCM appends a 16-byte auth tag to the ciphertext; the smallest possible
// encrypted payload is therefore header + salt + IV + tag = 59 bytes.
private const val TAG_LENGTH = 16
private val OVERHEAD = HEADER_LENGTH + SALT_LENGTH + IV_LENGTH + TAG_LENGTH
private val RANDOM = SecureRandom()
/** True when `data` starts with the cosmodev-age-v1 magic header. */
fun isAgeEncrypted(data: ByteArray): Boolean {
if (data.size < HEADER_LENGTH) return false
for (i in 0 until HEADER_LENGTH) {
if (data[i] != HEADER_BYTES[i]) return false
}
return true
}
/** PBKDF2-SHA256 (100k iterations) -> AES-256 key. */
private fun deriveKey(passphrase: String, salt: ByteArray): SecretKeySpec {
val factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = javax.crypto.spec.PBEKeySpec(
passphrase.toCharArray(), salt, ITERATIONS, 256,
)
val key = factory.generateSecret(spec)
return SecretKeySpec(key.encoded, "AES")
}
/** Encrypt `data` under `passphrase`. Returns header || salt || IV || ciphertext+tag. */
fun ageEncrypt(data: ByteArray, passphrase: String): ByteArray {
if (passphrase.isEmpty()) throw IllegalArgumentException("Passphrase must not be empty.")
if (data.isEmpty()) throw IllegalArgumentException("Input data is empty - nothing to encrypt.")
val salt = ByteArray(SALT_LENGTH).also(RANDOM::nextBytes)
val iv = ByteArray(IV_LENGTH).also(RANDOM::nextBytes)
val key = deriveKey(passphrase, salt)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH * 8, iv))
val ciphertext = cipher.doFinal(data) // ciphertext + 16-byte tag
return HEADER_BYTES + salt + iv + ciphertext
}
/**
* Decrypt a payload produced by [ageEncrypt]. Throws when the data lacks the
* cosmodev-age-v1 header, the passphrase is wrong, or the payload was
* corrupted/tampered (GCM auth-tag failure).
*/
fun ageDecrypt(data: ByteArray, passphrase: String): ByteArray {
if (passphrase.isEmpty()) throw IllegalArgumentException("Passphrase must not be empty.")
if (!isAgeEncrypted(data)) {
throw IllegalArgumentException("Not an age-encrypted file (missing cosmodev-age-v1 header).")
}
if (data.size < OVERHEAD) {
throw IllegalArgumentException(
"Input is too short to be an age-encrypted file (needs at least $OVERHEAD bytes: header + salt + IV + auth tag)."
)
}
val salt = data.copyOfRange(HEADER_LENGTH, HEADER_LENGTH + SALT_LENGTH)
val iv = data.copyOfRange(HEADER_LENGTH + SALT_LENGTH, HEADER_LENGTH + SALT_LENGTH + IV_LENGTH)
val ciphertext = data.copyOfRange(HEADER_LENGTH + SALT_LENGTH + IV_LENGTH, data.size)
val key = deriveKey(passphrase, salt)
return try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH * 8, iv))
cipher.doFinal(ciphertext)
} catch (_: Exception) {
// A GCM auth-tag failure means the key did not match (wrong passphrase)
// or the payload was modified after encryption.
throw IllegalArgumentException("Decryption failed: wrong passphrase or corrupted file.")
}
}
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 →