Skip to main content

Credential Vault

The Credential Vault is Fishnet’s encrypted storage system for API keys and secrets. Your AI agent never sees the real credentials — only Fishnet can decrypt them.

How It Works

Master Password

On first launch, you set a master password. This password is used to derive an encryption key that protects all stored credentials.
# First time setup
fishnet
# Prompts: "Set master password:"
The master password is never stored anywhere. If you lose it, your credentials are unrecoverable.

Key Derivation

Fishnet uses Argon2id (the state-of-the-art key derivation function) to derive a 256-bit encryption key from your master password. From vault.rs:394-411:
fn derive_secretbox_key(
    master_password: &str,
    salt: &[u8; SALT_LEN],
) -> Result<MasterKey, VaultError> {
    let params = Params::new(
        ARGON2_MEMORY_COST_KIB,  // 256 MB
        ARGON2_TIME_COST,         // 3 iterations
        ARGON2_PARALLELISM,       // 1 thread
        Some(DERIVED_KEY_LEN),    // 32 bytes
    )?;
    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);

    let mut derived = Zeroizing::new(vec![0u8; DERIVED_KEY_LEN]);
    argon2.hash_password_into(
        master_password.as_bytes(), 
        salt, 
        &mut derived
    )?;
    MasterKey::from_derived_bytes(&derived)
}
Parameters:
  • Memory cost: 256 MB — Makes brute-force attacks expensive
  • Time cost: 3 iterations — Balances security and speed
  • Algorithm: Argon2id — Resistant to GPU/ASIC attacks

Encryption

Credentials are encrypted using XSalsa20-Poly1305 (libsodium’s crypto_secretbox) with:
  • 256-bit key derived from your master password
  • 192-bit nonce generated randomly per credential
  • 128-bit authentication tag to detect tampering
From vault.rs:318-336:
fn encrypt_with_key(
    key: &[u8; DERIVED_KEY_LEN],
    plaintext: &[u8],
) -> Result<(Vec<u8>, [u8; NONCE_LEN]), VaultError> {
    let nonce = Self::generate_nonce();
    let mut ciphertext = vec![0u8; plaintext.len() + SECRETBOX_MAC_BYTES];
    let rc = unsafe {
        sodium::crypto_secretbox_easy(
            ciphertext.as_mut_ptr(),
            plaintext.as_ptr(),
            plaintext.len() as libc::c_ulonglong,
            nonce.as_ptr(),
            key.as_ptr(),
        )
    };
    if rc != 0 {
        return Err(VaultError::EncryptionFailed);
    }
    Ok((ciphertext, nonce))
}

Decryption

When the proxy needs a credential, it:
  1. Retrieves the ciphertext and nonce from SQLite
  2. Decrypts using the master key
  3. Returns the plaintext API key (in memory only)
  4. The key is automatically zeroized when dropped
From vault.rs:532-569:
pub async fn decrypt_for_service(
    &self,
    service: &str,
) -> Result<Option<DecryptedCredential>, VaultError> {
    // ... retrieve from database ...
    let plaintext = Self::decrypt_with_key(
        key.key_array(), 
        &nonce_bytes, 
        &encrypted_key
    )?;
    let key_str = String::from_utf8(plaintext)
        .map_err(|_| VaultError::InvalidUtf8)?;

    Ok(Some(DecryptedCredential {
        id,
        key: Zeroizing::new(key_str),  // Auto-zeroed on drop
    }))
}
Decrypted keys are stored in Zeroizing<String>, which overwrites memory with zeros when the value is dropped, preventing sensitive data from lingering in RAM.

Database Schema

Credentials are stored in vault.db with this schema:
CREATE TABLE credentials (
    id TEXT PRIMARY KEY,
    service TEXT NOT NULL,           -- e.g., "openai", "binance"
    name TEXT NOT NULL,              -- e.g., "production", "backup"
    encrypted_key BLOB NOT NULL,     -- Ciphertext
    nonce BLOB NOT NULL,             -- 24-byte nonce
    created_at INTEGER NOT NULL,     -- Unix timestamp
    last_used_at INTEGER             -- Updated on each use
);

Vault Metadata

Two special metadata entries are stored:
CREATE TABLE vault_meta (
    key TEXT PRIMARY KEY,
    value BLOB NOT NULL
);
KeyPurpose
saltRandom 16-byte salt for Argon2id
canaryEncrypted test value to validate master password

Password Validation

The canary is a known plaintext ("fishnet-vault-canary-v1") encrypted with your derived key. When you unlock the vault:
  1. Fishnet decrypts the canary
  2. If decryption fails or plaintext doesn’t match, password is invalid
  3. If it matches, your password is correct and vault is unlocked
From vault.rs:414-446:
fn validate_or_create_canary(conn: &Connection, key: &MasterKey) -> Result<(), VaultError> {
    if let Some(blob) = existing {
        let nonce_bytes: [u8; NONCE_LEN] = blob[..NONCE_LEN].try_into()?;
        let ciphertext = &blob[NONCE_LEN..];
        let plaintext = Self::decrypt_with_key(
            key.key_array(), 
            &nonce_bytes, 
            ciphertext
        ).map_err(|_| VaultError::InvalidMasterPassword)?;
        
        if plaintext != CANARY_PLAINTEXT {
            return Err(VaultError::InvalidMasterPassword);
        }
    }
    Ok(())
}

Memory Protection

Fishnet uses mlock(2) to pin sensitive memory pages to RAM, preventing them from being swapped to disk. From vault.rs:77-88:
fn new(mut key: [u8; DERIVED_KEY_LEN], require_mlock: bool) -> Result<Self, VaultError> {
    let rc = unsafe { libc::mlock(key.as_ptr().cast(), key.len()) };
    if rc != 0 {
        let err = std::io::Error::last_os_error().to_string();
        if require_mlock {
            key.zeroize();
            return Err(VaultError::MlockFailed(err));
        }
        eprintln!("[fishnet] warning: mlock failed; continuing without memory lock: {err}");
    }
    Ok(Self { key, locked: true })
}
mlock requires elevated privileges on some systems. If it fails, Fishnet logs a warning but continues (you can set FISHNET_VAULT_REQUIRE_MLOCK=1 to make it mandatory).

Storage Location

The vault database is stored at:
  • Linux: /var/lib/fishnet/vault.db
  • macOS: /Library/Application Support/Fishnet/vault.db
  • Custom: Set FISHNET_DATA_DIR environment variable

Adding Credentials

Via Dashboard

  1. Navigate to Settings → Credentials
  2. Click Add Credential
  3. Enter:
    • Service (e.g., openai, anthropic, custom.github)
    • Name (e.g., production, backup)
    • API Key

Via API

From vault.rs:680-710:
curl -X POST http://localhost:8473/api/credentials \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "service": "openai",
    "name": "production",
    "key": "sk-..."
  }'
Response:
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "service": "openai",
  "name": "production",
  "created_at": 1700000000
}
The API response never includes the actual key. Once stored, the plaintext key cannot be retrieved — only used by the proxy.

Credential Lookup

When the proxy handles a request to /proxy/openai/..., it looks up the credential by service name: From vault.rs:532-569:
pub async fn decrypt_for_service(
    &self,
    service: &str,
) -> Result<Option<DecryptedCredential>, VaultError> {
    // Query: SELECT encrypted_key, nonce FROM credentials
    //        WHERE service = ?1 ORDER BY created_at DESC LIMIT 1
    // ...
}
Lookup priority:
  1. Most recently created credential for that service
  2. If multiple credentials exist, you can query by (service, name)

Security Guarantees

All credentials are encrypted with XSalsa20-Poly1305 before touching disk. The SQLite database contains only ciphertext.
  • Derived keys are locked with mlock(2) to prevent swapping
  • Decrypted credentials use Zeroizing<String> to overwrite memory on drop
  • Sensitive types like DecryptedCredential are !Copy, !Clone, !Debug, !Serialize
The master password is never stored. The SQLite database contains:
  • Encrypted credentials
  • Random nonces
  • Metadata (salt, canary)
Without the master password, the database is useless.
Each credential includes a Poly1305 MAC. Any modification to the ciphertext will cause decryption to fail.

Recovery

If you lose your master password, there is no recovery mechanism. This is by design. Best practices:
  • Store your master password in a password manager
  • Keep encrypted backups of vault.db (useless without the password)
  • Use environment variables for non-production keys during testing

Keychain Integration

Fishnet does not currently integrate with OS keychains (macOS Keychain, Windows Credential Manager, Linux Secret Service). This is a planned feature.
You can export the derived key hex to reuse across sessions:
let derived_hex = store.derived_key_hex();
// Store in a secure location, then reopen with:
let store = CredentialStore::open_with_derived_key(path, &hex::decode(derived_hex)?);

Performance

  • Vault unlock (Argon2id derivation): ~100-300ms (intentionally slow)
  • Credential retrieval: < 5ms (cached key, SQLite lookup)
  • Encryption/Decryption: < 1ms per credential

Next Steps

Spend Limits

Learn how Fishnet enforces daily budgets

Rate Limiting

See how request throttling protects against runaway agents

Build docs developers (and LLMs) love