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
- All encryption and decryption runs on dirty CPU schedulers. Slow work, such as scrypt or large payloads, never stalls the BEAM's normal schedulers.
- The NIF only uses the
agecrate's public API. The cryptography, the format parsing, and the armor handling all come from upstreamage. - Data is currently processed as whole binaries in memory. A streaming API for multi-gigabyte files is a natural next step. See "Not yet supported" below.
Not yet supported
- Streaming encryption and decryption (chunked
Stream/Fileintegration) - age plugins (
age-plugin-yubikey, and so on), which require spawning external binaries - Passphrase-protected SSH private keys (these return a clear error)
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
-
Bump
@versioninmix.exs. The repository must be public so Hex users can download the release assets from@source_url. -
Push a
vX.Y.Ztag. Therelease.ymlworkflow builds a NIF for each target and attaches it to a GitHub release. Wait for all of the jobs to finish. -
Generate the checksum file that ships with the Hex package, and commit it:
mix rustler_precompiled.download ExAge.Native --all --printgit add checksum-Elixir.ExAge.Native.exs && git commit -m "Add checksums for vX.Y.Z" -
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.