ic_auth_client/storage/sync_storage/
pem.rs

1//! File-based storage implementation for native environments.
2//!
3//! This storage backend persists both private keys and delegation chains to the filesystem,
4//! allowing usage in environments where an OS keyring is not available.
5
6use crate::storage::{
7    DecodeError, KEY_STORAGE_KEY, StorageError, StoredKey, sync_storage::AuthClientStorage,
8};
9use base64::prelude::{BASE64_STANDARD_NO_PAD, Engine as _};
10use pkcs8::{
11    LineEnding, ObjectIdentifier, PrivateKeyInfo, SecretDocument, der::pem::PemLabel,
12    spki::AlgorithmIdentifierRef,
13};
14use serde::{Deserialize, Serialize};
15use std::{
16    fs,
17    io::ErrorKind,
18    path::{Path, PathBuf},
19};
20
21const PEM_STORAGE_PREFIX: &str = "ic-";
22const STORAGE_FILE_EXTENSION: &str = "json";
23const KEY_FILE_EXTENSION: &str = "pem";
24const ED25519_OID: &str = "1.3.101.112";
25
26/// File-based storage backend that persists values to JSON files on disk.
27#[derive(Debug, Clone)]
28pub struct PemStorage {
29    directory: PathBuf,
30}
31
32impl PemStorage {
33    /// Creates a new instance of [`PemStorage`].
34    ///
35    /// # Arguments
36    ///
37    /// * `directory` - The directory where the storage files will be stored.
38    ///
39    /// # Returns
40    ///
41    /// A new instance of [`PemStorage`].
42    pub fn new(directory: PathBuf) -> Self {
43        Self { directory }
44    }
45
46    /// Imports a PEM file containing an Ed25519 private key and stores it using the storage format.
47    pub fn import_private_key_from_pem_file<P: AsRef<Path>>(
48        &mut self,
49        path: P,
50    ) -> Result<(), StorageError> {
51        let raw_key = Self::decode_pem_private_key_from_path(path.as_ref())?;
52        self.write_private_key_pem(&raw_key)?;
53        Ok(())
54    }
55
56    fn ensure_directory(&self) -> Result<(), StorageError> {
57        if self.directory.as_os_str().is_empty() {
58            return Ok(()); // current directory
59        }
60        fs::create_dir_all(&self.directory)?;
61        Ok(())
62    }
63
64    fn file_path(&self, key: &str) -> PathBuf {
65        let sanitized_key = sanitize_key(key);
66        self.directory.join(format!(
67            "{PEM_STORAGE_PREFIX}{sanitized_key}.{STORAGE_FILE_EXTENSION}"
68        ))
69    }
70
71    fn key_file_path(&self) -> PathBuf {
72        self.directory.join(format!(
73            "{PEM_STORAGE_PREFIX}{KEY_STORAGE_KEY}.{KEY_FILE_EXTENSION}"
74        ))
75    }
76
77    fn read_private_key_pem(&self) -> Result<Option<[u8; 32]>, StorageError> {
78        let path = self.key_file_path();
79        match fs::read_to_string(&path) {
80            Ok(contents) => Self::decode_pem_private_key(&contents).map(Some),
81            Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
82            Err(e) => Err(StorageError::from(e)),
83        }
84    }
85
86    fn write_private_key_pem(&self, key: &[u8; 32]) -> Result<(), StorageError> {
87        self.ensure_directory()?;
88        let algorithm = AlgorithmIdentifierRef {
89            oid: ObjectIdentifier::new_unwrap(ED25519_OID),
90            parameters: None,
91        };
92        let info = PrivateKeyInfo::new(algorithm, key);
93        let document =
94            SecretDocument::encode_msg(&info).map_err(|e| StorageError::File(e.to_string()))?;
95        let pem = document
96            .to_pem(PrivateKeyInfo::PEM_LABEL, LineEnding::LF)
97            .map_err(|e| StorageError::File(e.to_string()))?;
98        fs::write(self.key_file_path(), pem.as_bytes())?;
99        Ok(())
100    }
101
102    fn decode_pem_private_key(contents: &str) -> Result<[u8; 32], StorageError> {
103        let (_, document) =
104            SecretDocument::from_pem(contents).map_err(|e| StorageError::File(e.to_string()))?;
105        let info: PrivateKeyInfo<'_> = document
106            .decode_msg()
107            .map_err(|e| StorageError::File(e.to_string()))?;
108        if info.algorithm.oid != ObjectIdentifier::new_unwrap(ED25519_OID) {
109            return Err(StorageError::Decode(DecodeError::Ed25519(
110                "Unsupported key algorithm".to_string(),
111            )));
112        }
113        let bytes: [u8; 32] = info
114            .private_key
115            .try_into()
116            .map_err(|_| StorageError::Decode(DecodeError::Ed25519("Invalid key length".into())))?;
117        Ok(bytes)
118    }
119
120    fn decode_pem_private_key_from_path(path: &Path) -> Result<[u8; 32], StorageError> {
121        let data = fs::read_to_string(path)?;
122        Self::decode_pem_private_key(&data)
123    }
124
125    fn read_json_value(&self, key: &str) -> Result<Option<StoredKey>, StorageError> {
126        let path = self.file_path(key);
127        match fs::read_to_string(&path) {
128            Ok(contents) => {
129                let value: PemStoredValue = serde_json::from_str(&contents)
130                    .map_err(|e| StorageError::File(e.to_string()))?;
131                Ok(Some(StoredKey::try_from(value)?))
132            }
133            Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
134            Err(e) => Err(StorageError::from(e)),
135        }
136    }
137
138    fn write_json_value(&mut self, key: &str, value: StoredKey) -> Result<(), StorageError> {
139        self.ensure_directory()?;
140        let path = self.file_path(key);
141        let serialized = serde_json::to_string(&PemStoredValue::from(&value))
142            .map_err(|e| StorageError::File(e.to_string()))?;
143        fs::write(path, serialized)?;
144        Ok(())
145    }
146
147    fn remove_json_file(&self, key: &str) -> Result<(), StorageError> {
148        let path = self.file_path(key);
149        match fs::remove_file(&path) {
150            Ok(_) => Ok(()),
151            Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
152            Err(e) => Err(StorageError::from(e)),
153        }
154    }
155
156    fn stored_key_to_raw(value: StoredKey) -> Result<[u8; 32], StorageError> {
157        match value {
158            StoredKey::Raw(bytes) => Ok(bytes),
159            StoredKey::String(string) => {
160                let stored = StoredKey::String(string);
161                stored.decode().map_err(StorageError::from)
162            }
163        }
164    }
165}
166
167fn sanitize_key(key: &str) -> String {
168    key.chars()
169        .map(|c| {
170            if matches!(c, '/' | '\\' | ':' | '*') {
171                '_'
172            } else {
173                c
174            }
175        })
176        .collect()
177}
178
179#[derive(Debug, Serialize, Deserialize)]
180#[serde(tag = "type", content = "value")]
181enum PemStoredValue {
182    Raw(String),
183    String(String),
184}
185
186impl From<&StoredKey> for PemStoredValue {
187    fn from(value: &StoredKey) -> Self {
188        match value {
189            StoredKey::Raw(bytes) => {
190                PemStoredValue::Raw(BASE64_STANDARD_NO_PAD.encode(bytes.as_slice()))
191            }
192            StoredKey::String(string) => PemStoredValue::String(string.clone()),
193        }
194    }
195}
196
197impl TryFrom<PemStoredValue> for StoredKey {
198    type Error = DecodeError;
199
200    fn try_from(value: PemStoredValue) -> Result<Self, Self::Error> {
201        match value {
202            PemStoredValue::Raw(data) => {
203                let decoded = BASE64_STANDARD_NO_PAD
204                    .decode(data)
205                    .map_err(DecodeError::Base64)?;
206                StoredKey::try_from(decoded)
207            }
208            PemStoredValue::String(string) => Ok(StoredKey::String(string)),
209        }
210    }
211}
212
213impl AuthClientStorage for PemStorage {
214    fn get(&mut self, key: &str) -> Result<Option<StoredKey>, StorageError> {
215        if key == KEY_STORAGE_KEY {
216            if let Some(raw_key) = self.read_private_key_pem()? {
217                return Ok(Some(StoredKey::Raw(raw_key)));
218            }
219            if let Some(legacy) = self.read_json_value(key)? {
220                let raw = legacy.decode().map_err(StorageError::from)?;
221                self.write_private_key_pem(&raw)?;
222                let _ = self.remove_json_file(key);
223                return Ok(Some(StoredKey::Raw(raw)));
224            }
225            return Ok(None);
226        }
227        self.read_json_value(key)
228    }
229
230    fn set(&mut self, key: &str, value: StoredKey) -> Result<(), StorageError> {
231        if key == KEY_STORAGE_KEY {
232            let raw = Self::stored_key_to_raw(value)?;
233            self.write_private_key_pem(&raw)?;
234            let _ = self.remove_json_file(key);
235            return Ok(());
236        }
237        self.write_json_value(key, value)
238    }
239
240    fn remove(&mut self, key: &str) -> Result<(), StorageError> {
241        if key == KEY_STORAGE_KEY {
242            let path = self.key_file_path();
243            match fs::remove_file(&path) {
244                Ok(_) => (),
245                Err(e) if e.kind() == ErrorKind::NotFound => (),
246                Err(e) => return Err(StorageError::from(e)),
247            }
248            let _ = self.remove_json_file(key);
249            return Ok(());
250        }
251        self.remove_json_file(key)
252    }
253}
254
255impl From<PemStorage> for Box<dyn AuthClientStorage> {
256    fn from(storage: PemStorage) -> Self {
257        Box::new(storage)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::time::{SystemTime, UNIX_EPOCH};
265
266    fn temp_directory() -> PathBuf {
267        let mut path = std::env::temp_dir();
268        let unique = SystemTime::now()
269            .duration_since(UNIX_EPOCH)
270            .unwrap()
271            .as_nanos();
272        path.push(format!("ic-auth-client-test-{unique}"));
273        path
274    }
275
276    #[test]
277    fn pem_storage_persists_raw_keys() {
278        let dir = temp_directory();
279        let mut storage = PemStorage::new(dir.clone());
280        let key = [42u8; 32];
281        storage
282            .set("identity", StoredKey::from(key))
283            .expect("store key");
284        let retrieved = storage.get("identity").expect("read key").unwrap();
285        assert_eq!(retrieved.decode().unwrap(), key);
286        let _ = fs::remove_dir_all(dir);
287    }
288
289    #[test]
290    fn pem_storage_persists_strings() {
291        let dir = temp_directory();
292        let mut storage = PemStorage::new(dir.clone());
293        storage
294            .set("delegation", StoredKey::String("value".into()))
295            .expect("store value");
296        let retrieved = storage.get("delegation").expect("read value").unwrap();
297        assert_eq!(retrieved.encode(), "value");
298        storage.remove("delegation").expect("remove");
299        let after_remove = storage.get("delegation").expect("read missing");
300        assert!(after_remove.is_none());
301        let _ = fs::remove_dir_all(dir);
302    }
303
304    #[test]
305    fn pem_storage_persists_identity_as_pem() {
306        let dir = temp_directory();
307        let mut storage = PemStorage::new(dir.clone());
308        let key = [7u8; 32];
309        storage
310            .set(KEY_STORAGE_KEY, StoredKey::from(key))
311            .expect("store key");
312        let retrieved = storage
313            .get(KEY_STORAGE_KEY)
314            .expect("read key")
315            .expect("missing key");
316        assert_eq!(retrieved.decode().unwrap(), key);
317        let pem_key = storage
318            .read_private_key_pem()
319            .expect("read pem")
320            .expect("missing pem");
321        assert_eq!(pem_key, key);
322        let _ = fs::remove_dir_all(dir);
323    }
324
325    #[test]
326    fn pem_storage_migrates_legacy_identity_json() {
327        let dir = temp_directory();
328        let mut storage = PemStorage::new(dir.clone());
329        let key = [9u8; 32];
330        storage
331            .write_json_value(KEY_STORAGE_KEY, StoredKey::from(key))
332            .expect("write legacy json");
333        let legacy_path = storage.file_path(KEY_STORAGE_KEY);
334        assert!(legacy_path.exists());
335
336        let retrieved = storage
337            .get(KEY_STORAGE_KEY)
338            .expect("read key")
339            .expect("missing key");
340        assert_eq!(retrieved.decode().unwrap(), key);
341        assert!(storage.key_file_path().exists());
342        assert!(!legacy_path.exists());
343        let _ = fs::remove_dir_all(dir);
344    }
345}