ExAge

age encryption for Elixir, backed by the Rust age crate (the library that powers rage) through Rustler.

ExAge writes and reads standard age v1 files, so its output works with the age and rage command-line tools and every other conforming implementation.

Installation

def deps do
[
{:ex_age, "~> 0.1"}
]
end

You don't need a Rust toolchain. ExAge uses rustler_precompiled, so mix deps.get downloads a prebuilt, checksum-verified NIF for your platform: macOS (Apple Silicon and Intel), Linux (glibc and musl, x86_64 and aarch64), and Windows. The NIF targets NIF version 2.15, which covers OTP 24 and later.

To build from source instead (for example, on an unlisted platform), install Rust, add {:rustler, ">= 0.0.0", optional: true} to your deps, and set EX_AGE_BUILD=1:

EX_AGE_BUILD=1 mix deps.compile ex_age --force

Usage

Key pairs

{identity, recipient} = ExAge.generate_identity()
# identity => "AGE-SECRET-KEY-1..." (keep secret)
# recipient => "age1..." (share freely)
{:ok, ciphertext} = ExAge.encrypt("attack at dawn", recipient)
{:ok, "attack at dawn"} = ExAge.decrypt(ciphertext, identity)

Encrypt to several recipients at once. Any one of them can decrypt:

{:ok, ciphertext} = ExAge.encrypt(data, [alice_recipient, bob_recipient])

To decrypt, pass one identity or a list of them. Each can be a single key or the full contents of an identity file (for example, one generated by age-keygen):

{:ok, plaintext} = ExAge.decrypt(ciphertext, File.read!("key.txt"))

ASCII armor

{:ok, armored} = ExAge.encrypt("hello", recipient, armor: true)
# "-----BEGIN AGE ENCRYPTED FILE-----\n..."

decrypt/2 accepts both binary and armored input.

SSH keys

Recipients can be ssh-ed25519 or ssh-rsa public keys. Identities can be unencrypted OpenSSH private keys:

{:ok, ct} = ExAge.encrypt("hi", File.read!(Path.expand("~/.ssh/id_ed25519.pub")))
{:ok, "hi"} = ExAge.decrypt(ct, File.read!(Path.expand("~/.ssh/id_ed25519")))

Passphrases

{:ok, ct} = ExAge.encrypt_with_passphrase("secret", "correct horse battery staple")
{:ok, "secret"} = ExAge.decrypt_with_passphrase(ct, "correct horse battery staple")

Passphrase encryption uses scrypt, and by default each call takes about one second of CPU time. That cost is intentional. Use key pairs for anything programmatic. :work_factor (encryption) and :max_work_factor (decryption) tune the cost.

Errors

Every function returns {:ok, result} or {:error, reason}, where reason is a human-readable string. Each function also has a ! variant that raises ExAge.Error. Error messages never include secret key material.

Design notes

Not yet supported

Development

Requirements: Elixir 1.15+, OTP 24+, and a stable Rust toolchain.

mix deps.get
mix test # compiles the NIF from source automatically in dev and test

The interoperability tests run against the reference age CLI and ssh-keygen. If either tool isn't on your PATH, those tests are skipped automatically. On Debian or Ubuntu, apt install age installs the CLI.

lib/ex_age.ex Public API
lib/ex_age/native.ex NIF loader (RustlerPrecompiled)
native/ex_age/src/lib.rs Rust NIF wrapping the age crate
.github/workflows/ CI and the precompiled NIF release build

Releasing

  1. Bump @version in mix.exs. The repository must be public so Hex users can download the release assets from @source_url.

  2. Push a vX.Y.Z tag. The release.yml workflow builds a NIF for each target and attaches it to a GitHub release. Wait for all of the jobs to finish.

  3. Generate the checksum file that ships with the Hex package, and commit it:

    mix rustler_precompiled.download ExAge.Native --all --print
    git add checksum-Elixir.ExAge.Native.exs && git commit -m "Add checksums for vX.Y.Z"
  4. Run mix hex.publish.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option, the same terms as age and rage.