DEV Community

mroczect
mroczect

Posted on

Introducing age-credentials: A Backend‑Agnostic Account Engine for the Age Encryption Era

Introducing age-credentials: A Backend‑Agnostic Account Engine for the Age Encryption Era

Managing user accounts and their cryptographic keys is one of the most critical parts of any secure application. Yet, many solutions either tie you to a specific storage backend, force you to use a command‑line tool, or leave the encryption details to you. age-credentials changes that.

age-credentials is a pure Rust library that provides a complete, backend‑agnostic account management engine built on the age encryption format. It handles everything from key generation and passphrase‑protected private key storage, to encrypting and decrypting data for specific accounts, all through a clean, trait‑based API. You bring your storage (files, databases, vaults, memory – anything that implements a simple trait), and the engine does the rest.

In this post, I’ll walk through why I built age-credentials, how it works, and how you can start using it today.


The Problem: Reinventing Account Management Every Time

Whether you're building a CLI tool that needs to store API keys securely, a collaborative editor that manages identities, or a service that provisions short‑lived credentials, you always end up solving the same problems:

  • Generate a strong cryptographic key pair.
  • Encrypt the private key with a user‑provided passphrase so it can be recovered later.
  • Associate a public identity (name, email, public key) with the account.
  • Provide an easy way to encrypt data for, and decrypt data from, a specific account.
  • Support import/export workflows.
  • Never let secret keys or plaintext leak into logs or core dumps.

Most solutions either hardcode a filesystem layout, assume a particular database, or leave the encryption entirely to you. age-credentials decouples the business logic (what you want to do) from the storage (how you do it).


Architecture: Traits, Not Assumptions

At the heart of the library lies the AccountBackend trait:

pub trait AccountBackend {
    fn save_identity(&mut self, identity: &Identity) -> Result<()>;
    fn load_identity(&self, fingerprint: &Fingerprint) -> Result<Option<Identity>>;
    fn delete_identity(&mut self, fingerprint: &Fingerprint) -> Result<()>;
    fn store_encrypted_private_key(&mut self, fingerprint: &Fingerprint, key: &[u8]) -> Result<()>;
    fn load_encrypted_private_key(&self, fp: &Fingerprint) -> Result<Option<Zeroizing<Vec<u8>>>>;
    fn list_fingerprints(&self) -> Result<Vec<Fingerprint>>;
    fn find_by_email(&self, email: &str) -> Result<Option<Fingerprint>> {  }
}
Enter fullscreen mode Exit fullscreen mode

By implementing this trait, you decide where identities and encrypted private keys live. The library never touches a filesystem or a database directly. This makes it easy to integrate into any existing system – whether you're using SQLite, a cloud object store, or just a HashMap for testing.

The AccountEngine struct (stateless) then provides all the high‑level operations on top of any backend:

  • create_account – generates a key pair, encrypts the secret key with a passphrase, stores the identity and the encrypted blob.
  • encrypt_for_account – encrypts data to a user's public key.
  • decrypt_for_account – unlocks the private key with the passphrase and decrypts.
  • change_passphrase – re‑wraps the private key with a new passphrase.
  • export_account / import_account – transfer accounts as passphrase‑encrypted hex blobs.
  • delete_account, list_accounts, find_by_email.

All methods are synchronous, return Result<_, AccountError>, and never panic.


Strong Typing and Validation

Every domain object is validated at construction. For example:

let user = UserID::new("Alice", "alice@example.com")?;
Enter fullscreen mode Exit fullscreen mode

This validates that the name is between 2 and 255 characters, contains only allowed characters, and that the email has exactly one @ sign, non‑empty parts, and a valid length. You can't accidentally create an invalid user.

A Fingerprint is always a non‑empty hexadecimal string. If you try to create one from garbage, you get an AccountError::InvalidFingerprint immediately.


Security by Design

Several features make age-credentials safe by default:

  • Zeroize – Secret keys (Zeroizing<String>) and decrypted plaintext (Zeroizing<Vec<u8>>) are automatically wiped from memory when they go out of scope.
  • Passphrase minimum length – at least 8 characters, enforced at the engine level.
  • No panics – every fallible operation propagates a structured AccountError enum. There are no hidden unwrap() calls.
  • Delegation to librage – all cryptographic operations are performed by the well‑audited rage library.
  • Armored encryption – optionally wrap ciphertext in the age‑standard PEM armor, making it safe for transmission over text channels.

Quick Example: Creating an Account and Sending an Encrypted Message

use age_credentials::account::AccountEngine;
use age_credentials::backend::traits::AccountBackend;
use age_credentials::domain::types::UserID;

// Your custom backend (e.g., files, memory, database)
struct MyBackend { /* ... */ }
impl AccountBackend for MyBackend { /* ... */ }

let mut backend = MyBackend::new();

// Create an account for Alice
let alice = UserID::new("Alice Example", "alice@example.com")?;
let account = AccountEngine::create_account(
    &mut backend,
    alice,
    "strong‑passphrase",
    None, // optional label
)?;

// Encrypt a message to Alice
let ciphertext = AccountEngine::encrypt_for_account(
    &backend,
    &account.fingerprint,
    b"Hello, Alice!",
)?;

// Alice decrypts it later with her passphrase
let plaintext = AccountEngine::decrypt_for_account(
    &backend,
    &account.fingerprint,
    "strong‑passphrase",
    &ciphertext,
)?;

assert_eq!(*plaintext, b"Hello, Alice!");
Enter fullscreen mode Exit fullscreen mode

No files were harmed in this example. Replace MyBackend with your production storage, and you have a fully working account system.


Export and Import

Need to move an account between devices? The engine supports an export format that bundles the identity and encrypted private key into a single passphrase‑protected blob:

let exported = AccountEngine::export_account(
    &backend,
    &account.fingerprint,
    "original‑passphrase",
)?;
// `exported` is a hex‑encoded string

// On another device, with a different backend:
let imported_identity = AccountEngine::import_account(
    &mut backend2,
    &exported,
    "original‑passphrase",
)?;
Enter fullscreen mode Exit fullscreen mode

No plaintext private key ever leaves the library.


Who Is This For?

age-credentials is designed for Rust developers who need an embeddable, secure account system without reinventing the wheel. Some use cases:

  • CLI tools that store per‑user secrets (API tokens, SSH keys).
  • Decentralised applications where users manage their own identities.
  • Collaborative editors that need to encrypt documents for specific participants.
  • Provisioning systems that generate one‑off credentials and encrypt them for a recipient.
  • Games that save per‑player encrypted profiles.

Because the backend is abstract, you can even use it to prototype entirely in memory before committing to a persistent storage design.


What's Next?

The library is fully functional and ready for production use. Some enhancements on the horizon:

  • A reference filesystem backend implementation.
  • Support for custom account metadata.
  • Account status (active, suspended, revoked).
  • Integration with version‑controlled storage via libvctrl for an auditable account history.

Contributions are very welcome! The code is MIT licensed and available on GitHub:

github.com/mroczect/age-credentials


If you're tired of re‑implementing account management for every project, give age-credentials a try. It takes care of the hard parts so you can focus on building your application. I'd love to hear your feedback and use cases in the comments!

Top comments (0)