Skip to main content

miden_client/keystore/
fs_keystore.rs

1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::string::String;
4use std::fs;
5use std::hash::{DefaultHasher, Hash, Hasher};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::string::ToString;
9use std::sync::Arc;
10
11use miden_protocol::Word;
12use miden_protocol::account::AccountId;
13use miden_protocol::account::auth::{AuthSecretKey, PublicKey, PublicKeyCommitment, Signature};
14use miden_tx::AuthenticationError;
15use miden_tx::auth::{SigningInputs, TransactionAuthenticator};
16use miden_tx::utils::sync::RwLock;
17use miden_tx::utils::{Deserializable, Serializable};
18use serde::{Deserialize, Serialize};
19
20use super::{KeyStoreError, Keystore};
21
22// INDEX FILE
23// ================================================================================================
24
25const INDEX_FILE_NAME: &str = "key_index.json";
26const INDEX_VERSION: u32 = 1;
27
28/// The structure of the key index file that maps account IDs to public key commitments.
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30struct KeyIndex {
31    version: u32,
32    /// Maps account ID (hex) to a set of public key commitment (hex).
33    mappings: BTreeMap<String, BTreeSet<String>>,
34}
35
36impl KeyIndex {
37    fn new() -> Self {
38        Self {
39            version: INDEX_VERSION,
40            mappings: BTreeMap::new(),
41        }
42    }
43
44    /// Adds a mapping from account ID to public key commitment.
45    fn add_mapping(&mut self, account_id: &AccountId, pub_key_commitment: PublicKeyCommitment) {
46        let account_id_hex = account_id.to_hex();
47        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
48
49        self.mappings.entry(account_id_hex).or_default().insert(pub_key_hex);
50    }
51
52    /// Removes all mappings for a given public key commitment.
53    fn remove_all_mappings_for_key(&mut self, pub_key_commitment: PublicKeyCommitment) {
54        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
55
56        // Remove the key from all account mappings
57        self.mappings.retain(|_, commitments| {
58            commitments.remove(&pub_key_hex);
59            !commitments.is_empty()
60        });
61    }
62
63    /// Loads the index from disk, or creates a new one if it doesn't exist.
64    fn read_from_file(keys_directory: &Path) -> Result<Self, KeyStoreError> {
65        let index_path = keys_directory.join(INDEX_FILE_NAME);
66
67        if !index_path.exists() {
68            return Ok(Self::new());
69        }
70
71        let contents =
72            fs::read_to_string(&index_path).map_err(keystore_error("error reading index file"))?;
73
74        serde_json::from_str(&contents).map_err(|err| {
75            KeyStoreError::DecodingError(format!("error parsing index file: {err:?}"))
76        })
77    }
78
79    /// Saves the index to disk atomically (write to temp file, then rename).
80    fn write_to_file(&self, keys_directory: &Path) -> Result<(), KeyStoreError> {
81        let index_path = keys_directory.join(INDEX_FILE_NAME);
82        let temp_path = std::env::temp_dir().join(INDEX_FILE_NAME);
83
84        let contents = serde_json::to_string_pretty(self).map_err(|err| {
85            KeyStoreError::StorageError(format!("error serializing index: {err:?}"))
86        })?;
87
88        // Write to temp file
89        let mut file = fs::File::create(&temp_path)
90            .map_err(keystore_error("error creating temp index file"))?;
91        file.write_all(contents.as_bytes())
92            .map_err(keystore_error("error writing temp index file"))?;
93        file.sync_all().map_err(keystore_error("error syncing temp index file"))?;
94
95        // Atomically rename
96        fs::rename(&temp_path, &index_path).map_err(keystore_error("error renaming index file"))
97    }
98
99    /// Returns the account ID associated with a given public key commitment hex.
100    ///
101    /// Iterates over all mappings to find which account contains the commitment.
102    /// Returns `None` if no account is found.
103    fn get_account_id(&self, pub_key_commitment: PublicKeyCommitment) -> Option<AccountId> {
104        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
105
106        for (account_id_hex, commitments) in &self.mappings {
107            if commitments.contains(&pub_key_hex) {
108                return AccountId::from_hex(account_id_hex).ok();
109            }
110        }
111
112        None
113    }
114
115    /// Gets all public key commitments for an account ID.
116    fn get_commitments(
117        &self,
118        account_id: &AccountId,
119    ) -> Result<BTreeSet<PublicKeyCommitment>, KeyStoreError> {
120        let account_id_hex = account_id.to_hex();
121
122        self.mappings
123            .get(&account_id_hex)
124            .map(|commitments| {
125                commitments
126                    .iter()
127                    .filter_map(|hex| {
128                        Word::try_from(hex.as_str()).ok().map(PublicKeyCommitment::from)
129                    })
130                    .collect()
131            })
132            .ok_or_else(|| {
133                KeyStoreError::StorageError(format!("account not found {account_id_hex}"))
134            })
135    }
136}
137
138// FILESYSTEM KEYSTORE
139// ================================================================================================
140
141/// A filesystem-based keystore that stores keys in separate files and provides transaction
142/// authentication functionality. The public key is hashed and the result is used as the filename
143/// and the contents of the file are the serialized public and secret key.
144///
145/// Account-to-key mappings are stored in a separate JSON index file.
146#[derive(Debug)]
147pub struct FilesystemKeyStore {
148    /// The directory where the keys are stored and read from.
149    pub keys_directory: PathBuf,
150    /// The in-memory index of account-to-key mappings.
151    index: RwLock<KeyIndex>,
152}
153
154impl Clone for FilesystemKeyStore {
155    fn clone(&self) -> Self {
156        let index = self.index.read().clone();
157        Self {
158            keys_directory: self.keys_directory.clone(),
159            index: RwLock::new(index),
160        }
161    }
162}
163
164impl FilesystemKeyStore {
165    /// Creates a [`FilesystemKeyStore`] on a specific directory.
166    pub fn new(keys_directory: PathBuf) -> Result<Self, KeyStoreError> {
167        if !keys_directory.exists() {
168            fs::create_dir_all(&keys_directory)
169                .map_err(keystore_error("error creating keys directory"))?;
170        }
171
172        let index = KeyIndex::read_from_file(&keys_directory)?;
173
174        Ok(FilesystemKeyStore {
175            keys_directory,
176            index: RwLock::new(index),
177        })
178    }
179
180    /// Adds a secret key to the keystore without updating account mappings.
181    ///
182    /// This is an internal method. Use [`Keystore::add_key`] instead.
183    fn add_key_without_account(&self, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
184        let pub_key_commitment = key.public_key().to_commitment();
185        let file_path = key_file_path(&self.keys_directory, pub_key_commitment);
186        write_secret_key_file(&file_path, key)
187    }
188
189    /// Retrieves a secret key from the keystore given the commitment of a public key.
190    pub fn get_key_sync(
191        &self,
192        pub_key: PublicKeyCommitment,
193    ) -> Result<Option<AuthSecretKey>, KeyStoreError> {
194        let file_path = key_file_path(&self.keys_directory, pub_key);
195        match fs::read(&file_path) {
196            Ok(bytes) => {
197                let key = AuthSecretKey::read_from_bytes(&bytes).map_err(|err| {
198                    KeyStoreError::DecodingError(format!(
199                        "error reading secret key from file: {err:?}"
200                    ))
201                })?;
202                Ok(Some(key))
203            },
204            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
205            Err(e) => Err(keystore_error("error reading secret key file")(e)),
206        }
207    }
208
209    /// Saves the index to disk.
210    fn save_index(&self) -> Result<(), KeyStoreError> {
211        let index = self.index.read();
212        index.write_to_file(&self.keys_directory)
213    }
214}
215
216impl TransactionAuthenticator for FilesystemKeyStore {
217    /// Gets a signature over a message, given a public key.
218    ///
219    /// The public key should correspond to one of the keys tracked by the keystore.
220    ///
221    /// # Errors
222    /// If the public key isn't found in the store, [`AuthenticationError::UnknownPublicKey`] is
223    /// returned.
224    async fn get_signature(
225        &self,
226        pub_key: PublicKeyCommitment,
227        signing_info: &SigningInputs,
228    ) -> Result<Signature, AuthenticationError> {
229        let message = signing_info.to_commitment();
230
231        let secret_key = self
232            .get_key_sync(pub_key)
233            .map_err(|err| {
234                AuthenticationError::other_with_source("failed to load secret key", err)
235            })?
236            .ok_or(AuthenticationError::UnknownPublicKey(pub_key))?;
237
238        let signature = secret_key.sign(message);
239
240        Ok(signature)
241    }
242
243    /// Retrieves a public key for a specific public key commitment.
244    async fn get_public_key(
245        &self,
246        pub_key_commitment: PublicKeyCommitment,
247    ) -> Option<Arc<PublicKey>> {
248        self.get_key(pub_key_commitment)
249            .await
250            .ok()
251            .flatten()
252            .map(|key| Arc::new(key.public_key()))
253    }
254}
255
256#[async_trait::async_trait]
257impl Keystore for FilesystemKeyStore {
258    async fn add_key(
259        &self,
260        key: &AuthSecretKey,
261        account_id: AccountId,
262    ) -> Result<(), KeyStoreError> {
263        let pub_key_commitment = key.public_key().to_commitment();
264
265        // Write the key file
266        self.add_key_without_account(key)?;
267
268        // Update the index
269        {
270            let mut index = self.index.write();
271            index.add_mapping(&account_id, pub_key_commitment);
272        }
273
274        // Persist the index
275        self.save_index()?;
276
277        Ok(())
278    }
279
280    async fn remove_key(&self, pub_key: PublicKeyCommitment) -> Result<(), KeyStoreError> {
281        // Remove from index first
282        {
283            let mut index = self.index.write();
284            index.remove_all_mappings_for_key(pub_key);
285        }
286
287        // Persist the index
288        self.save_index()?;
289
290        // Remove the key file
291        let file_path = key_file_path(&self.keys_directory, pub_key);
292        match fs::remove_file(file_path) {
293            Ok(()) => {},
294            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
295            Err(e) => return Err(keystore_error("error removing secret key file")(e)),
296        }
297
298        Ok(())
299    }
300
301    async fn get_key(
302        &self,
303        pub_key: PublicKeyCommitment,
304    ) -> Result<Option<AuthSecretKey>, KeyStoreError> {
305        self.get_key_sync(pub_key)
306    }
307
308    async fn get_account_id_by_key_commitment(
309        &self,
310        pub_key_commitment: PublicKeyCommitment,
311    ) -> Result<Option<AccountId>, KeyStoreError> {
312        let index = self.index.read();
313        Ok(index.get_account_id(pub_key_commitment))
314    }
315
316    async fn get_account_key_commitments(
317        &self,
318        account_id: &AccountId,
319    ) -> Result<BTreeSet<PublicKeyCommitment>, KeyStoreError> {
320        let index = self.index.read();
321        index.get_commitments(account_id)
322    }
323}
324
325// HELPERS
326// ================================================================================================
327
328/// Returns the file path that belongs to the public key commitment
329fn key_file_path(keys_directory: &Path, pub_key: PublicKeyCommitment) -> PathBuf {
330    let filename = hash_pub_key(pub_key.into());
331    keys_directory.join(filename)
332}
333
334/// Writes an [`AuthSecretKey`] into a file with restrictive permissions (0600 on Unix).
335#[cfg(unix)]
336fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
337    use std::io::Write;
338    use std::os::unix::fs::OpenOptionsExt;
339    let mut file = fs::OpenOptions::new()
340        .write(true)
341        .create(true)
342        .truncate(true)
343        .mode(0o600)
344        .open(file_path)
345        .map_err(keystore_error("error writing secret key file"))?;
346    file.write_all(&key.to_bytes())
347        .map_err(keystore_error("error writing secret key file"))
348}
349
350/// Writes an [`AuthSecretKey`] into a file.
351// TODO: on Windows, set restrictive ACLs to limit access to the current user.
352#[cfg(not(unix))]
353fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
354    fs::write(file_path, key.to_bytes()).map_err(keystore_error("error writing secret key file"))
355}
356
357fn keystore_error(context: &str) -> impl FnOnce(std::io::Error) -> KeyStoreError {
358    move |err| KeyStoreError::StorageError(format!("{context}: {err:?}"))
359}
360
361/// Hashes a public key to a string representation.
362fn hash_pub_key(pub_key: Word) -> String {
363    let pub_key = pub_key.to_hex();
364    let mut hasher = DefaultHasher::new();
365    pub_key.hash(&mut hasher);
366    hasher.finish().to_string()
367}