A Hybrid Key Architecture for Autonomous Agent Credential Management
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).

The Three Operations
| Operation | Key Type | Threat Model | Storage |
|---|---|---|---|
| Vault encryption | Symmetric (256-bit) | Credential exposure at rest | Argon2id-derived key, optionally cached in Keychain |
| Onchain approval | P-256 asymmetric | Unauthorized permit approval and replay | Secure Enclave in runtime; software signer for tests |
| Ethereum signing | secp256k1 asymmetric | Unauthorized permit signing | File (.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.
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

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.
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.
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 String | Meaning |
|---|---|
| p256-secure-enclave-bridge | Hardware-backed, persists across restarts |
| p256-secure-enclave-bridge-session | Hardware-backed, rotates on restart |
| p256-local-bridge | Software 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.
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.
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
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

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.