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