All posts
RustSecurityEthereum

A Hybrid Key Architecture for Autonomous Agent Credential Management

Dhruv SharmaMar 9, 20267 min read

AI agents that move money on-chain have a problem nobody talks about cleanly: who holds the keys? Fishnet, a Rust-based AI agent transaction security proxy, necessarily holds signing keys between agents and blockchain. The central question becomes minimizing blast radius when secrets cannot be eliminated entirely.

The approach: use the right storage primitive for each key’s threat model, and compose them behind a clean trait abstraction.

Architecture Overview

Fishnet manages three distinct cryptographic identities with separate threat models: vault encryption (symmetric), onchain approval (P-256 asymmetric), and Ethereum signing (secp256k1 asymmetric).

Fishnet Architecture Overview
Fishnet Architecture Overview

The Three Operations

OperationKey TypeThreat ModelStorage
Vault encryptionSymmetric (256-bit)Credential exposure at restArgon2id-derived key, optionally cached in Keychain
Onchain approvalP-256 asymmetricUnauthorized permit approval and replaySecure Enclave in runtime; software signer for tests
Ethereum signingsecp256k1 asymmetricUnauthorized permit signingFile (.hex)

Layer 1: Vault Encryption (Argon2id + Keychain)

The credential vault stores encrypted API keys. The encryption key derives from user passwords via Argon2id, a memory-hard password KDF. Two unlock paths exist: password-based when cache is absent, and Keychain-protected when cache is present.

rust
const ARGON2_MEMORY_COST_KIB: u32 = 262_144; // 256 MB
const ARGON2_TIME_COST: u32 = 3;
const ARGON2_PARALLELISM: u32 = 1;
const DERIVED_KEY_LEN: usize = 32;

The 256 MB memory cost intentionally raises GPU cracking costs by pushing brute-force computation into memory bandwidth requirements. The resulting 32-byte key feeds into libsodium’s crypto_secretbox_easy for XSalsa20-Poly1305 authenticated encryption.

Vault Unlock Flow

Vault Unlock Flow
Vault Unlock Flow

The Keychain entry prefix (derived_hex:v1:) enables future migration paths. In-memory keys use mlock() for swap prevention and the zeroize crate for secure teardown.

rust
impl Drop for LockedSecretboxKey {
    fn drop(&mut self) {
        if self.locked {
            unsafe { libc::munlock(self.key.as_ptr().cast(), self.key.len()); }
        }
        self.key.zeroize();
    }
}

Key teardown overwrites bytes before allocator reuse, reducing post-use exposure but not protecting against live-memory capture or pre-Drop crashes. Keychain caching improves ergonomics but shifts security dependency to Keychain controls rather than Argon2 parameters.

Layer 2: Onchain Approval Key (P-256 + Secure Enclave)

When onchain.approval.enabled is configured, Fishnet adds P-256 second-signature requirements before emitting secp256k1 permit signatures. This hardware-backed approval gates whether signing occurs.

rust
pub trait BridgeApprovalSigner: Send + Sync {
    fn mode(&self) -> &str;
    fn public_key_hex(&self) -> &str;
    fn sign_prehash(&self, prehash: &[u8; 32]) -> Result<P256Signature, SignerError>;
}

Persistent Secure Enclave keys use kSecAccessControlPrivateKeyUsage for private-key operations, kSecAccessControlUserPresence for user presence required, and kSecAttrAccessibleWhenUnlockedThisDeviceOnly for device-bound, locked-device inaccessible. Non-exportability derives from Secure Enclave generation itself.

Graceful Degradation

Mode StringMeaning
p256-secure-enclave-bridgeHardware-backed, persists across restarts
p256-secure-enclave-bridge-sessionHardware-backed, rotates on restart
p256-local-bridgeSoftware signer in tests/dev, not automatic runtime fallback

No silent downgrades occur without mode labeling. Non-macOS runtime approval fails closed rather than automatically degrading.

Layer 3: Ethereum Signing Key (secp256k1 + file)

EIP-712 permit signing executes on every agent transaction. The secp256k1 key resides in hex files with 0600 permissions. This tradeoff prioritizes cross-platform portability — Linux agents lack Keychain access, and on-chain nonces provide final replay protection.

rust
pub fn try_from_bytes(secret_bytes: [u8; 32]) -> Result<Self, SignerError> {
    let signing_key = SigningKey::from_bytes((&secret_bytes).into())?;
    let verifying_key = signing_key.verifying_key();
    let public_key_bytes = verifying_key.to_encoded_point(false);
    let hash = Keccak256::digest(&public_key_bytes.as_bytes()[1..]);
    let mut address = [0u8; 20];
    address.copy_from_slice(&hash[12..]);
    Ok(Self { signing_key, address })
}

The uint48 Footgun

Permit schemas use uint48 expiry while Rust uses u64. Out-of-range values create invalid typed-data payloads where signatures no longer match contract expectations.

rust
const UINT48_MAX: u64 = (1u64 << 48) - 1;

if self.expiry > UINT48_MAX {
    return Err(SignerError::InvalidPermit(format!(
        "expiry {} exceeds uint48 max ({}), invalid for Solidity uint48",
        self.expiry, UINT48_MAX
    )));
}

Hard rejection occurs at input boundaries — not warnings or clamping — keeping Rust values within exact Solidity type domains.

Composing the Layers: BridgeSigner

rust
pub struct BridgeSigner {
    inner: Arc<dyn SignerTrait>,
    approval_signer: Arc<dyn BridgeApprovalSigner>,
    approval_ttl_seconds: u64,
    replay_cache: Mutex<HashMap<[u8; 32], u64>>,
}

Approval Signing Flow

Approval Signing Flow
Approval Signing Flow

Step 7 (sign-then-verify) catches key corruption before invalid proofs propagate. Step 8’s rollback prevents failed secp256k1 signing from leaving consumed replay cache entries that would block retries.

What This Architecture Gets Right

Blast radius containment: Each key serves one function. Secp256k1 compromise enables Ethereum transaction signing without vault access; vault compromise exposes API keys without on-chain capabilities. The approval key requires independent compromise and stays hardware-isolated in Secure Enclave mode.

Hardware backing where needed: The approval key targets “sign this transaction” attacks. Secure Enclave mode keeps private keys non-exportable and isolated from process memory.

Graceful degradation without silent failure: When persistent Secure Enclave storage becomes unavailable, mode strings surface to callers — no silent downgrades to session-only modes or automatic software fallbacks.

Versioned storage formats: Keychain prefixes (derived_hex:v1:), replay cache keys (fishnet-bridge-replay-v1|), and intent hash prefixes (fishnet-bridge-approval-v1|) include version identifiers enabling future format migrations without parsing ambiguity.

Boundary validation: Rust’s u64 values face rejection before signing when exceeding Solidity’s uint48 maximums.

What I’d Do Differently

The secp256k1 hex file remains the weakest link. Production deployments should move to HSM, KMS, or OS-managed key stores appropriate to deployment targets. Portability motivated the hex file choice, but this represents acknowledged architectural debt.

The replay cache exists in-memory only. Process restarts clear it, allowing permit replay across restart boundaries. While on-chain nonces provide final protection in Fishnet’s current use case, persistent replay stores would enhance robustness.

The goal is always to minimize what any single compromise can reach. When you can’t give your control plane zero secrets, the next best thing is ensuring each secret only unlocks one blast radius.