iroh_db_security/
credentials.rs

1use std::{
2    fs::OpenOptions,
3    io::Write as _,
4    path::{Path, PathBuf},
5};
6
7use chacha20poly1305::{
8    XChaCha20Poly1305, XNonce,
9    aead::{Aead as _, KeyInit as _, Payload},
10};
11use hkdf::Hkdf;
12use iroh_db_core::DomainId;
13use sha2::Sha256;
14use zeroize::Zeroizing;
15
16use crate::{DomainKey, IrohSigner, SecurityError};
17
18const MASTER_FILE: &str = "master.key";
19const CREDENTIALS_FILE: &str = "credentials.v1";
20const MAGIC: &[u8; 8] = b"IDBKEY01";
21const DOMAIN_MAGIC: &[u8; 8] = b"IDBDOM01";
22const PLAINTEXT_LEN: usize = 96;
23const DOMAIN_PLAINTEXT_LEN: usize = 72;
24const HEX: &[u8; 16] = b"0123456789abcdef";
25
26/// Sensitive bootstrap material for adding a new device to an encrypted domain.
27#[derive(Clone)]
28pub struct DomainSeed {
29    domain_id: DomainId,
30    epoch: u64,
31    domain_key: DomainKey,
32}
33
34impl DomainSeed {
35    /// Creates a fresh encrypted domain seed.
36    pub fn generate() -> Result<Self, SecurityError> {
37        let mut domain_id = [0_u8; 32];
38        getrandom::fill(&mut domain_id).map_err(|_| SecurityError::RandomnessUnavailable)?;
39        Ok(Self {
40            domain_id: DomainId::from_bytes(domain_id),
41            epoch: 0,
42            domain_key: DomainKey::generate()?,
43        })
44    }
45
46    /// Returns the stable domain identifier.
47    pub const fn domain_id(&self) -> DomainId {
48        self.domain_id
49    }
50
51    /// Returns the active encryption epoch represented by this seed.
52    pub const fn epoch(&self) -> u64 {
53        self.epoch
54    }
55
56    /// Returns the epoch-zero key for a trusted local key provider.
57    pub fn domain_key(&self) -> DomainKey {
58        self.domain_key.clone()
59    }
60
61    /// Exposes key bytes only to an authenticated epoch-distribution protocol.
62    #[doc(hidden)]
63    pub const fn epoch_key_bytes(&self) -> [u8; 32] {
64        *self.domain_key.as_bytes()
65    }
66
67    /// Creates the next epoch for this stable domain using a fresh random key.
68    pub fn next_epoch(&self) -> Result<Self, SecurityError> {
69        Ok(Self {
70            domain_id: self.domain_id,
71            epoch: self
72                .epoch
73                .checked_add(1)
74                .ok_or(SecurityError::InvalidKeyProviderData)?,
75            domain_key: DomainKey::generate()?,
76        })
77    }
78
79    /// Returns a non-secret commitment used to bind targeted key distribution to control.
80    pub fn distribution_digest(&self) -> [u8; 32] {
81        let mut input = Vec::with_capacity(72);
82        input.extend_from_slice(self.domain_id.as_bytes());
83        input.extend_from_slice(&self.epoch.to_be_bytes());
84        input.extend_from_slice(self.domain_key.as_bytes());
85        *blake3::hash(&input).as_bytes()
86    }
87
88    pub(crate) const fn from_parts(domain_id: DomainId, epoch: u64, domain_key: DomainKey) -> Self {
89        Self {
90            domain_id,
91            epoch,
92            domain_key,
93        }
94    }
95
96    /// Reconstructs a digest-verified epoch grant received over authenticated transport.
97    #[doc(hidden)]
98    pub const fn from_epoch_key(domain_id: DomainId, epoch: u64, key: [u8; 32]) -> Self {
99        Self::from_parts(domain_id, epoch, DomainKey::from_bytes(key))
100    }
101}
102
103impl std::fmt::Debug for DomainSeed {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        formatter.write_str("DomainSeed([redacted])")
106    }
107}
108
109/// Persistent private-domain identity and epoch-zero key material.
110#[derive(Clone)]
111pub struct LocalCredentials {
112    signer: IrohSigner,
113    domain_key: DomainKey,
114    domain_id: DomainId,
115    root: PathBuf,
116}
117
118/// Application-supplied durable device identity and encrypted domain-key storage.
119///
120/// Implementations must make catalog updates durable before returning and must
121/// never return a key for a different domain or epoch than requested.
122pub trait KeyProvider: Send + Sync {
123    /// Returns the signer used for immutable database protocol objects.
124    fn signer(&self) -> IrohSigner;
125    /// Returns the endpoint secret used by the iroh transport identity.
126    fn endpoint_secret_key(&self) -> iroh_base::SecretKey;
127    /// Returns the provider's default private domain identifier.
128    fn domain_id(&self) -> DomainId;
129    /// Returns the provider's default private domain seed.
130    fn domain_seed(&self) -> DomainSeed;
131    /// Returns the stable default-domain key used for local authenticated metadata.
132    fn domain_key(&self) -> DomainKey {
133        self.domain_seed().domain_key()
134    }
135    /// Persists a newly attached domain seed.
136    fn store_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError>;
137    /// Durably stages an adjacent epoch without activating it.
138    fn stage_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError>;
139    /// Atomically selects a retained domain epoch as active.
140    fn activate_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError>;
141    /// Loads the active seed for one domain.
142    fn load_domain(&self, domain_id: DomainId) -> Result<DomainSeed, SecurityError>;
143    /// Loads one retained historical domain epoch.
144    fn load_domain_epoch(
145        &self,
146        domain_id: DomainId,
147        epoch: u64,
148    ) -> Result<DomainSeed, SecurityError>;
149    /// Lists the domains available to this device.
150    fn domains(&self) -> Result<Vec<DomainId>, SecurityError>;
151}
152
153impl LocalCredentials {
154    /// Loads an existing owner-only encrypted local key vault without creating files.
155    pub fn load(root: impl AsRef<Path>) -> Result<Self, SecurityError> {
156        let root = root.as_ref();
157        let master = load_master(&root.join(MASTER_FILE))?;
158        let credentials_path = root.join(CREDENTIALS_FILE);
159        check_owner_only(&credentials_path)?;
160        let bytes = std::fs::read(credentials_path).map_err(io_error)?;
161        let mut credentials = decode_credentials(&bytes, &master)?;
162        credentials.root = root.to_path_buf();
163        Ok(credentials)
164    }
165
166    /// Loads or creates an owner-only encrypted local key vault.
167    pub fn load_or_create(root: impl AsRef<Path>) -> Result<Self, SecurityError> {
168        let root = root.as_ref();
169        std::fs::create_dir_all(root).map_err(io_error)?;
170        let master_path = root.join(MASTER_FILE);
171        let master = load_or_create_master(&master_path)?;
172        let credentials_path = root.join(CREDENTIALS_FILE);
173        if credentials_path.exists() {
174            check_owner_only(&credentials_path)?;
175            let bytes = std::fs::read(credentials_path).map_err(io_error)?;
176            let mut credentials = decode_credentials(&bytes, &master)?;
177            credentials.root = root.to_path_buf();
178            Ok(credentials)
179        } else {
180            let credentials = Self::generate(root.to_path_buf())?;
181            let bytes = encode_credentials(&credentials, &master)?;
182            write_owner_only(&credentials_path, &bytes)?;
183            Ok(credentials)
184        }
185    }
186
187    /// Creates a new device identity in an existing trusted domain.
188    pub fn create_for_domain(
189        root: impl AsRef<Path>,
190        seed: &DomainSeed,
191    ) -> Result<Self, SecurityError> {
192        let root = root.as_ref();
193        std::fs::create_dir_all(root).map_err(io_error)?;
194        let master = load_or_create_master(&root.join(MASTER_FILE))?;
195        let credentials_path = root.join(CREDENTIALS_FILE);
196        if credentials_path.exists() {
197            return Err(SecurityError::InvalidKeyProviderData);
198        }
199        let credentials = Self {
200            signer: IrohSigner::generate(),
201            domain_key: seed.domain_key(),
202            domain_id: seed.domain_id(),
203            root: root.to_path_buf(),
204        };
205        write_owner_only(
206            &credentials_path,
207            &encode_credentials(&credentials, &master)?,
208        )?;
209        Ok(credentials)
210    }
211
212    /// Returns the iroh endpoint signer used by this database.
213    pub fn signer(&self) -> IrohSigner {
214        self.signer.clone()
215    }
216
217    /// Returns the epoch-zero private-domain key.
218    pub fn domain_key(&self) -> DomainKey {
219        self.domain_key.clone()
220    }
221
222    /// Returns the stable default private-domain ID.
223    pub const fn domain_id(&self) -> DomainId {
224        self.domain_id
225    }
226
227    /// Exports sensitive domain bootstrap material for a trusted invitation flow.
228    pub fn domain_seed(&self) -> DomainSeed {
229        DomainSeed {
230            domain_id: self.domain_id,
231            epoch: 0,
232            domain_key: self.domain_key.clone(),
233        }
234    }
235
236    /// Persists a domain seed in the owner-only encrypted device catalog.
237    pub fn store_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
238        let directory = self.root.join("domains");
239        std::fs::create_dir_all(&directory).map_err(io_error)?;
240        let path = directory.join(format!("{}.v1", seed.domain_id));
241        if path.exists() {
242            let existing = self.load_domain(seed.domain_id)?;
243            if existing.epoch == seed.epoch
244                && existing.domain_key.as_bytes() == seed.domain_key.as_bytes()
245            {
246                return Ok(());
247            }
248            return Err(SecurityError::InvalidKeyProviderData);
249        }
250        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
251        let bytes = encode_domain_seed(seed, &master)?;
252        store_epoch_archive(&directory, seed, &master)?;
253        write_owner_only(&path, &bytes)
254    }
255
256    /// Atomically advances a catalog domain while retaining both epoch keys locally.
257    pub fn replace_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
258        let current = self.load_domain(seed.domain_id)?;
259        if seed.epoch
260            != current
261                .epoch
262                .checked_add(1)
263                .ok_or(SecurityError::InvalidKeyProviderData)?
264        {
265            return Err(SecurityError::InvalidKeyProviderData);
266        }
267        let directory = self.root.join("domains");
268        std::fs::create_dir_all(&directory).map_err(io_error)?;
269        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
270        store_epoch_archive(&directory, &current, &master)?;
271        store_epoch_archive(&directory, seed, &master)?;
272        let bytes = encode_domain_seed(seed, &master)?;
273        write_owner_only_replace(&directory.join(format!("{}.v1", seed.domain_id)), &bytes)
274    }
275
276    /// Durably archives the next epoch key without changing the active catalog entry.
277    #[doc(hidden)]
278    pub fn stage_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
279        let current = self.load_domain(seed.domain_id)?;
280        if seed.epoch == current.epoch
281            && seed.domain_key.as_bytes() == current.domain_key.as_bytes()
282        {
283            return Ok(());
284        }
285        if seed.epoch
286            != current
287                .epoch
288                .checked_add(1)
289                .ok_or(SecurityError::InvalidKeyProviderData)?
290        {
291            return Err(SecurityError::InvalidKeyProviderData);
292        }
293        let directory = self.root.join("domains");
294        std::fs::create_dir_all(&directory).map_err(io_error)?;
295        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
296        store_epoch_archive(&directory, seed, &master)
297    }
298
299    /// Selects a retained epoch as active, including rotation rollback after metadata failure.
300    #[doc(hidden)]
301    pub fn activate_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
302        let directory = self.root.join("domains");
303        std::fs::create_dir_all(&directory).map_err(io_error)?;
304        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
305        store_epoch_archive(&directory, seed, &master)?;
306        write_owner_only_replace(
307            &directory.join(format!("{}.v1", seed.domain_id)),
308            &encode_domain_seed(seed, &master)?,
309        )
310    }
311
312    /// Loads one domain seed from the encrypted device catalog.
313    pub fn load_domain(&self, domain_id: DomainId) -> Result<DomainSeed, SecurityError> {
314        if domain_id == self.domain_id {
315            let catalog = self.root.join("domains").join(format!("{domain_id}.v1"));
316            if !catalog.exists() {
317                return Ok(self.domain_seed());
318            }
319        }
320        let path = self.root.join("domains").join(format!("{domain_id}.v1"));
321        check_owner_only(&path)?;
322        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
323        let bytes = std::fs::read(path).map_err(io_error)?;
324        let seed = decode_domain_seed(&bytes, &master)?;
325        if seed.domain_id != domain_id {
326            return Err(SecurityError::InvalidKeyProviderData);
327        }
328        Ok(seed)
329    }
330
331    /// Loads a retained historical epoch key without changing the active catalog entry.
332    pub fn load_domain_epoch(
333        &self,
334        domain_id: DomainId,
335        epoch: u64,
336    ) -> Result<DomainSeed, SecurityError> {
337        let current = self.load_domain(domain_id)?;
338        if current.epoch == epoch {
339            return Ok(current);
340        }
341        if domain_id == self.domain_id && epoch == 0 {
342            return Ok(self.domain_seed());
343        }
344        let path = self
345            .root
346            .join("domains")
347            .join(format!("{domain_id}.epoch-{epoch}.v1"));
348        check_owner_only(&path)?;
349        let master = load_or_create_master(&self.root.join(MASTER_FILE))?;
350        let bytes = std::fs::read(path).map_err(io_error)?;
351        let seed = decode_domain_seed(&bytes, &master)?;
352        if seed.domain_id != domain_id || seed.epoch != epoch {
353            return Err(SecurityError::InvalidKeyProviderData);
354        }
355        Ok(seed)
356    }
357
358    /// Lists every domain available to this device.
359    pub fn domains(&self) -> Result<Vec<DomainId>, SecurityError> {
360        let mut domains = vec![self.domain_id];
361        let directory = self.root.join("domains");
362        if directory.exists() {
363            for entry in std::fs::read_dir(directory).map_err(io_error)? {
364                let entry = entry.map_err(io_error)?;
365                let name = entry.file_name();
366                let Some(name) = name.to_str() else {
367                    continue;
368                };
369                let Some(id) = name.strip_suffix(".v1") else {
370                    continue;
371                };
372                let Ok(id) = id.parse() else {
373                    continue;
374                };
375                domains.push(id);
376            }
377        }
378        domains.sort_unstable();
379        domains.dedup();
380        Ok(domains)
381    }
382
383    /// Returns the same secret identity used to authenticate the iroh endpoint.
384    pub fn endpoint_secret_key(&self) -> iroh_base::SecretKey {
385        iroh_base::SecretKey::from_bytes(&self.signer.secret_bytes())
386    }
387
388    fn generate(root: PathBuf) -> Result<Self, SecurityError> {
389        let mut signer = [0_u8; 32];
390        let mut domain_key = [0_u8; 32];
391        let mut domain_id = [0_u8; 32];
392        getrandom::fill(&mut signer).map_err(|_| SecurityError::RandomnessUnavailable)?;
393        getrandom::fill(&mut domain_key).map_err(|_| SecurityError::RandomnessUnavailable)?;
394        getrandom::fill(&mut domain_id).map_err(|_| SecurityError::RandomnessUnavailable)?;
395        Ok(Self {
396            signer: IrohSigner::from_secret_bytes(signer),
397            domain_key: DomainKey::from_bytes(domain_key),
398            domain_id: DomainId::from_bytes(domain_id),
399            root,
400        })
401    }
402}
403
404impl std::fmt::Debug for LocalCredentials {
405    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        formatter.write_str("LocalCredentials([redacted])")
407    }
408}
409
410impl KeyProvider for LocalCredentials {
411    fn signer(&self) -> IrohSigner {
412        Self::signer(self)
413    }
414
415    fn endpoint_secret_key(&self) -> iroh_base::SecretKey {
416        Self::endpoint_secret_key(self)
417    }
418
419    fn domain_id(&self) -> DomainId {
420        Self::domain_id(self)
421    }
422
423    fn domain_seed(&self) -> DomainSeed {
424        Self::domain_seed(self)
425    }
426
427    fn store_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
428        Self::store_domain(self, seed)
429    }
430
431    fn stage_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
432        Self::stage_domain(self, seed)
433    }
434
435    fn activate_domain(&self, seed: &DomainSeed) -> Result<(), SecurityError> {
436        Self::activate_domain(self, seed)
437    }
438
439    fn load_domain(&self, domain_id: DomainId) -> Result<DomainSeed, SecurityError> {
440        Self::load_domain(self, domain_id)
441    }
442
443    fn load_domain_epoch(
444        &self,
445        domain_id: DomainId,
446        epoch: u64,
447    ) -> Result<DomainSeed, SecurityError> {
448        Self::load_domain_epoch(self, domain_id, epoch)
449    }
450
451    fn domains(&self) -> Result<Vec<DomainId>, SecurityError> {
452        Self::domains(self)
453    }
454}
455
456fn load_or_create_master(path: &Path) -> Result<Zeroizing<[u8; 32]>, SecurityError> {
457    if path.exists() {
458        return load_master(path);
459    }
460    let mut bytes = Zeroizing::new([0_u8; 32]);
461    getrandom::fill(&mut *bytes).map_err(|_| SecurityError::RandomnessUnavailable)?;
462    write_owner_only(path, &*bytes)?;
463    Ok(bytes)
464}
465
466fn load_master(path: &Path) -> Result<Zeroizing<[u8; 32]>, SecurityError> {
467    check_owner_only(path)?;
468    let bytes = std::fs::read(path).map_err(io_error)?;
469    let bytes: [u8; 32] = bytes
470        .try_into()
471        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
472    Ok(Zeroizing::new(bytes))
473}
474
475fn encode_credentials(
476    credentials: &LocalCredentials,
477    master: &[u8; 32],
478) -> Result<Vec<u8>, SecurityError> {
479    let mut nonce = [0_u8; 24];
480    getrandom::fill(&mut nonce).map_err(|_| SecurityError::RandomnessUnavailable)?;
481    let mut plaintext = Zeroizing::new(Vec::with_capacity(PLAINTEXT_LEN));
482    plaintext.extend_from_slice(&credentials.signer.secret_bytes());
483    plaintext.extend_from_slice(credentials.domain_key.as_bytes());
484    plaintext.extend_from_slice(credentials.domain_id.as_bytes());
485    let key = vault_key(master)?;
486    let cipher = XChaCha20Poly1305::new((&*key).into());
487    let ciphertext = cipher
488        .encrypt(
489            &XNonce::from(nonce),
490            Payload {
491                msg: &plaintext,
492                aad: MAGIC,
493            },
494        )
495        .map_err(|_| SecurityError::EncryptionFailed)?;
496    let mut encoded = Vec::with_capacity(MAGIC.len() + nonce.len() + ciphertext.len());
497    encoded.extend_from_slice(MAGIC);
498    encoded.extend_from_slice(&nonce);
499    encoded.extend_from_slice(&ciphertext);
500    Ok(encoded)
501}
502
503fn decode_credentials(bytes: &[u8], master: &[u8; 32]) -> Result<LocalCredentials, SecurityError> {
504    if bytes.len() != MAGIC.len() + 24 + PLAINTEXT_LEN + 16 || &bytes[..MAGIC.len()] != MAGIC {
505        return Err(SecurityError::InvalidKeyProviderData);
506    }
507    let nonce: [u8; 24] = bytes[8..32]
508        .try_into()
509        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
510    let key = vault_key(master)?;
511    let cipher = XChaCha20Poly1305::new((&*key).into());
512    let plaintext = Zeroizing::new(
513        cipher
514            .decrypt(
515                &XNonce::from(nonce),
516                Payload {
517                    msg: &bytes[32..],
518                    aad: MAGIC,
519                },
520            )
521            .map_err(|_| SecurityError::InvalidKeyProviderData)?,
522    );
523    if plaintext.len() != PLAINTEXT_LEN {
524        return Err(SecurityError::InvalidKeyProviderData);
525    }
526    let signer = plaintext[..32]
527        .try_into()
528        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
529    let domain_key = plaintext[32..64]
530        .try_into()
531        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
532    let domain_id = plaintext[64..]
533        .try_into()
534        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
535    Ok(LocalCredentials {
536        signer: IrohSigner::from_secret_bytes(signer),
537        domain_key: DomainKey::from_bytes(domain_key),
538        domain_id: DomainId::from_bytes(domain_id),
539        root: PathBuf::new(),
540    })
541}
542
543fn encode_domain_seed(seed: &DomainSeed, master: &[u8; 32]) -> Result<Vec<u8>, SecurityError> {
544    let mut nonce = [0_u8; 24];
545    getrandom::fill(&mut nonce).map_err(|_| SecurityError::RandomnessUnavailable)?;
546    let mut plaintext = Zeroizing::new(Vec::with_capacity(DOMAIN_PLAINTEXT_LEN));
547    plaintext.extend_from_slice(seed.domain_id.as_bytes());
548    plaintext.extend_from_slice(&seed.epoch.to_be_bytes());
549    plaintext.extend_from_slice(seed.domain_key.as_bytes());
550    let key = vault_key(master)?;
551    let ciphertext = XChaCha20Poly1305::new((&*key).into())
552        .encrypt(
553            &XNonce::from(nonce),
554            Payload {
555                msg: &plaintext,
556                aad: DOMAIN_MAGIC,
557            },
558        )
559        .map_err(|_| SecurityError::EncryptionFailed)?;
560    let mut encoded = Vec::with_capacity(32 + ciphertext.len());
561    encoded.extend_from_slice(DOMAIN_MAGIC);
562    encoded.extend_from_slice(&nonce);
563    encoded.extend_from_slice(&ciphertext);
564    Ok(encoded)
565}
566
567fn decode_domain_seed(bytes: &[u8], master: &[u8; 32]) -> Result<DomainSeed, SecurityError> {
568    if bytes.len() != 32 + DOMAIN_PLAINTEXT_LEN + 16 || &bytes[..8] != DOMAIN_MAGIC {
569        return Err(SecurityError::InvalidKeyProviderData);
570    }
571    let nonce: [u8; 24] = bytes[8..32]
572        .try_into()
573        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
574    let key = vault_key(master)?;
575    let plaintext = Zeroizing::new(
576        XChaCha20Poly1305::new((&*key).into())
577            .decrypt(
578                &XNonce::from(nonce),
579                Payload {
580                    msg: &bytes[32..],
581                    aad: DOMAIN_MAGIC,
582                },
583            )
584            .map_err(|_| SecurityError::InvalidKeyProviderData)?,
585    );
586    let domain_id = plaintext[..32]
587        .try_into()
588        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
589    let epoch = u64::from_be_bytes(
590        plaintext[32..40]
591            .try_into()
592            .map_err(|_| SecurityError::InvalidKeyProviderData)?,
593    );
594    let domain_key = plaintext[40..]
595        .try_into()
596        .map_err(|_| SecurityError::InvalidKeyProviderData)?;
597    Ok(DomainSeed {
598        domain_id: DomainId::from_bytes(domain_id),
599        epoch,
600        domain_key: DomainKey::from_bytes(domain_key),
601    })
602}
603
604fn vault_key(master: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>, SecurityError> {
605    let hkdf = Hkdf::<Sha256>::new(Some(b"iroh-db/local-key-vault/v1"), master);
606    let mut key = Zeroizing::new([0_u8; 32]);
607    hkdf.expand(b"credentials/v1", &mut *key)
608        .map_err(|_| SecurityError::KeyDerivationFailed)?;
609    Ok(key)
610}
611
612fn store_epoch_archive(
613    directory: &Path,
614    seed: &DomainSeed,
615    master: &[u8; 32],
616) -> Result<(), SecurityError> {
617    let path = directory.join(format!("{}.epoch-{}.v1", seed.domain_id, seed.epoch));
618    if path.exists() {
619        let bytes = std::fs::read(&path).map_err(io_error)?;
620        let existing = decode_domain_seed(&bytes, master)?;
621        if existing.domain_id == seed.domain_id
622            && existing.epoch == seed.epoch
623            && existing.domain_key.as_bytes() == seed.domain_key.as_bytes()
624        {
625            return Ok(());
626        }
627        return Err(SecurityError::InvalidKeyProviderData);
628    }
629    write_owner_only_replace(&path, &encode_domain_seed(seed, master)?)
630}
631
632fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), SecurityError> {
633    let mut options = OpenOptions::new();
634    options.create_new(true).write(true);
635    #[cfg(unix)]
636    {
637        use std::os::unix::fs::OpenOptionsExt as _;
638        options.mode(0o600);
639    }
640    let mut file = options.open(path).map_err(io_error)?;
641    file.write_all(bytes).map_err(io_error)?;
642    file.sync_all().map_err(io_error)
643}
644
645fn write_owner_only_replace(path: &Path, bytes: &[u8]) -> Result<(), SecurityError> {
646    let mut suffix = [0_u8; 8];
647    getrandom::fill(&mut suffix).map_err(|_| SecurityError::RandomnessUnavailable)?;
648    let mut encoded_suffix = String::with_capacity(16);
649    for byte in suffix {
650        encoded_suffix.push(HEX[usize::from(byte >> 4)] as char);
651        encoded_suffix.push(HEX[usize::from(byte & 0x0f)] as char);
652    }
653    let temporary = path.with_extension(format!("tmp-{encoded_suffix}"));
654    write_owner_only(&temporary, bytes)?;
655    std::fs::rename(&temporary, path).map_err(io_error)?;
656    if let Some(parent) = path.parent() {
657        OpenOptions::new()
658            .read(true)
659            .open(parent)
660            .and_then(|directory| directory.sync_all())
661            .map_err(io_error)?;
662    }
663    Ok(())
664}
665
666#[cfg(unix)]
667fn check_owner_only(path: &Path) -> Result<(), SecurityError> {
668    use std::os::unix::fs::PermissionsExt as _;
669    let mode = std::fs::metadata(path)
670        .map_err(io_error)?
671        .permissions()
672        .mode();
673    if mode & 0o077 != 0 {
674        return Err(SecurityError::InsecureKeyPermissions);
675    }
676    Ok(())
677}
678
679#[cfg(not(unix))]
680fn check_owner_only(_path: &Path) -> Result<(), SecurityError> {
681    Ok(())
682}
683
684#[allow(clippy::needless_pass_by_value)]
685fn io_error(error: std::io::Error) -> SecurityError {
686    SecurityError::KeyProviderIo(error.to_string())
687}