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
27const INDEX_FILE_NAME: &str = "key_index.json";
31const INDEX_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35struct KeyIndex {
36 version: u32,
37 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 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 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 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 self.mappings.retain(|_, commitments| {
87 commitments.remove(&pub_key_hex);
88 !commitments.is_empty()
89 });
90 }
91
92 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 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 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 temp_file
130 .persist(&index_path)
131 .map_err(|err| keystore_error("error renaming index file")(err.error))?;
132
133 Ok(())
134 }
135
136 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 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 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#[derive(Debug)]
205pub struct FilesystemKeyStore {
206 pub keys_directory: PathBuf,
208 index: RwLock<KeyIndex>,
210}
211
212#[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 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 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 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 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 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 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 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 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 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 #[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 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 self.save_index()?;
435
436 Ok(())
437 }
438
439 async fn remove_key(&self, pub_key: PublicKeyCommitment) -> Result<(), KeyStoreError> {
440 {
442 let mut index = self.index.write();
443 index.remove_all_mappings_for_key(pub_key);
444 }
445
446 self.save_index()?;
448
449 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
484fn 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#[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#[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#[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 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 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 #[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 #[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 fs::write(dir.path().join(unused_commitment().to_hex()), [1, 2, 3]).unwrap();
678 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}