Skip to content

neuxdotdev/age-vault

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>

About

A secure vault for managing age-encrypted accounts and data.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages