AI agents that move money on-chain have a problem nobody talks about cleanly: who holds the keys?
That's the problem I ran into building Fishnet, an AI agent transaction security proxy in Rust. Fishnet sits between the AI agent and the chain — a control plane that necessarily holds signing keys. You can't give it zero secrets. So the question becomes: how do you minimize blast radius when secrets are unavoidable?
The naive answer is to pick one storage primitive and use it for everything. That breaks down immediately when your system has multiple cryptographic operations with different security requirements. Keychain is good for secret storage but not the same thing as hardware-backed signing. In this flow, Secure Enclave gives me P-256, while Ethereum signing requires secp256k1. File storage is portable, but it mostly relies on filesystem permissions rather than hardware isolation.
The answer I landed on: use the right storage primitive for each key's threat model, and compose them behind a clean trait abstraction.
The Architecture at a Glance
Fishnet sits between the AI agent and the chain. Every transaction goes through it. That means Fishnet holds three distinct cryptographic identities — each with a completely different threat model.
Three keys. Three blast radii. Vault compromise does not imply signing access. Signing compromise does not imply credential access. The approval key is hardware-backed when Secure Enclave mode is active.
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 type exists for tests and explicit construction paths |
| Ethereum signing | secp256k1 asymmetric | Unauthorized permit signing | File (.hex) |
Layer 1: Vault Encryption (Argon2id + Keychain)
The credential vault stores API keys encrypted at rest. Its encryption key is derived from a user password using Argon2id, a widely recommended memory-hard password KDF. Fishnet can also cache that derived 32-byte key in macOS Keychain when the operator opts in, so the security story has two paths: password-based unlock when the cache is absent, and Keychain-protected unlock when the 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 is intentional. When the password-based unlock path is used, it pushes brute-force cost into memory bandwidth as well as compute, which makes large-scale GPU cracking materially more expensive and less efficient. It does not make GPU attacks impossible; it raises their cost.
The resulting 32-byte key feeds directly into libsodium's crypto_secretbox_easy for XSalsa20-Poly1305 authenticated encryption. The cipher here is XSalsa20-Poly1305, not AES.
Vault Unlock Flow
The version prefix on the cached Keychain entry (derived_hex:v1:) provides a migration path. Future derivation formats can use v2:, v3:, and so on without breaking existing entries.
The in-memory key is pinned with mlock() where the OS allows it, to keep it out of swap, and zeroed on drop via the zeroize crate:
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();
}
}
On normal teardown, the key bytes are overwritten before the allocator can reuse that memory. That reduces post-use exposure in freed memory, but it does not protect against live-memory capture or a crash that happens before Drop runs.
Caching the derived key in Keychain is a conscious tradeoff: it improves operator ergonomics, but once that cache exists, the strength of that path depends more on Keychain access controls than on Argon2 parameters.
Layer 2: Onchain Approval Key (P-256 + Secure Enclave)
When onchain.approval.enabled is set, Fishnet adds a P-256 second signature requirement before it emits the secp256k1 permit signature. This is a hardware-backed approval proof layered in front of normal onchain permit signing.
That P-256 approval is enforced by Fishnet's control plane, not by the EVM itself. Its purpose is to gate whether the secp256k1 permit signature is ever emitted.
The type names still use BridgeSigner and BridgeApprovalSigner because the feature originated around bridge-style risk controls, but the current runtime wiring applies the approval layer to generic onchain permit issuance.
The BridgeApprovalSigner trait makes the approval layer pluggable. In the current macOS runtime, the signer is Secure Enclave-backed when the platform allows it. A software P-256 signer type also exists in the codebase for tests and explicit construction paths:
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>;
}
When the persistent Secure Enclave path is active, the key is created with:
-
kSecAccessControlPrivateKeyUsage— usable for private-key operations like signing -
kSecAccessControlUserPresence— user presence required -
kSecAttrAccessibleWhenUnlockedThisDeviceOnly— inaccessible while the device is locked and bound to that device
The non-exportability comes from Secure Enclave key generation itself; the ThisDeviceOnly accessibility class keeps the keychain item from migrating to another device.
The graceful degradation story is important. If persistent Secure Enclave storage is denied or unavailable, which is common in unsigned CLI or non-interactive contexts, Fishnet falls back to a session-only Secure Enclave key and surfaces the mode string to the caller:
| 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 type present in tests/dev code, not the automatic runtime fallback on this branch |
It never silently downgrades from persistent to session-only Secure Enclave storage without labeling the mode. On the current branch, non-macOS runtime approval is fail-closed rather than an automatic software fallback.
Layer 3: Ethereum Signing Key (secp256k1 + file)
EIP-712 permit signing happens on every agent transaction. The secp256k1 key lives in a hex file with 0600 permissions. The tradeoff is portability: Linux agents do not have macOS Keychain, and the on-chain nonce provides the final replay backstop.
The address derivation follows the Ethereum spec exactly:
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); // uncompressed (65 bytes)
let hash = Keccak256::digest(&public_key_bytes.as_bytes()[1..]); // drop 0x04 prefix
let mut address = [0u8; 20];
address.copy_from_slice(&hash[12..]); // last 20 bytes
Ok(Self { signing_key, address })
}
The uint48 footgun
The permit schema uses a uint48 expiry field, while Rust stores it as u64. If the Rust side accepts values above 2^48 - 1, the request is now outside the Solidity type's valid domain. Depending on the encoder or verifier, that can show up as rejected inputs, invalid typed-data payloads, or signatures that no longer match what the contract expects to hash.
Fishnet validates this at the boundary before any signature runs:
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 at input. Not a warning. Not a clamp. A rejection that keeps off-chain inputs inside the exact range the Solidity side accepts.
Composing the Layers: BridgeSigner
The three layers compose cleanly. BridgeSigner wraps any SignerTrait (the secp256k1 signer) with any BridgeApprovalSigner (P-256, software, or Secure Enclave). Despite the name, this wrapper currently sits in the generic onchain permit path:
pub struct BridgeSigner {
inner: Arc<dyn SignerTrait>, // secp256k1 layer
approval_signer: Arc<dyn BridgeApprovalSigner>, // P-256 layer
approval_ttl_seconds: u64,
replay_cache: Mutex<HashMap<[u8; 32], u64>>, // keyed by derived replay hash over stable permit fields
}
Approval Signing Flow
Step 7 (sign, then verify) catches key corruption immediately rather than producing an invalid proof that propagates deeper into the system. Step 8's rollback ensures a failed secp256k1 signing does not leave a consumed replay cache entry behind that would block a retry.
Key Hierarchy Summary
┌──────────────────┬──────────────────────┬───────────────────────┐
│ Vault Layer │ Approval Layer │ Signing Layer │
├──────────────────┼──────────────────────┼───────────────────────┤
│ Argon2id │ P-256 (secp256r1) │ secp256k1 │
│ ↓ │ │ (k256 crate) │
│ XSalsa20-Poly │ │ │
├──────────────────┼──────────────────────┼───────────────────────┤
│ macOS Keychain │ Secure Enclave │ .hex file │
│ (cached key) │ (user presence) │ (0600 permissions) │
├──────────────────┼──────────────────────┼───────────────────────┤
│ + mlock() │ In enclave mode, │ Validated at input │
│ + zeroize on drop│ key stays on-chip │ (uint48, U256, addr) │
├──────────────────┼──────────────────────┼───────────────────────┤
│ Protects: │ Protects: │ Produces: │
│ API credentials │ Permit approvals │ EIP-712 permit sigs │
│ at rest │ from replay + abuse │ for on-chain actions │
└──────────────────┴──────────────────────┴───────────────────────┘
What This Architecture Gets Right
Blast radius containment. Each key has exactly one job. Compromising the secp256k1 key lets an attacker sign Ethereum transactions, but not decrypt vault credentials. Compromising the vault key exposes API keys, but doesn't enable on-chain actions. The approval key adds a second factor that must be compromised independently — and, in Secure Enclave mode, it never leaves hardware.
Hardware backing where it matters. The approval key is a likely target for a "sign this transaction" attack. When Secure Enclave mode is active, the private key is non-exportable and isolated from normal process memory.
Graceful degradation without silent failure. When persistent Secure Enclave storage is unavailable, Fishnet surfaces the mode string to callers. No silent downgrade to session-only mode, and no automatic runtime software fallback on unsupported platforms.
Versioned storage formats. The Keychain prefix (derived_hex:v1:), replay cache key (fishnet-bridge-replay-v1|), and intent hash prefix (fishnet-bridge-approval-v1|) all include version identifiers. The approval-related prefixes still carry bridge-flavored names for historical reasons, but the versioning itself is what matters. Future migrations can introduce new formats without ambiguous parsing or ad hoc compatibility logic.
Boundary validation. Rust uses u64, Solidity expects uint48, and Fishnet rejects out-of-range values before signing.
What I'd Do Differently
The secp256k1 key in a hex file is the weakest link. For production, this should move to an HSM, KMS, or another OS-managed key store appropriate to the deployment target. The hex file was chosen for portability, but that is still architectural debt worth acknowledging explicitly.
The replay cache is in-memory only. A process restart clears it, meaning a cached permit could be replayed across a restart boundary. For Fishnet's current use case, the on-chain nonce provides the final replay protection, but a persistent replay store would be more robust.
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.
How do you handle key management in systems where secrets are unavoidable?



Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.