# memseal

[![CI](https://github.com/samjanny/memseal/actions/workflows/ci.yml/badge.svg)](https://github.com/samjanny/memseal/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/memseal.svg)](https://crates.io/crates/memseal)
[![docs.rs](https://docs.rs/memseal/badge.svg)](https://docs.rs/memseal)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

A small password-based encrypted vault for named secrets, with authenticated encryption, bounded parsing, and explicit memory-hygiene trade-offs.

> **Status:** `memseal` is an experimental `0.x` crate and has **not** been independently audited.

> **Note:** This crate is not a wrapper around Linux `mseal(2)`. "memseal" refers to sealing secrets in an encrypted in-memory vault.

## Overview

`memseal` stores named secrets in an encrypted vault protected by a password.

It is designed for applications that need a small, self-contained encrypted vault that can be kept in memory, exported to bytes, or saved to disk.

It is **not** a replacement for OS keyrings, HSMs, cloud secret managers, or mature password managers.

## Quick Start

```rust
use memseal::Vault;

let mut vault = Vault::create(b"my-password-here").unwrap();

// Store secrets
vault.store("api_key", b"sk-secret-12345").unwrap();
vault.store("db_url", b"postgres://user:pass@host/db").unwrap();

// Export to bytes
let bytes = vault.export().unwrap();

// Reopen with the same password
let vault = Vault::open(b"my-password-here", &bytes).unwrap();
let api_key = vault.retrieve("api_key").unwrap();

assert_eq!(api_key, Some(b"sk-secret-12345".to_vec()));
```

## File Persistence

```rust
use memseal::{Vault, VaultError};
use std::path::Path;

fn main() -> Result<(), VaultError> {
    let mut vault = Vault::create(b"my-password-here")?;

    vault.store("api_key", b"sk-secret-12345")?;

    // Save to disk
    vault.save(Path::new("secrets.seal"))?;

    // Later: load and retrieve
    let vault = Vault::load(Path::new("secrets.seal"), b"my-password-here")?;
    let api_key = vault.retrieve("api_key")?;
    assert_eq!(api_key, Some(b"sk-secret-12345".to_vec()));

    Ok(())
}
```

## API

```rust
use memseal::{Vault, VaultError};
use std::path::Path;

fn main() -> Result<(), VaultError> {
    let password = b"my-password-here";

    // Create
    let mut vault = Vault::create(password)?;

    // Store, retrieve, remove
    vault.store("name", b"secret")?;
    let data = vault.retrieve("name")?;  // Option<Vec<u8>>
    assert_eq!(data, Some(b"secret".to_vec()));
    let existed = vault.remove("name")?; // bool
    assert!(existed);

    // Change password
    vault.change_password(password, b"new-password-here")?;

    // Export to bytes, then reopen with the new password
    let bytes = vault.export()?;
    let reopened = Vault::open(b"new-password-here", &bytes)?;
    assert_eq!(reopened.retrieve("name")?, None);

    // File I/O
    vault.save(Path::new("vault.seal"))?;
    let loaded = Vault::load(Path::new("vault.seal"), b"new-password-here")?;
    let _ = loaded;

    Ok(())
}
```

### Limits

| Item | Limit |
|------|-------|
| Password length | Minimum 8 bytes |
| Entry name | Maximum 255 bytes |
| Entry data | Maximum 64 MiB |
| Vault file | Maximum 256 MiB |
| Index entries | Maximum 1024 |

The 256 MiB file bound is enforced on both sides: `load()` refuses larger
files and `export()`/`save()` refuse to produce them. Encrypted entry
bytes are stored in the index JSON as number arrays (roughly 3.6 output
bytes per stored byte), so the practical bound on total stored plaintext
across all entries is about 70 MiB; `export()` fails cleanly when it is
exceeded and the vault stays usable. See `DESIGN.md` section 9.3.

## Handling Plaintext

`retrieve()` returns decrypted data as `Option<Vec<u8>>`.

This is convenient, but it means the caller owns the returned plaintext and is responsible for handling it carefully.

In particular, caller code should avoid:

- logging returned secrets;
- cloning or converting them unnecessarily;
- keeping plaintext alive longer than needed;
- assuming returned plaintext is protected by `mlock`.

Internal temporary plaintext and key material are zeroized where possible, but returned plaintext belongs to the caller.

If the caller wants drop-time zeroization, the returned `Vec<u8>` can be wrapped by the caller using `zeroize::Zeroizing`:

```rust
use memseal::{Vault, VaultError};
use zeroize::Zeroizing;

fn main() -> Result<(), VaultError> {
    let mut vault = Vault::create(b"my-password-here")?;
    vault.store("api_key", b"sk-secret-12345")?;

    if let Some(secret) = vault.retrieve("api_key")? {
        let secret = Zeroizing::new(secret);

        // Use secret here.
        assert_eq!(secret.as_slice(), b"sk-secret-12345");

        // This allocation will be zeroized when `secret` is dropped.
    }

    Ok(())
}
```

This only zeroizes that returned allocation on drop. It does not prevent accidental copies made by caller code or by the allocator/runtime.

## What memseal is

- A small embedded vault for named secrets.
- Password-based: vault keys are derived from a caller-provided password.
- Self-contained: vaults can be exported to bytes or saved to disk.
- Authenticated: encrypted data is protected against tampering.
- Explicit about its limitations and memory-hygiene trade-offs.

## What memseal is not

- Not independently audited.
- Not an OS keyring wrapper.
- Not a cloud secret manager.
- Not an HSM.
- Not a password manager.
- Not a general secure-memory allocator.
- Not related to Linux `mseal(2)`.

## Intended Use Cases

- **Embedded encrypted vaults** - Store named secrets in an application-managed encrypted vault.
- **Portable secret bundles** - Export/load a password-protected vault without relying on an OS credential store.
- **Credential caches** - Keep secrets encrypted at rest in memory and on disk, while accepting explicit caller-owned plaintext boundaries.
- **Application-managed secret storage** - Store small sets of API keys, tokens, or credentials where a lightweight Rust-native vault is appropriate.

## Threat Model

For the exact byte format, key derivation chain, nonce derivation, and AAD bindings that back these claims, see [DESIGN.md](DESIGN.md).

### Intended mitigations

| Threat | Mitigation |
|--------|------------|
| **Tampered vault data** | The vault header is authenticated as AAD for index decryption; the index JSON and every per-entry payload are encrypted and authenticated with XChaCha20-Poly1305. Bit flips in authenticated header fields, nonces, ciphertext, or Poly1305 tags are detected during `open()` or entry retrieval. |
| **Entry swap attacks** | Each entry's data and name ciphertexts share an AAD made of the entry's HMAC-derived key and its `data_counter`. Swapping either the encrypted data or the encrypted name across entries causes AEAD verification to fail. |
| **Entry name leakage in serialized vaults** | Entry names are not stored in plaintext. Index keys are derived with HMAC-SHA256. |
| **KDF parameter downgrade** | The vault header is authenticated as AAD, so tampering with persisted KDF parameters is detected. Header KDF fields are also bounded before they reach Argon2i: out-of-range values (for example, below-minimum memory cost or zero iterations) are rejected by `validate_header()` during `open()`, blocking forged headers that would make password guessing artificially cheap. |
| **Nonce reuse** | All XChaCha20 nonces are 24-byte values drawn from the OS CSPRNG and stored next to the ciphertext they protect: a fresh index nonce on every `export()`, fresh entry data/name nonces on every `store()`. Random generation also covers state forks: two vault instances opened from the same persisted bytes cannot repeat a nonce when both export, which counter-derived nonces would. Monotonic counters remain as authenticated state, are bound into entry AAD, and overflow at `u64::MAX` is a hard error. |
| **Key reuse across roles** | The Argon2i-derived master key is never used directly. HKDF-SHA256 derives two 32-byte subkeys with disjoint `info` strings: one for XChaCha20-Poly1305, one for HMAC-SHA256 entry-name hashing. The master key is zeroized as soon as both subkeys exist. |
| **Plaintext lifetime inside the library** | Internal temporary plaintext and key material are zeroized where possible, including error paths. |
| **Resource exhaustion from crafted files** | Vault file size, header length, KDF parameters, entry name length, entry data size, and the decoded index entry count are bounded before processing. `open()` rejects an index whose entry count exceeds the 1024 cap. |
| **Swap exposure of ciphertext buffers** | Internal ciphertext buffers in `SecureMemoryVault` are locked with `mlock` via `memsec` where supported. |

### Out of scope / limitations

| Threat | Reason |
|--------|--------|
| **Kernel-level or root attacker** | A privileged attacker can read process memory regardless of user-space protections. |
| **Debugger-based extraction** | A debugger attached to the process can read decrypted data while it is being processed or after it has been returned by `retrieve()`. |
| **Caller-owned plaintext leaks** | `retrieve()` returns `Vec<u8>`. The caller is responsible for avoiding logs, copies, long-lived plaintext, and unsafe conversions. |
| **Side-channel attacks** | `memseal` does not attempt to mitigate Spectre, cache timing, power analysis, or other side channels. |
| **Compromised dependencies** | The crate trusts its dependency chain, including `orion`, `memsec`, and `zeroize`. |
| **Denial of service** | memseal detects corruption and refuses to open tampered files, but it cannot recover from them. An attacker with write or delete access to the vault file can deny access until a clean copy is restored. Backups and replication are the integrator's responsibility. |
| **Full swap protection** | Only internal ciphertext buffers are locked. Internal keys, nonces, allocator metadata, returned plaintext, and caller-owned copies are outside that guarantee. |
| **Rollback protection** | memseal does not provide rollback protection. An attacker who can replace a vault file with an older valid copy can cause the application to load older data unless the application stores freshness/version information externally. |
| **Formal cryptographic assurance** | The crate has not been independently audited. The integration layer should be reviewed before high-risk use. |

## Architecture

```text
            Password (>= 8 bytes)
               |
           Argon2i
      128 MiB, 4 iterations
      random 16-byte salt
               |
          Master Key (32B)
               |
         HKDF-SHA256
         salt = KDF salt
          /         \
    enc_subkey    hmac_subkey
      (32B)         (32B)
        |              |
        |              +--> HMAC-SHA256 entry-name hashing
        |
        +--> XChaCha20-Poly1305 encryption

Per-entry encryption:
  nonce = random 24 bytes (OsRng), stored as the ciphertext prefix
  aad   = hex(HMAC-SHA256(hmac_subkey, plaintext_name)) || data_counter (u64 LE)
  ct    = XChaCha20-Poly1305(enc_subkey, nonce, plaintext, aad)

Index encryption:
  fresh random index nonce on every export, stored in the file
  vault header is authenticated as AAD
```

Note: `mlock` is applied only to internal ciphertext buffers inside `SecureMemoryVault`. It does not lock every secret-related allocation.

## Cryptographic Primitives

| Primitive | Implementation | Purpose |
|-----------|----------------|---------|
| Argon2i | `orion` | Password-based key derivation |
| HKDF-SHA256 | `orion` | Subkey derivation |
| XChaCha20-Poly1305 | `orion` | Authenticated encryption |
| HMAC-SHA256 | `orion` | Entry-name hashing |
| OsRng | `rand_core` | Random salt and nonce generation |
| `mlock` / `munlock` | `memsec` | Best-effort locking of internal ciphertext buffers |
| Zeroization | `zeroize` | Clearing internal temporary secrets where possible |

## Security Properties

- **Small public API.** The crate exposes `Vault` and `VaultError`; internal modules are private.
- **Unsafe code is isolated.** The crate uses `#![deny(unsafe_code)]` at the crate root. The memory-locking module explicitly allows unsafe code for `mlock`/`munlock` and `unsafe impl Send/Sync`.
- **Domain separation.** Subkey derivation uses distinct HKDF `info` labels for the encryption and HMAC keys.
- **Authenticated encryption.** Vault index data and entries are encrypted with AEAD.
- **Bounded parsing.** Untrusted vault data is checked against size and parameter bounds before processing.
- **Atomic file writes.** `save()` writes to a temporary file, fsyncs it, renames it, and uses `0600` permissions on Unix.
- **Best-effort memory hygiene.** Internal temporary key material and plaintext are zeroized where possible.
- **Partial swap protection.** Internal ciphertext buffers are locked with `mlock` where supported, but this does not cover every allocation.

## Comparison

`memseal` is not a replacement for lower-level memory-hygiene crates such as `zeroize`, `secrecy`, or `memsec`.

It is also not an OS credential-store wrapper like `keyring`.

`memseal` is useful when an application wants a small, portable, self-contained encrypted vault for named secrets that it can export, save, and load directly.

## Development

```bash
cargo build
cargo test
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
```

Run benchmarks with:

```bash
cargo bench --bench full_bench
```

## CI

GitHub Actions runs on every push and PR to `main`:

- `cargo check --all-targets`
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets -- -D warnings`
- `cargo test`
- `rustsec/audit-check` action

## MSRV

`memseal` currently targets the Rust stable toolchain and uses the Rust 2024 edition.

## Security

This crate has not been independently audited.

Please report security issues privately. See [SECURITY.md](SECURITY.md).

## License

MIT - see [LICENSE](LICENSE).
