Packages

age file encryption for Elixir, powered by the Rust age crate (rage).

Current section

Files

Jump to
ex_age README.md
Raw

README.md

# ExAge
[age](https://age-encryption.org) encryption for Elixir, backed by the Rust
[`age`](https://crates.io/crates/age) crate (the library that powers
[`rage`](https://github.com/str4d/rage)) through [Rustler](https://github.com/rusterlium/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
```elixir
def deps do
[
{:ex_age, "~> 0.1"}
]
end
```
You don't need a Rust toolchain. ExAge uses
[`rustler_precompiled`](https://github.com/philss/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`:
```sh
EX_AGE_BUILD=1 mix deps.compile ex_age --force
```
## Usage
### Key pairs
```elixir
{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:
```elixir
{: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`):
```elixir
{:ok, plaintext} = ExAge.decrypt(ciphertext, File.read!("key.txt"))
```
### ASCII armor
```elixir
{: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:
```elixir
{: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
```elixir
{: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 `age` crate's public API. The cryptography, the format
parsing, and the armor handling all come from upstream `age`.
- 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`/`File` integration)
- 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.
```sh
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:
```sh
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](https://github.com/trentjones21/agex/blob/main/LICENSE-APACHE)
or [MIT license](https://github.com/trentjones21/agex/blob/main/LICENSE-MIT) at your option,
the same terms as `age` and `rage`.