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