Skip to main content

foundry_common/tempo/
keystore.rs

1//! Tempo wallet keystore types and discovery helpers.
2//!
3//! Shared types for reading keys from the Tempo CLI wallet keystore
4//! (`$TEMPO_HOME/wallet/keys.toml`, defaulting to `~/.tempo/wallet/keys.toml`).
5
6use alloy_primitives::{Address, hex};
7use alloy_rlp::Decodable;
8use serde::{Deserialize, Serialize};
9use std::{env, fmt, path::PathBuf};
10
11use super::registry::{read_toml_file, write_toml_file_atomic};
12
13/// Environment variable for an ephemeral Tempo private key.
14pub const TEMPO_PRIVATE_KEY_ENV: &str = "TEMPO_PRIVATE_KEY";
15
16/// Environment variable to override the Tempo home directory.
17pub const TEMPO_HOME_ENV: &str = "TEMPO_HOME";
18
19/// Default Tempo home directory relative to the user's home.
20pub const DEFAULT_TEMPO_HOME: &str = ".tempo";
21
22/// Relative path from Tempo home to the wallet keys file.
23pub const WALLET_KEYS_PATH: &str = "wallet/keys.toml";
24
25/// Wallet type matching `tempo-common`'s `WalletType` enum.
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum WalletType {
29    #[default]
30    Local,
31    Passkey,
32}
33
34/// Cryptographic key type.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(rename_all = "lowercase")]
37pub enum KeyType {
38    #[default]
39    Secp256k1,
40    P256,
41    WebAuthn,
42}
43
44/// Per-token spending limit stored in `keys.toml`.
45#[derive(Debug, Default, Deserialize, Serialize)]
46pub struct StoredTokenLimit {
47    pub currency: Address,
48    pub limit: String,
49}
50
51/// A single key entry in `keys.toml`.
52///
53/// Mirrors the fields from `tempo-common::keys::model::KeyEntry`.
54/// Unknown fields are ignored by serde.
55#[derive(Default, Deserialize, Serialize)]
56pub struct KeyEntry {
57    /// Wallet type: "local" or "passkey".
58    #[serde(default)]
59    pub wallet_type: WalletType,
60    /// Smart wallet address (the on-chain account).
61    #[serde(default)]
62    pub wallet_address: Address,
63    /// Chain ID.
64    #[serde(default)]
65    pub chain_id: u64,
66    /// Cryptographic key type.
67    #[serde(default)]
68    pub key_type: KeyType,
69    /// Key address (the EOA derived from the private key).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub key_address: Option<Address>,
72    /// Key private key, stored inline in keys.toml.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub key: Option<String>,
75    /// RLP-encoded signed key authorization (hex string).
76    /// Used in keychain mode to atomically provision the access key on-chain.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub key_authorization: Option<String>,
79    /// Expiry timestamp.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub expiry: Option<u64>,
82    /// Per-token spending limits.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub limits: Vec<StoredTokenLimit>,
85}
86
87// Manual `Debug` redacts the persistent key material; propagates to `KeysFile`.
88impl fmt::Debug for KeyEntry {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("KeyEntry")
91            .field("wallet_type", &self.wallet_type)
92            .field("wallet_address", &self.wallet_address)
93            .field("chain_id", &self.chain_id)
94            .field("key_type", &self.key_type)
95            .field("key_address", &self.key_address)
96            .field("key", &self.key.as_deref().map(super::redacted_debug))
97            .field(
98                "key_authorization",
99                &self.key_authorization.as_deref().map(super::redacted_debug),
100            )
101            .field("expiry", &self.expiry)
102            .field("limits", &self.limits)
103            .finish()
104    }
105}
106
107impl KeyEntry {
108    /// Whether this entry has a non-empty inline private key.
109    pub fn has_inline_key(&self) -> bool {
110        self.key.as_ref().is_some_and(|k| !k.trim().is_empty())
111    }
112}
113
114/// The top-level structure of `keys.toml`.
115#[derive(Debug, Default, Deserialize, Serialize)]
116pub struct KeysFile {
117    #[serde(default)]
118    pub keys: Vec<KeyEntry>,
119}
120
121/// Resolve the Tempo home directory.
122///
123/// Uses `TEMPO_HOME` env var if set, otherwise `~/.tempo`.
124pub fn tempo_home() -> Option<PathBuf> {
125    if let Ok(home) = env::var(TEMPO_HOME_ENV) {
126        return Some(PathBuf::from(home));
127    }
128    dirs::home_dir().map(|h| h.join(DEFAULT_TEMPO_HOME))
129}
130
131/// Returns the path to the Tempo wallet keys file.
132pub fn tempo_keys_path() -> Option<PathBuf> {
133    tempo_home().map(|home| home.join(WALLET_KEYS_PATH))
134}
135
136/// Read and parse the Tempo wallet keys file.
137///
138/// Returns `None` if the file doesn't exist or can't be read/parsed.
139/// Errors are logged as warnings.
140pub fn read_tempo_keys_file() -> Option<KeysFile> {
141    let keys_path = tempo_keys_path()?;
142    match read_toml_file(&keys_path, "tempo keys") {
143        Ok(value) => value,
144        Err(e) => {
145            tracing::warn!(?keys_path, %e, "failed to load tempo keys file");
146            None
147        }
148    }
149}
150
151/// Decodes a hex-encoded, RLP-encoded key authorization.
152///
153/// The input should be a hex string (with or without 0x prefix) containing
154/// RLP-encoded `SignedKeyAuthorization` data.
155pub fn decode_key_authorization<T: Decodable>(hex_str: &str) -> eyre::Result<T> {
156    let bytes = hex::decode(hex_str)?;
157    let auth = T::decode(&mut bytes.as_slice())?;
158    Ok(auth)
159}
160
161/// Atomically upsert a [`KeyEntry`] into `keys.toml`.
162///
163/// Replaces any existing entry for the same `(wallet_address, chain_id)`.
164/// Each Tempo wallet has at most one active access key per chain, so a fresh
165/// login always supersedes the previous entry regardless of the new key
166/// address. Creates the file (and parent directories) if missing. Writes via
167/// temp file + rename so a crash mid-write cannot corrupt the file.
168pub(crate) fn upsert_key_entry(entry: KeyEntry) -> eyre::Result<()> {
169    let path = tempo_keys_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
170    let mut file = read_toml_file::<KeysFile>(&path, "tempo keys")?.unwrap_or_default();
171    file.keys
172        .retain(|k| !(k.wallet_address == entry.wallet_address && k.chain_id == entry.chain_id));
173    file.keys.push(entry);
174
175    write_toml_file_atomic(
176        &path,
177        &file,
178        "# Tempo wallet keys — managed by Foundry / Tempo CLI.\n# Do not edit manually.",
179    )
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::tempo::with_tempo_home;
186    use std::{fs, str::FromStr};
187
188    #[test]
189    fn debug_redacts_key_entry_secrets() {
190        let entry = KeyEntry {
191            key: Some("0xPRIVATE_KEY_MUST_NOT_LEAK".to_string()),
192            key_authorization: Some("0xKEY_AUTH_MUST_NOT_LEAK".to_string()),
193            ..Default::default()
194        };
195
196        let entry_dbg = format!("{entry:?}");
197        let file_dbg = format!("{:?}", KeysFile { keys: vec![entry] });
198
199        for rendered in [&entry_dbg, &file_dbg] {
200            assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK"), "key leaked in: {rendered}");
201            assert!(!rendered.contains("KEY_AUTH_MUST_NOT_LEAK"), "auth leaked in: {rendered}");
202        }
203        assert!(entry_dbg.contains("key: Some(\"<redacted>\")"), "got: {entry_dbg}");
204        assert!(entry_dbg.contains("key_authorization: Some(\"<redacted>\")"), "got: {entry_dbg}");
205        // Non-secret metadata stays visible.
206        assert!(entry_dbg.contains("wallet_type"));
207    }
208
209    #[test]
210    fn upsert_replaces_matching_entry_atomically() {
211        with_tempo_home(|| {
212            let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
213            let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
214
215            let mk = |expiry: u64| KeyEntry {
216                wallet_type: WalletType::Passkey,
217                wallet_address: wallet,
218                chain_id: 4217,
219                key_type: KeyType::Secp256k1,
220                key_address: Some(key),
221                key: Some("0xdead".to_string()),
222                key_authorization: Some("0xbeef".to_string()),
223                expiry: Some(expiry),
224                limits: vec![],
225            };
226
227            upsert_key_entry(mk(100)).unwrap();
228            upsert_key_entry(mk(200)).unwrap();
229
230            let file = read_tempo_keys_file().unwrap();
231            assert_eq!(file.keys.len(), 1);
232            assert_eq!(file.keys[0].expiry, Some(200));
233
234            // Different chain_id => separate entry.
235            let mut other = mk(300);
236            other.chain_id = 42431;
237            upsert_key_entry(other).unwrap();
238            let file = read_tempo_keys_file().unwrap();
239            assert_eq!(file.keys.len(), 2);
240        });
241    }
242
243    #[test]
244    fn upsert_replaces_when_key_address_changes() {
245        // Re-login produces a fresh random key address; the new entry must
246        // supersede the old one for the same (wallet, chain), not coexist.
247        with_tempo_home(|| {
248            let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
249            let old_key = Address::from_str("0x000000000000000000000000000000000000aaaa").unwrap();
250            let new_key = Address::from_str("0x000000000000000000000000000000000000bbbb").unwrap();
251
252            let mk = |key_addr: Address| KeyEntry {
253                wallet_type: WalletType::Passkey,
254                wallet_address: wallet,
255                chain_id: 4217,
256                key_type: KeyType::Secp256k1,
257                key_address: Some(key_addr),
258                key: Some("0xdead".to_string()),
259                key_authorization: Some("0xbeef".to_string()),
260                expiry: Some(100),
261                limits: vec![],
262            };
263
264            upsert_key_entry(mk(old_key)).unwrap();
265            upsert_key_entry(mk(new_key)).unwrap();
266
267            let file = read_tempo_keys_file().unwrap();
268            assert_eq!(file.keys.len(), 1, "old entry must be replaced, not duplicated");
269            assert_eq!(file.keys[0].key_address, Some(new_key));
270        });
271    }
272
273    #[test]
274    fn upsert_fails_closed_when_keys_file_is_corrupt() {
275        with_tempo_home(|| {
276            let path = tempo_keys_path().unwrap();
277            fs::create_dir_all(path.parent().unwrap()).unwrap();
278            fs::write(&path, "keys = [").unwrap();
279            let original = fs::read_to_string(&path).unwrap();
280
281            let wallet = Address::from_str("0x0000000000000000000000000000000000000001").unwrap();
282            let key = Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
283            let entry = KeyEntry {
284                wallet_type: WalletType::Passkey,
285                wallet_address: wallet,
286                chain_id: 4217,
287                key_type: KeyType::Secp256k1,
288                key_address: Some(key),
289                key: Some("0xdead".to_string()),
290                key_authorization: Some("0xbeef".to_string()),
291                expiry: Some(100),
292                limits: vec![],
293            };
294
295            assert!(read_tempo_keys_file().is_none());
296            assert!(upsert_key_entry(entry).is_err());
297            assert_eq!(fs::read_to_string(&path).unwrap(), original);
298        });
299    }
300}