Building an Offline Authenticator with Rust and Age Encryption
I'm building a Rust library called age-auth — a toolkit for creating offline, one‑time password systems where the secret is always encrypted with age (X25519). Think of it as the cryptographic foundation for a super‑secure, offline‑first authenticator app like Google Authenticator, but without ever storing or transmitting the secret in plain text.
In this post I'll walk through why I started the project, the architecture I chose, and how you can use it today.
The Problem
Most OTP apps store the shared secret in plain text (or with weak protection). If someone extracts the app's data, all accounts enrolled in that authenticator are compromised. I wanted a library that forces the secret to be encrypted at rest and only decrypted on‑demand, using modern public‑key cryptography.
The constraints I set for myself:
- The user generates an age keypair offline (public key for provisioning, private key for decryption).
- The server never sees the private key — it only encrypts a fresh OTP secret to the user's public key.
- The user's client library can decrypt the ciphertext and generate TOTP/HOTP codes, all offline.
- No hidden network calls, no default configurations, no backdoors.
Crate Architecture
The workspace is split into four focused crates, plus a root re‑export crate:
| Crate | Purpose |
|---|---|
libage_auth_handler |
Traits, error types, and validated wrapper types |
libage_crypto |
Age encryption / decryption via librage |
libage_otp |
Pure TOTP (RFC 6238) and HOTP (RFC 4226) engine |
libage_authenticator |
Combines crypto + OTP into a high‑level API |
age_auth |
Re‑exports everything as a single dependency |
Each crate can be used independently, so you could just use libage_otp for a non‑encrypted OTP generator, or combine them to build a fully encrypted authenticator.
Strong Typing and Safety First
Every cryptographic parameter is wrapped in a validated newtype from libage_auth_handler. For example:
// Age public key – validated at construction
let recipient = Recipient::new("age1...").unwrap();
// Age secret key – never serialized, validated format
let identity = Identity::new("AGE-SECRET-KEY-...").unwrap();
// Secret data – automatically zeroized on drop
let secret = Secret::new(b"my secret".to_vec());
This eliminates entire classes of bugs — you can't accidentally swap a public key with a private key, or use an invalid Base32 string, because the types enforce it at compile time.
All sensitive containers (Secret, EncryptedPayload) use the zeroize crate. When they go out of scope, the memory is scrubbed.
Errors are unified under a single AuthError enum (backed by thiserror), and there's not a single unwrap() or expect() in production code — all failures propagate as Result<T, AuthError>.
The Offline Flow
Here's a minimal example of the complete workflow:
use age_auth::{AgeAuthenticator, generate_keypair, traits::Authenticator, types::Secret};
use std::io::Cursor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Create the authenticator
let auth = AgeAuthenticator::new();
// 2. Generate a keypair (offline)
let (recipient, identity) = generate_keypair()?;
// 3. Provision a Base32 OTP secret
let secret = Secret::new(b"JBSWY3DPEHPK3PXP".to_vec());
let encrypted = auth.provision(&recipient, &secret)?;
// 4. Later, when the user needs a code:
let mut identity_reader = Cursor::new(identity.as_str().as_bytes());
let totp_code = auth.generate_totp_from_encrypted(&mut identity_reader, &encrypted)?;
println!("Your one‑time code is: {}", totp_code);
Ok(())
}
The secret is never stored in plain text on disk — you keep the encrypted payload anywhere, and only when you present the private key do you get the TOTP code.
Multiple recipients are also supported (provision_multiple), so you can encrypt a secret for several devices at once.
OTP Engine – RFC Compliance
The libage_otp crate implements HOTP and TOTP from scratch, with full test vectors from RFC 4226 and RFC 6238. It supports SHA‑1, SHA‑256, and SHA‑512, with SHA‑256 as the default (because SHA‑1 is legacy). The dynamic truncation is done exactly as specified.
Benchmarks show that a single HOTP generation takes ~442 ns (SHA‑1) to ~1.2 µs (SHA‑512) on a modern CPU, so performance is more than adequate for interactive use.
Development & CI
The project uses a workspace with a custom version management script (scripts/bump_version.sh) that keeps all crates at the same version. GitHub Actions runs formatting, clippy, and tests on every push, and a separate workflow automatically publishes all crates to crates.io when a new tag is pushed.
What's Next?
-
otpauth://URL generation so you can scan a QR code directly. - Streaming encryption/decryption for very large secrets.
- Fuzz testing to harden the parser and crypto boundaries.
- Better documentation on docs.rs.
I'm actively developing this library and welcome feedback, contributions, and real‑world use cases. You can find the code at github.com/mroczect/age-auth and the crates on crates.io.
Do you think an offline‑first authenticator approach like this could improve security in your workflows? Let me know in the comments!
Top comments (0)