neuxdotdev/age-vault
Folders and files
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Repository files navigation
# AGE-VAULT TECHNICAL WIKI
## HOME / OVERVIEW
Age Vault is a Rust library (crate) that provides a secure, file-based vault for
managing identities and encrypting data using the age encryption format. It
wraps lower-level age-crypto, age-setup, and neuxdb crates to deliver a
batteries-included solution for applications needing encrypted account
management.
Key features:
- File-based vault: single database file, encrypted with a master password.
- Account management: create, list, remove accounts with unique names and roles.
- Age keypairs: each account owns an age public/secret keypair generated on
creation. The secret key is encrypted with a Key Encryption Key (KEK) derived
from the master password and a random salt.
- Multi-recipient encryption: encrypt arbitrary data for one or more accounts
using age's native multi-recipient support.
- Decryption: decrypt data using an account's secret key, which is
transparently decrypted by the vault's KEK.
- Strong cryptographic primitives: Argon2id for key derivation, age for
authenticated encryption, secret zeroization for sensitive material.
- Error handling: unified error type covering I/O, database, cryptographic,
serialization, and vault-specific failures.
The crate is intended for developers who need to embed a secure,
identity-oriented encryption layer in their Rust applications.
Quick Start:
Command: cargo add age-vault
Then in your code:
Code: Rust
use age_vault::{Vault, Role};
use std::path::PathBuf;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let vault_path = PathBuf::from("my_vault.age");
let master_password = "strong-master-password";
// Create a new vault
let mut vault = Vault::create(&vault_path, master_password)?;
// Add accounts
let alice = vault.add_account("alice", Role::Admin)?;
let bob = vault.add_account("bob", Role::User)?;
// Encrypt a message for multiple recipients
let ciphertext = vault.encrypt_for(
&["alice", "bob"],
b"confidential data"
)?;
// Decrypt using Alice's identity
let plaintext = vault.decrypt_with("alice", &ciphertext)?;
assert_eq!(plaintext, b"confidential data");
Ok(())
}
# API REFERENCE
This section documents all public types, functions, and methods exposed by the
crate. Everything is re-exported from the crate root for convenience.
## Module: age_vault (crate root)
Public re-exports:
* age_vault::Vault (vault::Vault)
* age_vault::Account (account::Account)
* age_vault::Role (account::Role)
* age_vault::Error (error::Error)
* age_vault::Result (error::Result)
## Type: Role (enum)
Defined in account.rs. Represents the authorization role assigned to an
account.
Variants:
* Admin - Full privileges (default administrative role).
* User - Standard user.
* Custom(String) - Arbitrary role defined by a string.
What: A simple tagged enum to categorize accounts. Currently the vault itself
does not enforce role-based access control (RBAC) on operations; this is left
to the consuming application. However, the enum supports serialization so roles
can be persisted and used in application-level authorization.
How: Construct directly:
Code: Rust
let admin = Role::Admin;
let user = Role::User;
let manager = Role::Custom("manager".into());
Why: Providing built-in roles and a custom variant gives flexibility for future
extensions without breaking the data model.
## Type: Account (struct)
Defined in account.rs. Represents a named identity with an age keypair stored
securely in the vault.
Fields (all public):
Name Type Description
---- ---- -----------
id String Unique identifier (UUID v4).
name String Human-readable account name, must be unique
within a single vault.
role Role Role assigned to the account.
public_key String Age public key (e.g., "age1...") for
encryption.
encrypted_secret_key Vec<u8> Age secret key encrypted with the vault's
Key Encryption Key (KEK). Never stored in
plaintext.
enabled bool Whether the account is active. Disabled
accounts are still stored but may be ignored
by application logic.
What: An Account is the core identity object. It encapsulates everything needed
to encrypt data for this identity (via the public key) and, after KEK
decryption, decrypt data intended for it.
How: Usually created via Vault::add_account. Manual construction is possible
but requires generating a proper age keypair and encrypting the secret key
with the correct KEK.
Why: Separating the public key (plaintext) and encrypted secret key allows the
vault to list accounts and encrypt for them without exposing the master
password or KEK after opening. The secret key is only decrypted when needed for
a decryption operation, minimizing exposure time.
## Type: Vault (struct)
The primary entry point. A Vault instance holds a connection to an encrypted
database (neuxdb) and the derived Key Encryption Key (KEK) in memory.
Fields (internal):
_ db: neuxdb::Database - Embedded database file, encrypted with master pw.
_ kek: secrecy::SecretString - Key Encryption Key derived from master
password and salt. Zeroized on drop. \* salt: Vec<u8> - Random 16-byte salt for KEK derivation, also stored in DB.
Methods:
Method: Vault::create
```
Signature:
pub fn create(
db_path: impl Into<PathBuf>,
master_password: &str
) -> Result<Self>
What: Creates a new vault file at db_path. The database is initialized with two
tables (accounts, _metadata), a random 16-byte salt is generated and stored in
_metadata, and the KEK is derived via Argon2id(password, salt). The accounts
table is created empty.
Parameters:
Name Type Description
---- ---- -----------
db_path PathBuf Path to the database file to create.
master_password &str Master password; must be strong.
Used to encrypt the database and derive KEK.
Returns: A Vault instance ready for account operations.
Errors: Error::Db (database creation), Error::Kdf (key derivation failure),
Error::Io.
Example:
Code: Rust
use age_vault::Vault;
let vault = Vault::create("./vault.db", "secure_password")?;
Why: The design deliberately uses a random salt per vault so that even if the
same master password is reused across vaults, the derived KEK is different.
The KEK is used to protect account secret keys, while the database encryption
is handled by neuxdb's own password-based encryption (which may also use the
same master password). This provides defense-in-depth: an attacker who
compromises the database layer would still need the KEK (or master password) to
decrypt individual secret keys.
Method: Vault::open
~~~~~~~~~~~~~~~~~~~
Signature:
pub fn open(
db_path: impl Into<PathBuf>,
master_password: &str
) -> Result<Self>
What: Opens an existing vault file. Reads the salt from the _metadata table,
re-derives the KEK from the master password and salt, and prepares the accounts
table for use.
Parameters:
Name Type Description
---- ---- -----------
db_path PathBuf Path to existing vault database file.
master_password &str Master password (must match creation password).
Returns: Vault instance if password and salt are correct.
Errors: Error::Db (database open failure), Error::DecryptionFailed (salt not
found or base64 decode failure), Error::Kdf (key derivation failure),
Error::InvalidMasterPassword (indirectly, when later operations fail).
Example:
Code: Rust
use age_vault::Vault;
let vault = Vault::open("./vault.db", "secure_password")?;
Why: The KEK is re-derived every time the vault is opened. No stored hash of
the master password is compared; correctness is validated implicitly when
decrypting account secret keys later. If the password is wrong, decryption of
any secret key will fail with Error::DecryptionFailed.
Method: Vault::add_account
```
Signature:
pub fn add_account(
&mut self,
name: &str,
role: Role
) -> Result<Account>
What: Generates a new age keypair, encrypts the secret key with the vault's
KEK, and stores the resulting Account in the database. Returns the newly
created account.
Parameters:
Name Type Description
---- ---- -----------
name &str Desired account name. Must be unique within the vault.
role Role Role to assign (e.g., Role::Admin, Role::User).
Returns: The created Account (with public_key, etc.).
Errors: Error::AccountExists if name is already in use. Error::KeyGen if age
keypair generation fails. Error::Crypto if secret key encryption fails.
Error::Db or serialization errors from store.
Example:
Code: Rust
let alice = vault.add_account("alice", Role::Admin)?;
Why: The secret key is encrypted immediately with the KEK and never stored in
plaintext. The KEK is held only in memory as a SecretString and is zeroized
on vault drop. This reduces the risk of secret key exposure.
Method: Vault::remove_account
```
Signature:
pub fn remove_account(&mut self, name: &str) -> Result<()>
What: Deletes the account identified by name from the database.
Parameters:
Name Type Description
---- ---- -----------
name &str Name of the account to remove.
Returns: Ok(()) on success.
Errors: Error::AccountNotFound if name does not exist. Error::Db on deletion or
commit failure.
Example:
Code: Rust
vault.remove_account("bob")?;
Why: The method uses the account's unique ID (derived from the name lookup)
to delete the database row, ensuring that even if multiple accounts had the same
name (which is prevented by uniqueness), the correct one is removed.
Method: Vault::list_accounts
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn list_accounts(&self) -> Result<Vec<Account>>
What: Returns a vector of all accounts stored in the vault. The returned
accounts contain encrypted secret keys (still encrypted with KEK) and public
keys.
Parameters: None.
Returns: Vec<Account> of all accounts.
Errors: Error::Db, Error::Serde (deserialization), Error::DecryptionFailed
(base64 decoding issues).
Example:
Code: Rust
for acc in vault.list_accounts()? {
println!("{}: {}", acc.name, acc.public_key);
}
Why: The list is useful for displaying available recipients to a user. Note
that the encrypted secret keys are included in the returned structs; they
cannot be decrypted without the KEK, which is not exposed via this API.
Method: Vault::encrypt_for
~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn encrypt_for(
&self,
account_names: &[&str],
plaintext: &[u8]
) -> Result<Vec<u8>>
What: Encrypts arbitrary bytes for one or more accounts using age's
multi-recipient encryption. Each named account's public key is fetched and used
as a recipient.
Parameters:
Name Type Description
---- ---- -----------
account_names &[&str] Slice of account names that will be able
to decrypt.
plaintext &[u8] Data to encrypt.
Returns: Ciphertext as Vec<u8>.
Errors: Error::AccountNotFound if any name does not exist. Error::Crypto if
age encryption fails.
Example:
Code: Rust
let cipher = vault.encrypt_for(
&["alice", "bob"],
b"top secret"
)?;
Why: This abstracts away the complexity of mapping human-readable names to
public keys and calling age's multi-recipient encrypt. It ensures that the
ciphertext header contains all necessary recipient information.
Method: Vault::decrypt_with
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn decrypt_with(
&self,
account_name: &str,
ciphertext: &[u8]
) -> Result<Vec<u8>>
What: Decrypts ciphertext that was encrypted for the named account. The
account's encrypted secret key is first decrypted using the KEK, then the
resulting age identity is used to decrypt the ciphertext.
Parameters:
Name Type Description
---- ---- -----------
account_name &str Name of the account whose secret key will be used.
ciphertext &[u8] Ciphertext produced by encrypt_for or any
age-compatible tool.
Returns: Plaintext as Vec<u8>.
Errors: Error::AccountNotFound, Error::DecryptionFailed (if secret key
decryption fails due to wrong password/corruption, or age decryption fails).
Example:
Code: Rust
let plain = vault.decrypt_with("alice", &ciphertext)?;
Why: The KEK is only used temporarily to decrypt the secret key; the plain
secret key is then immediately used for decryption and dropped. This minimizes
the window during which the secret key is in plaintext memory.
Module: crypto (public functions)
---------------------------------
The crypto module provides lower-level key derivation and key-wrapping
utilities. They are public for advanced usage but are typically used internally
by Vault.
Function: derive_kek
~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn derive_kek(
master_password: &str,
salt_bytes: &[u8]
) -> Result<SecretString>
What: Derives a Key Encryption Key (KEK) from a master password and salt using
Argon2id with default parameters (memory, iterations as per argon2 crate
defaults). The output is a SecretString that zeroizes on drop.
Parameters:
Name Type Description
---- ---- -----------
master_password &str Master password.
salt_bytes &[u8] Salt (recommended 16 bytes).
Returns: SecretString containing the derived KEK.
Errors: Error::Kdf if salt encoding or Argon2 hashing fails.
Function: encrypt_secret_key
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn encrypt_secret_key(
secret_key: &str,
kek: &str
) -> Result<Vec<u8>>
What: Encrypts an age secret key (in its text representation) with a KEK using
age's passphrase encryption (age_crypto::encrypt_with_passphrase). The output
is a binary ciphertext that can be stored.
Parameters:
Name Type Description
---- ---- -----------
secret_key &str Plaintext age secret key (e.g., "AGE-SECRET-KEY-...").
kek &str Key encryption key (derived from derive_kek).
Returns: Encrypted bytes.
Errors: Error::Crypto if age encryption fails.
Function: decrypt_secret_key
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature:
pub fn decrypt_secret_key(
encrypted: &[u8],
kek: &str
) -> Result<String>
What: Reverse of encrypt_secret_key. Decrypts using the KEK and returns the
UTF-8 secret key string.
Parameters:
Name Type Description
---- ---- -----------
encrypted &[u8] Encrypted secret key bytes.
kek &str Key encryption key.
Returns: Decrypted age secret key string.
Errors: Error::Crypto if decryption fails (wrong KEK, corruption);
Error::DecryptionFailed if output is not valid UTF-8.
Module: store (public functions)
--------------------------------
The store module provides direct access to the underlying neuxdb database
operations. These are public but intended primarily for internal use by Vault.
Function: init_accounts_table
```
Signature: pub fn init_accounts_table(db: &mut Database) -> Result<()>
Creates the "accounts" table with columns (id, name, role, public_key,
encrypted_secret_key, enabled) if it doesn't exist. Idempotent.
Function: init_metadata_table
```
Signature: pub fn init_metadata_table(db: &mut Database) -> Result<()>
Creates the "_metadata" key-value table. Idempotent.
Function: save_account
~~~~~~~~~~~~~~~~~~~~~~
Signature: pub fn save_account(db: &mut Database, account: &Account) -> Result<()>
Serializes an account and inserts it into the accounts table. Role is stored as
JSON, encrypted secret key as base64.
Function: load_accounts
~~~~~~~~~~~~~~~~~~~~~~~
Signature: pub fn load_accounts(db: &Database) -> Result<Vec<Account>>
Reads all rows from accounts table, deserializing each.
Function: save_metadata_salt
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature: pub fn save_metadata_salt(db: &mut Database, salt: &[u8]) -> Result<()>
Stores the salt bytes (base64-encoded) under key "salt" in _metadata.
Function: load_metadata_salt
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Signature: pub fn load_metadata_salt(db: &Database) -> Result<Vec<u8>>
Retrieves and decodes the salt from _metadata.
Error Type: Error (enum)
-------------------------
Defined in error.rs. All operations return Result<T, Error>. The Error enum
provides both automatic conversion from dependency errors (via #[from]) and
explicit vault-specific variants.
Variants:
* Io(std::io::Error) - I/O error.
* Db(neuxdb::Error) - Database error.
* Config(neuxcfg::NeuxcfgError) - Configuration error.
* Crypto(age_crypto::Error) - Cryptographic error.
* KeyGen(age_setup::Error) - Key generation error.
* Kdf(String) - Argon2 key derivation error.
* Serde(serde_json::Error) - Serialization error.
* AccountNotFound(String) - Account name missing.
* AccountExists(String) - Account name already present.
* InvalidMasterPassword - Master password invalid.
* DecryptionFailed(String) - Decryption failure.
* EncryptionFailed(String) - Encryption failure.
The error type implements std::error::Error and Display via thiserror. It is
Send + Sync, making it suitable for use in async contexts.
Result type alias:
type Result<T> = std::result::Result<T, Error>;
ARCHITECTURE & INTERNALS
========================
This section explains the system design, data flow, and rationale behind key
decisions. The vault is a layered architecture where the Vault struct
orchestrates storage, cryptographic, and identity modules.
High-level component interaction diagram (text flow):
User Application
|
| calls Vault::create, open, add_account, encrypt_for, decrypt_with
|
[Vault] -- holds Database, KEK, salt
| |
| | uses crypto::derive_kek, encrypt_secret_key, decrypt_secret_key
| |
| +---> [crypto module] -- argon2, age_crypto (passphrase encrypt)
| |
| +---> [store module] -- neuxdb (embedded database)
|
+-------> [account] struct and Role enum
Data Flow:
1. Vault Creation:
- Generate 16 random bytes (salt).
- Derive KEK = Argon2id(password, salt).
- Create encrypted database file with the master password (neuxdb).
- Init tables (accounts, _metadata).
- Store salt in _metadata.
- Commit.
2. Adding an Account:
- Check for duplicate name.
- Generate age keypair (age_setup::build_keypair).
- Encrypt secret key: ciphertext = age_crypto::encrypt_with_passphrase(secret_key, KEK).
- Build Account struct (id=UUIDv4, name, role, public_key, encrypted_secret_key, enabled=true).
- Store via store::save_account (role -> JSON, enc_key -> base64, insert into neuxdb).
- Commit.
3. Opening a Vault:
- Open encrypted database with master password (neuxdb).
- Load salt from _metadata.
- Derive KEK = Argon2id(password, salt).
- Accounts table is available for queries.
4. Encrypting Data for Recipients:
- For each recipient name, fetch Account from DB (store::load_accounts, filter).
- Collect public keys.
- age_crypto::encrypt(plaintext, &public_keys) -> ciphertext.
5. Decrypting Data with an Account:
- Fetch Account by name.
- Decrypt secret key: age_crypto::decrypt_with_passphrase(encrypted_secret_key, KEK).
- Use decrypted secret key to age_crypto::decrypt(ciphertext, &secret_key) -> plaintext.
Design Decisions:
* Defense-in-depth encryption: The vault uses two encryption layers: the neuxdb
file is encrypted with the master password, and the account secret keys are
encrypted with a separate KEK derived from the same password plus a unique
salt. This means that even if the database encryption is bypassed (e.g., via
a vulnerability in neuxdb), the attacker still needs to derive the KEK to
recover the secret keys. The salt is stored in the _metadata table, so an
offline attacker would need both the database file and the master password
to access secret keys.
* Why Argon2id: Argon2id is the recommended password-hashing algorithm,
combining resistance to side-channel and GPU attacks. Default parameters are
used; future versions may expose tuning knobs for memory/iterations.
* Why base64 for encrypted secret keys and salt: Neuxdb stores text columns; to
safely store arbitrary binary (ciphertext), base64 encoding ensures
compatibility.
* Why a separate KEK: The master password is used for both the database
encryption (neuxdb's internal mechanism) and the KEK derivation. Separating
the KEK derivation with a salt means the key material used to protect the
most sensitive items (age secret keys) is not directly the same as the
database encryption key. This provides a security boundary even if the
database's encryption key is extracted at runtime.
* The crate re-exports important types for ergonomics: users don't need to
know the internal module structure.
GUIDES & TUTORIALS
==================
Basic Usage: Setting Up a Vault and Managing Accounts
Step 1: Create a new vault.
Code: Rust
use age_vault::Vault;
let mut vault = Vault::create("identities.age", "my_master_pw")?;
Step 2: Add accounts for your users or devices.
Code: Rust
use age_vault::Role;
let admin_acc = vault.add_account("admin", Role::Admin)?;
let user_acc = vault.add_account("laptop", Role::User)?;
Step 3: Encrypt a confidential document for both admin and laptop.
Code: Rust
let doc = b"Quarterly financial report";
let ciphertext = vault.encrypt_for(&["admin", "laptop"], doc)?;
Step 4: Later, decrypt the document using the admin identity.
Code: Rust
let recovered = vault.decrypt_with("admin", &ciphertext)?;
assert_eq!(recovered, doc);
Step 5: Remove an account when no longer needed.
Code: Rust
vault.remove_account("laptop")?;
Step 6: List all current accounts.
Code: Rust
for acc in vault.list_accounts()? {
println!("{} - {}", acc.name, acc.public_key);
}
Configuration Reference
-----------------------
All configuration is currently done via compile-time feature flags or
dependency versions. The argon2 parameters are fixed to the crate's defaults;
if stronger parameters are needed, you might need to fork or propose a feature
to expose settings.
The vault database is a single neuxdb file; its path is determined at creation
time. You may want to place it in a suitable application data directory.
Troubleshooting FAQ
-------------------
Q: I get "Invalid master password" when opening a vault.
A: The vault does not store a password hash; it will only report
InvalidMasterPassword indirectly. Most likely you will receive a
DecryptionFailed error when trying to decrypt a secret key. Double-check the
password and ensure the vault file is not corrupted.
Q: An account name already exists. How can I handle it?
A: The add_account method returns Error::AccountExists. You can either remove
the existing account first or choose a different name.
Q: Can I rename an account?
A: Not directly. You can remove it and re-add with a new name, but note that
the new account will have a different keypair, so previously encrypted data for
the old account will be inaccessible.
Q: How do I change the master password?
A: Currently not supported natively. You would need to create a new vault,
iterate over all accounts, decrypt each secret key with the old KEK, re-encrypt
with the new KEK, and save them in the new vault. Future versions may add a
password change function.
CONTRIBUTING & DEVELOPMENT
==========================
This section is for developers who want to modify the crate itself.
Setup Instructions
------------------
1. Clone the repository (see repository URL in Cargo.toml).
2. Ensure you have a recent Rust toolchain (edition 2024, Rust >= 1.70 likely).
3. Build: Command: cargo build
4. Run tests: Command: cargo test
5. Generate local documentation: Command: cargo doc --open
Coding Standards
----------------
* Follow standard Rust conventions (rustfmt, clippy).
* All public items must have documentation comments (enforced by
#![warn(missing_docs)] in lib.rs or Cargo.toml lints).
* Unsafe code is warned against; if absolutely necessary, document safety
invariants.
* Use thiserror for error types, secrecy for sensitive strings, and keep the
crate no_std-friendly (currently uses std, but might be considered).
* Lints: unsafe_code = "warn", missing_docs = "warn", unused = "warn",
deprecated = "warn" (see Cargo.toml).
Testing Strategy
----------------
* Unit tests are located in each module (e.g., vault.rs) but not shown here;
they likely use the test vault paths as seen in doc examples. The dev
dependency `criterion` suggests benchmarks exist under benches/.
* Integration tests may exist under tests/ (excluded by package metadata).
* Run all tests: cargo test --all-features
Pull Request Process
--------------------
* Fork the repository.
* Create a feature branch: git checkout -b feat/your-feature
* Ensure all tests pass: cargo test
* If adding public API, update documentation accordingly.
* Submit a PR with a clear description.
License
=======
This crate is licensed under the MIT license. See the LICENSE file in the
repository root.
Repository: https://github.com/neuxdotdev/age-vault
Documentation: https://docs.rs/age-vault
Authors: neuxdotdev <neuxdev1@gmail.com>