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    async fn get_signature(
231        &self,
232        pub_key: PublicKeyCommitment,
233        signing_info: &SigningInputs,
234    ) -> Result<Signature, AuthenticationError> {
235        let message = signing_info.to_commitment();
236
237        let secret_key = self
238            .get_key_sync(pub_key)
239            .map_err(|err| {
240                AuthenticationError::other_with_source("failed to load secret key", err)
241            })?
242            .ok_or(AuthenticationError::UnknownPublicKey(pub_key))?;
243
244        let signature = secret_key.sign(message);
245
246        Ok(signature)
247    }
248
249    /// Retrieves a public key for a specific public key commitment.
250    async fn get_public_key(
251        &self,
252        pub_key_commitment: PublicKeyCommitment,
253    ) -> Option<Arc<PublicKey>> {
254        self.get_key(pub_key_commitment)
255            .await
256            .ok()
257            .flatten()
258            .map(|key| Arc::new(key.public_key()))
259    }
260}
261
262#[async_trait::async_trait]
263impl Keystore for FilesystemKeyStore {
264    async fn add_key(
265        &self,
266        key: &AuthSecretKey,
267        account_id: AccountId,
268    ) -> Result<(), KeyStoreError> {
269        let pub_key_commitment = key.public_key().to_commitment();
270
271        // Write the key file
272        self.add_key_without_account(key)?;
273
274        // Update the index
275        {
276            let mut index = self.index.write();
277            index.add_mapping(&account_id, pub_key_commitment);
278        }
279
280        // Persist the index
281        self.save_index()?;
282
283        Ok(())
284    }
285
286    async fn remove_key(&self, pub_key: PublicKeyCommitment) -> Result<(), KeyStoreError> {
287        // Remove from index first
288        {
289            let mut index = self.index.write();
290            index.remove_all_mappings_for_key(pub_key);
291        }
292
293        // Persist the index
294        self.save_index()?;
295
296        // Remove the key file
297        let file_path = key_file_path(&self.keys_directory, pub_key);
298        match fs::remove_file(file_path) {
299            Ok(()) => {},
300            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
301            Err(e) => return Err(keystore_error("error removing secret key file")(e)),
302        }
303
304        Ok(())
305    }
306
307    async fn get_key(
308        &self,
309        pub_key: PublicKeyCommitment,
310    ) -> Result<Option<AuthSecretKey>, KeyStoreError> {
311        self.get_key_sync(pub_key)
312    }
313
314    async fn get_account_id_by_key_commitment(
315        &self,
316        pub_key_commitment: PublicKeyCommitment,
317    ) -> Result<Option<AccountId>, KeyStoreError> {
318        let index = self.index.read();
319        Ok(index.get_account_id(pub_key_commitment))
320    }
321
322    async fn get_account_key_commitments(
323        &self,
324        account_id: &AccountId,
325    ) -> Result<BTreeSet<PublicKeyCommitment>, KeyStoreError> {
326        let index = self.index.read();
327        index.get_commitments(account_id)
328    }
329}
330
331// HELPERS
332// ================================================================================================
333
334/// Returns the file path that belongs to the public key commitment
335fn key_file_path(keys_directory: &Path, pub_key_commitment: PublicKeyCommitment) -> PathBuf {
336    let filename = Word::from(pub_key_commitment).to_hex();
337    keys_directory.join(filename)
338}
339
340/// Writes an [`AuthSecretKey`] into a file with restrictive permissions (0600 on Unix).
341#[cfg(unix)]
342fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
343    use std::io::Write;
344    use std::os::unix::fs::OpenOptionsExt;
345    let mut file = fs::OpenOptions::new()
346        .write(true)
347        .create(true)
348        .truncate(true)
349        .mode(0o600)
350        .open(file_path)
351        .map_err(keystore_error("error writing secret key file"))?;
352    file.write_all(&key.to_bytes())
353        .map_err(keystore_error("error writing secret key file"))
354}
355
356/// Writes an [`AuthSecretKey`] into a file.
357// TODO: on Windows, set restrictive ACLs to limit access to the current user.
358#[cfg(not(unix))]
359fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
360    fs::write(file_path, key.to_bytes()).map_err(keystore_error("error writing secret key file"))
361}
362
363fn keystore_error(context: &str) -> impl FnOnce(std::io::Error) -> KeyStoreError {
364    move |err| KeyStoreError::StorageError(format!("{context}: {err:?}"))
365}