Component
Cast
Describe the feature you would like
Using an encrypted keystore with cast today means either retyping the password on every command or leaking it through --password in argv, ETH_PASSWORD, --unsafe-password, or a plaintext password file (#2794 added file passwords for exactly this friction). On macOS, cast could offer the UX users already know from sudo with pam_tid, 1Password, and age-plugin-se: unlock a key with Touch ID.
Proposed UX:
$ cast wallet import deployer --interactive --touch-id
Enter private key: ****
[Touch ID prompt]
`deployer` keystore saved; unlock protected by Touch ID on this Mac.
$ cast send --account deployer 0x... "transfer(...)"
[Touch ID prompt]
✅ Sent transaction ...
Proposed design: Secure Enclave key-wrapping (the age-plugin-se pattern)
The obvious approaches do not work for a CLI, so it is worth being explicit about the design that does:
- On
--touch-id enrollment, generate a P-256 key inside the Secure Enclave with SecAccessControl(.privateKeyUsage | .userPresence) (or .biometryCurrentSet for strict mode).
- Use it to ECIES-wrap the keystore password (or the secp256k1 key itself), and store the wrapped blob plus the enclave key's encrypted
dataRepresentation as a sidecar next to the normal keystore JSON in ~/.foundry/keystores.
- At signing time, decryption of the blob requires the Secure Enclave to evaluate the access control — the Touch ID prompt is hardware-enforced by the SEP. The decrypted secret then unlocks the keystore in memory as usual.
Key properties, all verified against Apple docs (TN3137, Secure Enclave docs) and empirically with ad-hoc-signed Swift probes on an Apple Silicon Mac:
- Works for an unbundled, foundryup-distributed CLI. No entitlements, provisioning profile, app bundle, or Developer ID signing needed — only the ad-hoc linker signature every Apple Silicon binary already has.
age-plugin-se ships exactly this via Homebrew.
- No Keychain items at all, which deliberately sidesteps two traps: (a) biometry-protected keychain items (
kSecAttrAccessControl) require the data-protection keychain, which requires provisioning-profile-backed entitlements in an app-like bundle — an ad-hoc CLI gets errSecMissingEntitlement (-34018); (b) file-based keychain "Always Allow" ACLs bind to the binary's code signature, so every foundryup upgrade of an unsigned binary would re-prompt (aws-vault code-signs its releases solely to avoid this; gh execs /usr/bin/security instead, which lets any local process read the item).
- The keystore JSON stays canonical. Touch ID is a convenience unlock, not custody: the encrypted keystore remains the portable, recoverable artifact, so losing the Mac, wiping biometrics, or moving machines degrades gracefully to the password path. Security is not reduced for existing users; nothing changes unless you opt in.
Constraints (inherent to the platform, worth recording as non-goals)
- Signing can never happen inside the Secure Enclave. The SE supports only NIST P-256 and cannot import pre-existing keys (Apple-documented), so the secp256k1 key is always reconstructed in process memory to sign — same exposure window as today's keystore decrypt. (SE-resident signing is only usable on-chain via contract-verified P-256 flows — EIP-7951 is live on L1 since Fusaka, RIP-7212 on major L2s — i.e. smart accounts / EIP-7702, out of scope here.)
- iCloud Keychain sync is off the table. Per-item biometry ACLs and
kSecAttrSynchronizable are mutually exclusive (SecItemAdd returns errSecParam when combined), sync requires the entitlement-gated data-protection keychain anyway, and SE blobs are device-bound by design.
- GUI login session only. Touch ID prompts cannot be raised over SSH, in headless CI, or from daemons; the flow must detect this and fall back to the password prompt with a clear message.
- With
.biometryCurrentSet, re-enrolling fingerprints permanently invalidates the wrap key — another reason the keystore JSON stays the source of truth.
Additional context
Implementation home. Signer/credential resolution lives in foundry-wallets (foundry-core), not this repo. The natural seam is the keystore-password resolution in create_keystore_signer / PendingSigner::unlock (currently: --password > --password-file > interactive rpassword prompt) — a Touch ID unwrap slots in before the interactive prompt, keyed by the keystore address already carried in PendingSigner::Keystore. That makes the change a password source, far smaller than a new WalletSigner backend, and every consumer (cast send/mktx/call/estimate, forge create, forge script broadcast and cheatcode wallets) benefits with no per-command changes. The cast side is enrollment UX on cast wallet import/new, plus list/remove awareness. The Swift shim is unavoidable but tiny: the SE key's dataRepresentation is a CryptoKit-only API with no C surface, so a small compiled-in Swift helper (or swift-rs) is needed, feature-gated and target_os = "macos"-only, following the existing hidapi/aws-kms optional-dependency patterns.
Naming. cast keychain is taken by Tempo's on-chain AccountKeychain, and "keyring" collides with GCP KMS "key ring" terminology in existing PRs — hence --touch-id (or --secure-enclave) rather than anything keychain-flavored. The macOS Keychain is genuinely not involved in this design, so the naming is also more accurate.
Duplicate check. No existing issue or PR requests OS-keychain/biometric/Secure Enclave key storage; all ~30 "keychain" hits are Tempo's on-chain keychain, and "keyring" hits are GCP KMS.
Prior art. age-plugin-se (this exact pattern, Homebrew-distributed, no signing infra); Secretive (SE-resident SSH agent, Touch ID per signature); aws-vault (keychain items + signed releases); 1Password op (signed desktop-app agent over XPC); dfx (Rust keyring crate, plain keychain item, no biometrics); geth clef (own encrypted vault, no OS integration).
Rust deps. security-framework ≥3.7 exposes SecAccessControl with the biometry flags; objc2-local-authentication covers LAContext; the CryptoKit bridge is the only Swift piece. A future cross-platform password-source seam (Windows Hello, Secret Service) could reuse the same hook, but macOS-first keeps the scope honest.
Component
Cast
Describe the feature you would like
Using an encrypted keystore with
casttoday means either retyping the password on every command or leaking it through--passwordin argv,ETH_PASSWORD,--unsafe-password, or a plaintext password file (#2794 added file passwords for exactly this friction). On macOS,castcould offer the UX users already know fromsudowithpam_tid, 1Password, andage-plugin-se: unlock a key with Touch ID.Proposed UX:
Proposed design: Secure Enclave key-wrapping (the
age-plugin-sepattern)The obvious approaches do not work for a CLI, so it is worth being explicit about the design that does:
--touch-idenrollment, generate a P-256 key inside the Secure Enclave withSecAccessControl(.privateKeyUsage | .userPresence)(or.biometryCurrentSetfor strict mode).dataRepresentationas a sidecar next to the normal keystore JSON in~/.foundry/keystores.Key properties, all verified against Apple docs (TN3137, Secure Enclave docs) and empirically with ad-hoc-signed Swift probes on an Apple Silicon Mac:
age-plugin-seships exactly this via Homebrew.kSecAttrAccessControl) require the data-protection keychain, which requires provisioning-profile-backed entitlements in an app-like bundle — an ad-hoc CLI getserrSecMissingEntitlement (-34018); (b) file-based keychain "Always Allow" ACLs bind to the binary's code signature, so everyfoundryupupgrade of an unsigned binary would re-prompt (aws-vault code-signs its releases solely to avoid this;ghexecs/usr/bin/securityinstead, which lets any local process read the item).Constraints (inherent to the platform, worth recording as non-goals)
kSecAttrSynchronizableare mutually exclusive (SecItemAddreturnserrSecParamwhen combined), sync requires the entitlement-gated data-protection keychain anyway, and SE blobs are device-bound by design..biometryCurrentSet, re-enrolling fingerprints permanently invalidates the wrap key — another reason the keystore JSON stays the source of truth.Additional context
Implementation home. Signer/credential resolution lives in
foundry-wallets(foundry-core), not this repo. The natural seam is the keystore-password resolution increate_keystore_signer/PendingSigner::unlock(currently:--password>--password-file> interactiverpasswordprompt) — a Touch ID unwrap slots in before the interactive prompt, keyed by the keystore address already carried inPendingSigner::Keystore. That makes the change a password source, far smaller than a newWalletSignerbackend, and every consumer (cast send/mktx/call/estimate,forge create,forge scriptbroadcast and cheatcode wallets) benefits with no per-command changes. The cast side is enrollment UX oncast wallet import/new, pluslist/removeawareness. The Swift shim is unavoidable but tiny: the SE key'sdataRepresentationis a CryptoKit-only API with no C surface, so a small compiled-in Swift helper (orswift-rs) is needed, feature-gated andtarget_os = "macos"-only, following the existinghidapi/aws-kmsoptional-dependency patterns.Naming.
cast keychainis taken by Tempo's on-chain AccountKeychain, and "keyring" collides with GCP KMS "key ring" terminology in existing PRs — hence--touch-id(or--secure-enclave) rather than anything keychain-flavored. The macOS Keychain is genuinely not involved in this design, so the naming is also more accurate.Duplicate check. No existing issue or PR requests OS-keychain/biometric/Secure Enclave key storage; all ~30 "keychain" hits are Tempo's on-chain keychain, and "keyring" hits are GCP KMS.
Prior art.
age-plugin-se(this exact pattern, Homebrew-distributed, no signing infra); Secretive (SE-resident SSH agent, Touch ID per signature); aws-vault (keychain items + signed releases); 1Passwordop(signed desktop-app agent over XPC);dfx(Rustkeyringcrate, plain keychain item, no biometrics); gethclef(own encrypted vault, no OS integration).Rust deps.
security-framework≥3.7 exposesSecAccessControlwith the biometry flags;objc2-local-authenticationcoversLAContext; the CryptoKit bridge is the only Swift piece. A future cross-platform password-source seam (Windows Hello, Secret Service) could reuse the same hook, but macOS-first keeps the scope honest.