Skip to main content

miden_client/keystore/
fs_keystore.rs

1use alloc::boxed::Box;
2use alloc::collections::{BTreeMap, BTreeSet};
3use alloc::string::String;
4use alloc::vec::Vec;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use miden_protocol::Word;
11use miden_protocol::account::AccountId;
12use miden_protocol::account::auth::{
13    AuthScheme,
14    AuthSecretKey,
15    PublicKey,
16    PublicKeyCommitment,
17    Signature,
18};
19use miden_tx::AuthenticationError;
20use miden_tx::auth::{SigningInputs, TransactionAuthenticator};
21use miden_tx::utils::serde::{Deserializable, Serializable};
22use miden_tx::utils::sync::RwLock;
23use serde::{Deserialize, Serialize};
24
25use super::{KeyStoreError, Keystore};
26
27// INDEX FILE
28// ================================================================================================
29
30const INDEX_FILE_NAME: &str = "key_index.json";
31const INDEX_VERSION: u32 = 1;
32
33/// The structure of the key index file that maps account IDs to public key commitments.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35struct KeyIndex {
36    version: u32,
37    /// Maps account ID (hex) to a set of public key commitment (hex).
38    mappings: BTreeMap<String, BTreeSet<String>>,
39}
40
41impl KeyIndex {
42    fn new() -> Self {
43        Self {
44            version: INDEX_VERSION,
45            mappings: BTreeMap::new(),
46        }
47    }
48
49    /// Adds a mapping from account ID to public key commitment.
50    fn add_mapping(&mut self, account_id: &AccountId, pub_key_commitment: PublicKeyCommitment) {
51        let account_id_hex = account_id.to_hex();
52        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
53
54        self.mappings.entry(account_id_hex).or_default().insert(pub_key_hex);
55    }
56
57    /// Removes a mapping from an account ID to a public key commitment.
58    ///
59    /// Returns `true` if the mapping was present. An account entry that keeps no commitment is
60    /// removed.
61    fn remove_mapping(
62        &mut self,
63        account_id: &AccountId,
64        pub_key_commitment: PublicKeyCommitment,
65    ) -> bool {
66        let account_id_hex = account_id.to_hex();
67        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
68
69        let Some(commitments) = self.mappings.get_mut(&account_id_hex) else {
70            return false;
71        };
72
73        let removed = commitments.remove(&pub_key_hex);
74        if commitments.is_empty() {
75            self.mappings.remove(&account_id_hex);
76        }
77
78        removed
79    }
80
81    /// Removes all mappings for a given public key commitment.
82    fn remove_all_mappings_for_key(&mut self, pub_key_commitment: PublicKeyCommitment) {
83        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
84
85        // Remove the key from all account mappings
86        self.mappings.retain(|_, commitments| {
87            commitments.remove(&pub_key_hex);
88            !commitments.is_empty()
89        });
90    }
91
92    /// Loads the index from disk, or creates a new one if it doesn't exist.
93    fn read_from_file(keys_directory: &Path) -> Result<Self, KeyStoreError> {
94        let index_path = keys_directory.join(INDEX_FILE_NAME);
95
96        if !index_path.exists() {
97            return Ok(Self::new());
98        }
99
100        let contents =
101            fs::read_to_string(&index_path).map_err(keystore_error("error reading index file"))?;
102
103        serde_json::from_str(&contents).map_err(|err| {
104            KeyStoreError::DecodingError(format!("error parsing index file: {err:?}"))
105        })
106    }
107
108    /// Saves the index to disk atomically (write to temp file, then rename).
109    fn write_to_file(&self, keys_directory: &Path) -> Result<(), KeyStoreError> {
110        let index_path = keys_directory.join(INDEX_FILE_NAME);
111
112        let contents = serde_json::to_string_pretty(self).map_err(|err| {
113            KeyStoreError::StorageError(format!("error serializing index: {err:?}"))
114        })?;
115
116        // Create the temp file in the same directory as the index so the subsequent atomic rename
117        // stays on the same filesystem.
118        let mut temp_file = tempfile::NamedTempFile::new_in(keys_directory)
119            .map_err(keystore_error("error creating temp index file"))?;
120        temp_file
121            .write_all(contents.as_bytes())
122            .map_err(keystore_error("error writing temp index file"))?;
123        temp_file
124            .as_file()
125            .sync_all()
126            .map_err(keystore_error("error syncing temp index file"))?;
127
128        // Atomically replace the index file.
129        temp_file
130            .persist(&index_path)
131            .map_err(|err| keystore_error("error renaming index file")(err.error))?;
132
133        Ok(())
134    }
135
136    /// Returns the account ID associated with a given public key commitment hex.
137    ///
138    /// Iterates over all mappings to find which account contains the commitment. Returns `None` if
139    /// no account is found.
140    ///
141    /// A key can be associated with more than one account. This method returns only the first
142    /// account in iteration order. Use [`KeyIndex::get_account_ids`] to get every account.
143    fn get_account_id(&self, pub_key_commitment: PublicKeyCommitment) -> Option<AccountId> {
144        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
145
146        for (account_id_hex, commitments) in &self.mappings {
147            if commitments.contains(&pub_key_hex) {
148                return AccountId::from_hex(account_id_hex).ok();
149            }
150        }
151
152        None
153    }
154
155    /// Returns all account IDs associated with a public key commitment.
156    fn get_account_ids(
157        &self,
158        pub_key_commitment: PublicKeyCommitment,
159    ) -> Result<BTreeSet<AccountId>, KeyStoreError> {
160        let pub_key_hex = Word::from(pub_key_commitment).to_hex();
161
162        self.mappings
163            .iter()
164            .filter(|(_, commitments)| commitments.contains(&pub_key_hex))
165            .map(|(account_id_hex, _)| {
166                AccountId::from_hex(account_id_hex).map_err(|err| {
167                    KeyStoreError::DecodingError(format!(
168                        "error parsing account ID in key index: {err:?}"
169                    ))
170                })
171            })
172            .collect()
173    }
174
175    /// Gets all public key commitments for an account ID.
176    ///
177    /// Returns an empty set if the index holds no mapping for the account. An account can hold keys
178    /// that this keystore does not have, so an absent mapping is a valid state.
179    fn get_commitments(&self, account_id: &AccountId) -> BTreeSet<PublicKeyCommitment> {
180        let account_id_hex = account_id.to_hex();
181
182        self.mappings
183            .get(&account_id_hex)
184            .map(|commitments| {
185                commitments
186                    .iter()
187                    .filter_map(|hex| {
188                        Word::try_from(hex.as_str()).ok().map(PublicKeyCommitment::from)
189                    })
190                    .collect()
191            })
192            .unwrap_or_default()
193    }
194}
195
196// FILESYSTEM KEYSTORE
197// ================================================================================================
198
199/// A filesystem-based keystore that stores keys in separate files and provides transaction
200/// authentication functionality. The public key is hashed and the result is used as the filename
201/// and the contents of the file are the serialized public and secret key.
202///
203/// Account-to-key mappings are stored in a separate JSON index file.
204#[derive(Debug)]
205pub struct FilesystemKeyStore {
206    /// The directory where the keys are stored and read from.
207    pub keys_directory: PathBuf,
208    /// The in-memory index of account-to-key mappings.
209    index: RwLock<KeyIndex>,
210}
211
212/// Information about a secret key in a [`FilesystemKeyStore`].
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct StoredKeyInfo {
215    pub commitment: PublicKeyCommitment,
216    pub scheme: AuthScheme,
217    pub account_ids: BTreeSet<AccountId>,
218}
219
220impl Clone for FilesystemKeyStore {
221    fn clone(&self) -> Self {
222        let index = self.index.read().clone();
223        Self {
224            keys_directory: self.keys_directory.clone(),
225            index: RwLock::new(index),
226        }
227    }
228}
229
230impl FilesystemKeyStore {
231    /// Creates a [`FilesystemKeyStore`] on a specific directory.
232    pub fn new(keys_directory: PathBuf) -> Result<Self, KeyStoreError> {
233        if !keys_directory.exists() {
234            fs::create_dir_all(&keys_directory)
235                .map_err(keystore_error("error creating keys directory"))?;
236        }
237
238        let index = KeyIndex::read_from_file(&keys_directory)?;
239
240        Ok(FilesystemKeyStore {
241            keys_directory,
242            index: RwLock::new(index),
243        })
244    }
245
246    /// Stores a secret key without associating it with an account.
247    pub fn store_key(&self, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
248        let pub_key_commitment = key.public_key().to_commitment();
249        let file_path = key_file_path(&self.keys_directory, pub_key_commitment);
250        write_secret_key_file(&file_path, key)
251    }
252
253    /// Returns information about all secret keys in the keystore.
254    pub fn list_keys(&self) -> Result<Vec<StoredKeyInfo>, KeyStoreError> {
255        let index = self.index.read().clone();
256        let mut keys = Vec::new();
257
258        for entry in fs::read_dir(&self.keys_directory)
259            .map_err(keystore_error("error reading keys directory"))?
260        {
261            let entry = entry.map_err(keystore_error("error reading keys directory entry"))?;
262            if !entry
263                .file_type()
264                .map_err(keystore_error("error reading key file type"))?
265                .is_file()
266            {
267                continue;
268            }
269
270            let file_name = entry.file_name();
271            if file_name == INDEX_FILE_NAME {
272                continue;
273            }
274            let Some(file_name) = file_name.to_str() else {
275                continue;
276            };
277            let Ok(commitment) = Word::try_from(file_name).map(PublicKeyCommitment::from) else {
278                continue;
279            };
280            // A file that does not hold a readable key must not hide the keys that are readable. An
281            // interrupted write leaves such a file behind, so `list_keys` skips it and reports the
282            // keys it can read.
283            let Ok(Some(key)) = self.get_key_sync(commitment) else {
284                continue;
285            };
286            if key.public_key().to_commitment() != commitment {
287                continue;
288            }
289
290            keys.push(StoredKeyInfo {
291                commitment,
292                scheme: key.auth_scheme(),
293                account_ids: index.get_account_ids(commitment).unwrap_or_default(),
294            });
295        }
296
297        keys.sort_by_key(|key| Word::from(key.commitment).to_hex());
298        Ok(keys)
299    }
300
301    /// Returns all account IDs associated with a public key commitment.
302    pub fn account_ids_for_key(
303        &self,
304        pub_key_commitment: PublicKeyCommitment,
305    ) -> Result<BTreeSet<AccountId>, KeyStoreError> {
306        self.index.read().get_account_ids(pub_key_commitment)
307    }
308
309    /// Associates a stored key with an account.
310    pub fn associate_key(
311        &self,
312        pub_key_commitment: PublicKeyCommitment,
313        account_id: AccountId,
314    ) -> Result<(), KeyStoreError> {
315        let key = self.get_key_sync(pub_key_commitment)?.ok_or_else(|| {
316            KeyStoreError::StorageError(format!(
317                "secret key not found for commitment {}",
318                Word::from(pub_key_commitment).to_hex()
319            ))
320        })?;
321        if key.public_key().to_commitment() != pub_key_commitment {
322            return Err(KeyStoreError::DecodingError(format!(
323                "key file content does not match commitment {}",
324                Word::from(pub_key_commitment).to_hex()
325            )));
326        }
327
328        self.index.write().add_mapping(&account_id, pub_key_commitment);
329        self.save_index()
330    }
331
332    /// Removes the association between a stored key and an account.
333    ///
334    /// Returns `true` if the association was present. The index is written only when it changes.
335    pub fn disassociate_key(
336        &self,
337        pub_key_commitment: PublicKeyCommitment,
338        account_id: AccountId,
339    ) -> Result<bool, KeyStoreError> {
340        let removed = self.index.write().remove_mapping(&account_id, pub_key_commitment);
341        if !removed {
342            return Ok(false);
343        }
344
345        self.save_index()?;
346        Ok(true)
347    }
348
349    /// Retrieves a secret key from the keystore given the commitment of a public key.
350    pub fn get_key_sync(
351        &self,
352        pub_key: PublicKeyCommitment,
353    ) -> Result<Option<AuthSecretKey>, KeyStoreError> {
354        let file_path = key_file_path(&self.keys_directory, pub_key);
355        match fs::read(&file_path) {
356            Ok(bytes) => {
357                let key = AuthSecretKey::read_from_bytes(&bytes).map_err(|err| {
358                    KeyStoreError::DecodingError(format!(
359                        "error reading secret key from file: {err:?}"
360                    ))
361                })?;
362                Ok(Some(key))
363            },
364            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
365            Err(e) => Err(keystore_error("error reading secret key file")(e)),
366        }
367    }
368
369    /// Saves the index to disk.
370    fn save_index(&self) -> Result<(), KeyStoreError> {
371        let index = self.index.read();
372        index.write_to_file(&self.keys_directory)
373    }
374}
375
376impl TransactionAuthenticator for FilesystemKeyStore {
377    /// Gets a signature over a message, given a public key.
378    ///
379    /// The public key should correspond to one of the keys tracked by the keystore.
380    ///
381    /// # Errors
382    /// If the public key isn't found in the store, [`AuthenticationError::UnknownPublicKey`] is
383    /// returned.
384    // The trait declares this method as async; this implementation signs from local state and has
385    // nothing to await.
386    #[allow(clippy::unused_async_trait_impl, reason = "the trait signature is async")]
387    async fn get_signature(
388        &self,
389        pub_key: PublicKeyCommitment,
390        signing_info: &SigningInputs,
391    ) -> Result<Signature, AuthenticationError> {
392        let message = signing_info.to_commitment();
393
394        let secret_key = self
395            .get_key_sync(pub_key)
396            .map_err(|err| {
397                AuthenticationError::other_with_source("failed to load secret key", err)
398            })?
399            .ok_or(AuthenticationError::UnknownPublicKey(pub_key))?;
400
401        Ok(secret_key.sign(message))
402    }
403
404    /// Retrieves a public key for a specific public key commitment.
405    async fn get_public_key(
406        &self,
407        pub_key_commitment: PublicKeyCommitment,
408    ) -> Option<Arc<PublicKey>> {
409        self.get_key(pub_key_commitment)
410            .await
411            .ok()
412            .flatten()
413            .map(|key| Arc::new(key.public_key()))
414    }
415}
416
417#[async_trait::async_trait]
418impl Keystore for FilesystemKeyStore {
419    async fn add_key(
420        &self,
421        key: &AuthSecretKey,
422        account_id: AccountId,
423    ) -> Result<(), KeyStoreError> {
424        let pub_key_commitment = key.public_key().to_commitment();
425
426        self.store_key(key)?;
427
428        {
429            let mut index = self.index.write();
430            index.add_mapping(&account_id, pub_key_commitment);
431        }
432
433        // Persist the index
434        self.save_index()?;
435
436        Ok(())
437    }
438
439    async fn remove_key(&self, pub_key: PublicKeyCommitment) -> Result<(), KeyStoreError> {
440        // Remove from index first
441        {
442            let mut index = self.index.write();
443            index.remove_all_mappings_for_key(pub_key);
444        }
445
446        // Persist the index
447        self.save_index()?;
448
449        // Remove the key file
450        let file_path = key_file_path(&self.keys_directory, pub_key);
451        match fs::remove_file(file_path) {
452            Ok(()) => {},
453            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {},
454            Err(e) => return Err(keystore_error("error removing secret key file")(e)),
455        }
456
457        Ok(())
458    }
459
460    async fn get_key(
461        &self,
462        pub_key: PublicKeyCommitment,
463    ) -> Result<Option<AuthSecretKey>, KeyStoreError> {
464        self.get_key_sync(pub_key)
465    }
466
467    async fn get_account_id_by_key_commitment(
468        &self,
469        pub_key_commitment: PublicKeyCommitment,
470    ) -> Result<Option<AccountId>, KeyStoreError> {
471        let index = self.index.read();
472        Ok(index.get_account_id(pub_key_commitment))
473    }
474
475    async fn get_account_key_commitments(
476        &self,
477        account_id: &AccountId,
478    ) -> Result<BTreeSet<PublicKeyCommitment>, KeyStoreError> {
479        let index = self.index.read();
480        Ok(index.get_commitments(account_id))
481    }
482}
483
484// HELPERS
485// ================================================================================================
486
487/// Returns the file path that belongs to the public key commitment
488fn key_file_path(keys_directory: &Path, pub_key_commitment: PublicKeyCommitment) -> PathBuf {
489    let filename = Word::from(pub_key_commitment).to_hex();
490    keys_directory.join(filename)
491}
492
493/// Writes an [`AuthSecretKey`] into a file with restrictive permissions (0600 on Unix).
494#[cfg(unix)]
495fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
496    use std::io::Write;
497    use std::os::unix::fs::OpenOptionsExt;
498    let mut file = fs::OpenOptions::new()
499        .write(true)
500        .create(true)
501        .truncate(true)
502        .mode(0o600)
503        .open(file_path)
504        .map_err(keystore_error("error writing secret key file"))?;
505    file.write_all(&key.to_bytes())
506        .map_err(keystore_error("error writing secret key file"))
507}
508
509/// Writes an [`AuthSecretKey`] into a file.
510// TODO: on Windows, set restrictive ACLs to limit access to the current user.
511#[cfg(not(unix))]
512fn write_secret_key_file(file_path: &Path, key: &AuthSecretKey) -> Result<(), KeyStoreError> {
513    fs::write(file_path, key.to_bytes()).map_err(keystore_error("error writing secret key file"))
514}
515
516fn keystore_error(context: &str) -> impl FnOnce(std::io::Error) -> KeyStoreError {
517    move |err| KeyStoreError::StorageError(format!("{context}: {err:?}"))
518}
519
520// TESTS
521// ================================================================================================
522
523#[cfg(test)]
524mod tests {
525    use miden_protocol::account::auth::AuthSecretKey;
526    use miden_protocol::testing::account_id::{
527        ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE,
528        ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE,
529    };
530
531    use super::*;
532
533    /// Creates a keystore on a temporary directory. The directory is removed when the returned
534    /// guard is dropped, so the guard must stay alive for the whole test.
535    fn test_keystore() -> (FilesystemKeyStore, tempfile::TempDir) {
536        let dir = tempfile::tempdir().expect("should create a temporary directory");
537        let keystore = FilesystemKeyStore::new(dir.path().to_path_buf())
538            .expect("should create a keystore on an existing directory");
539
540        (keystore, dir)
541    }
542
543    fn test_account_id() -> AccountId {
544        AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE)
545            .expect("test account ID should be well formed")
546    }
547
548    /// Returns a commitment that no generated key produces, so the keystore never holds a key for
549    /// it.
550    fn unused_commitment() -> Word {
551        Word::try_from("0x0000000000000000000000000000000000000000000000000000000000000001")
552            .expect("the test commitment is a valid word")
553    }
554
555    fn other_account_id() -> AccountId {
556        AccountId::try_from(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_IMMUTABLE_CODE)
557            .expect("test account ID should be well formed")
558    }
559
560    #[test]
561    fn standalone_key_is_listed_without_an_account() {
562        let (keystore, _dir) = test_keystore();
563        let key = AuthSecretKey::new_ecdsa_k256_keccak();
564        let commitment = key.public_key().to_commitment();
565
566        keystore.store_key(&key).unwrap();
567
568        let stored_keys = keystore.list_keys().unwrap();
569        assert_eq!(stored_keys.len(), 1);
570        assert_eq!(stored_keys[0].commitment, commitment);
571        assert_eq!(stored_keys[0].scheme, key.auth_scheme());
572        assert!(stored_keys[0].account_ids.is_empty());
573        assert!(keystore.account_ids_for_key(commitment).unwrap().is_empty());
574    }
575
576    #[tokio::test]
577    async fn key_commitments_of_untracked_account_are_empty() {
578        let (keystore, _dir) = test_keystore();
579
580        let commitments = keystore
581            .get_account_key_commitments(&test_account_id())
582            .await
583            .expect("an account without keys is not an error");
584        assert!(commitments.is_empty());
585
586        let keys = keystore
587            .get_keys_for_account(&test_account_id())
588            .await
589            .expect("an account without keys is not an error");
590        assert!(keys.is_empty());
591    }
592
593    #[tokio::test]
594    async fn key_commitments_are_scoped_to_their_account() {
595        let (keystore, _dir) = test_keystore();
596        let key = AuthSecretKey::new_falcon512_poseidon2();
597        let commitment = key.public_key().to_commitment();
598
599        keystore.add_key(&key, test_account_id()).await.unwrap();
600
601        let commitments = keystore.get_account_key_commitments(&test_account_id()).await.unwrap();
602        assert_eq!(commitments.len(), 1);
603        assert!(commitments.contains(&commitment));
604
605        let account_ids = keystore.account_ids_for_key(commitment).unwrap();
606        assert_eq!(account_ids, BTreeSet::from([test_account_id()]));
607
608        let commitments = keystore.get_account_key_commitments(&other_account_id()).await.unwrap();
609        assert!(commitments.is_empty());
610
611        keystore.disassociate_key(commitment, test_account_id()).unwrap();
612        assert!(keystore.account_ids_for_key(commitment).unwrap().is_empty());
613        assert!(keystore.get_key_sync(commitment).unwrap().is_some());
614    }
615
616    #[tokio::test]
617    async fn key_commitments_are_empty_after_the_last_key_is_removed() {
618        let (keystore, _dir) = test_keystore();
619        let key = AuthSecretKey::new_falcon512_poseidon2();
620
621        keystore.add_key(&key, test_account_id()).await.unwrap();
622        keystore.remove_key(key.public_key().to_commitment()).await.unwrap();
623
624        let commitments = keystore
625            .get_account_key_commitments(&test_account_id())
626            .await
627            .expect("removing the last key of an account is not an error");
628        assert!(commitments.is_empty());
629    }
630
631    /// A key can back more than one account. Removing one association must keep the others, and a
632    /// removal that changes nothing must say so.
633    #[tokio::test]
634    async fn disassociating_a_key_affects_only_the_named_account() {
635        let (keystore, _dir) = test_keystore();
636        let shared_key = AuthSecretKey::new_falcon512_poseidon2();
637        let shared_commitment = shared_key.public_key().to_commitment();
638
639        keystore.add_key(&shared_key, test_account_id()).await.unwrap();
640        keystore.add_key(&shared_key, other_account_id()).await.unwrap();
641
642        assert!(keystore.disassociate_key(shared_commitment, test_account_id()).unwrap());
643        assert_eq!(
644            keystore.account_ids_for_key(shared_commitment).unwrap(),
645            BTreeSet::from([other_account_id()]),
646            "the key must stay associated with the account that was not named"
647        );
648
649        assert!(
650            !keystore.disassociate_key(shared_commitment, test_account_id()).unwrap(),
651            "the association is already gone"
652        );
653        assert!(
654            !keystore
655                .disassociate_key(unused_commitment().into(), other_account_id())
656                .unwrap(),
657            "no key is stored for this commitment"
658        );
659        assert_eq!(
660            keystore.account_ids_for_key(shared_commitment).unwrap(),
661            BTreeSet::from([other_account_id()]),
662            "a call that changes nothing must not drop an existing association"
663        );
664    }
665
666    /// An interrupted write leaves a file that holds no readable key. The keys that are readable
667    /// must still be listed.
668    #[test]
669    fn unreadable_key_file_is_skipped_by_the_listing() {
670        let (keystore, dir) = test_keystore();
671        let key = AuthSecretKey::new_falcon512_poseidon2();
672        let commitment = key.public_key().to_commitment();
673
674        keystore.store_key(&key).unwrap();
675
676        // A truncated key file under a name that is a valid commitment.
677        fs::write(dir.path().join(unused_commitment().to_hex()), [1, 2, 3]).unwrap();
678        // A file whose name is not a commitment at all.
679        fs::write(dir.path().join(".DS_Store"), []).unwrap();
680
681        let listed = keystore.list_keys().unwrap();
682        assert_eq!(listed.len(), 1);
683        assert_eq!(listed[0].commitment, commitment);
684    }
685}