Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/account/
credentials.rs

1//! Explicit installation-scoped credentials, independent of a daemon login.
2//!
3//! A bundle contains one Account locator and exactly the supplied private keys.
4//! References and Identity membership are deliberately not inputs. Unlocking
5//! obtains signers, not authority: ordinary database Auth still applies.
6
7use aes_gcm::{
8    Aes256Gcm, KeyInit, Nonce,
9    aead::{Aead, AeadCore, OsRng, Payload},
10};
11use serde::{Deserialize, Serialize};
12use zeroize::Zeroizing;
13
14use super::{AccountError, crypto};
15use crate::{
16    ID, Result,
17    auth::crypto::{PrivateKey, PublicKey},
18};
19
20// Version 1 pins the existing account Argon2id 19 MiB / t=2 / p=1,
21// base64-text-as-salt convention and AES-256-GCM. It is not a PasswordStore
22// format, and does not adopt or migrate PasswordStore's independently versioned
23// envelopes. The domain/version is authenticated alongside the entire payload.
24const VERSION: u8 = 1;
25const AAD: &[u8] = b"eidetica:local-credential-bundle:v1";
26
27/// An encrypted local bundle, safe to persist outside the replicated DAG.
28///
29/// This is a local storage format, not a portable Account schema or a recovery
30/// export. All convenience logins use this same format, including empty passwords.
31/// Encrypted records may be served to filesystem-trusted socket clients;
32/// their passwords and decrypted keys need not be sent to a daemon.
33#[derive(Clone, Debug, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct CredentialBundle {
36    version: u8,
37    salt: String,
38    nonce: Vec<u8>,
39    ciphertext: Vec<u8>,
40}
41
42/// Unlocked keys and their Account locator, owned by the caller.
43///
44/// No Debug/Serialize/Clone implementation is provided. Returned signer clones
45/// and handles have independent lifetimes; dropping this owner is not revocation
46/// or a promise to erase parser, allocator or cryptographic-library copies.
47pub struct UnlockedCredentials {
48    account_id: ID,
49    keys: Vec<PrivateKey>,
50    // Retain only the wrapping key, not the password, for explicit key updates
51    // and password rewrap. Zeroized on drop, including early error returns.
52    wrapping_key: Option<Zeroizing<Vec<u8>>>,
53    salt: Option<String>,
54}
55
56#[derive(Serialize)]
57struct BorrowedPayload<'a> {
58    account_id: &'a ID,
59    keys: &'a [PrivateKey],
60}
61
62#[derive(Deserialize)]
63#[serde(deny_unknown_fields)]
64struct PayloadData {
65    account_id: ID,
66    keys: Vec<PrivateKey>,
67}
68
69fn invalid_bundle() -> crate::Error {
70    AccountError::DecryptionFailed {
71        reason: "Invalid local credential bundle".to_owned(),
72    }
73    .into()
74}
75
76fn validate_keys(keys: &[PrivateKey]) -> Result<()> {
77    if keys.is_empty() {
78        return Err(AccountError::NoKeysAvailable.into());
79    }
80    let mut seen = std::collections::HashSet::new();
81    for key in keys {
82        if !seen.insert(key.public_key()) {
83            return Err(AccountError::KeyAlreadyExists {
84                key_id: key.public_key().to_string(),
85            }
86            .into());
87        }
88    }
89    Ok(())
90}
91
92impl CredentialBundle {
93    /// Encrypt exactly these installation keys and this Account locator with
94    /// one password. Keys must be nonempty and unique. No reference lookup,
95    /// membership enrollment, or database write occurs. Every password string is
96    /// accepted, including empty. Empty-string wrapping provides no meaningful
97    /// confidentiality against anyone holding the bundle.
98    ///
99    /// ```
100    /// use eidetica::{ID, account::{CredentialBundle, AccountSigner}, auth::crypto::generate_keypair};
101    /// let account_id = ID::from_bytes("example-account-locator");
102    /// let (private, public) = generate_keypair();
103    /// let encrypted = CredentialBundle::seal(&account_id, &[private], "local password")?;
104    /// let bytes = serde_json::to_vec(&encrypted)?; // Persist only outside the Entry DAG.
105    /// let stored: CredentialBundle = serde_json::from_slice(&bytes)?;
106    /// let unlocked = stored.unlock_for(&account_id, "local password")?;
107    /// let acting = AccountSigner::new(account_id.clone(), unlocked.signer(&public)?);
108    /// assert_eq!(acting.account_id(), &account_id);
109    /// assert_eq!(acting.key_id(), public);
110    /// drop(unlocked); // The acting handle owns an independent signer clone.
111    /// # Ok::<(), Box<dyn std::error::Error>>(())
112    /// ```
113    pub fn seal(account_id: &ID, keys: &[PrivateKey], password: &str) -> Result<Self> {
114        validate_keys(keys)?;
115        let salt = crypto::generate_salt();
116        let wrapping_key = Zeroizing::new(crypto::derive_encryption_key(password, &salt)?);
117        Self::encrypt(account_id, keys, &salt, &wrapping_key)
118    }
119
120    fn encrypt(account_id: &ID, keys: &[PrivateKey], salt: &str, key: &[u8]) -> Result<Self> {
121        // Guard the buffer before serialization; the reservation avoids moving
122        // completed plaintext into abandoned reallocations. As with PasswordStore,
123        // serde's internal per-field scratch is not a complete-memory-wipe claim.
124        let payload = BorrowedPayload { account_id, keys };
125        let mut counter = ByteCount(0);
126        serde_json::to_writer(&mut counter, &payload).map_err(|_| invalid_bundle())?;
127        let mut plaintext = Zeroizing::new(Vec::with_capacity(counter.0));
128        serde_json::to_writer(&mut *plaintext, &payload).map_err(|_| invalid_bundle())?;
129        let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| invalid_bundle())?;
130        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
131        let ciphertext = cipher
132            .encrypt(
133                &nonce,
134                Payload {
135                    msg: &plaintext,
136                    aad: AAD,
137                },
138            )
139            .map_err(|_| invalid_bundle())?;
140        Ok(Self {
141            version: VERSION,
142            salt: salt.to_owned(),
143            nonce: nonce.to_vec(),
144            ciphertext,
145        })
146    }
147
148    /// Unlock on the client. The Account locator and every private key are
149    /// authenticated by the same envelope. Unknown formats, tampering and wrong
150    /// passwords fail closed without echoing decrypted bytes in diagnostics.
151    pub fn unlock(&self, password: &str) -> Result<UnlockedCredentials> {
152        if self.version != VERSION || self.nonce.len() != crypto::NONCE_LENGTH {
153            return Err(invalid_bundle());
154        }
155        let wrapping_key = Zeroizing::new(crypto::derive_encryption_key(password, &self.salt)?);
156        self.unlock_with_key(wrapping_key)
157    }
158
159    fn unlock_with_key(&self, wrapping_key: Zeroizing<Vec<u8>>) -> Result<UnlockedCredentials> {
160        if self.version != VERSION || self.nonce.len() != crypto::NONCE_LENGTH {
161            return Err(invalid_bundle());
162        }
163        let cipher = Aes256Gcm::new_from_slice(&wrapping_key).map_err(|_| invalid_bundle())?;
164        let plaintext = Zeroizing::new(
165            cipher
166                .decrypt(
167                    Nonce::from_slice(&self.nonce),
168                    Payload {
169                        msg: &self.ciphertext,
170                        aad: AAD,
171                    },
172                )
173                .map_err(|_| invalid_bundle())?,
174        );
175        let payload: PayloadData =
176            serde_json::from_slice(&plaintext).map_err(|_| invalid_bundle())?;
177        validate_keys(&payload.keys)?;
178        Ok(UnlockedCredentials {
179            account_id: payload.account_id,
180            keys: payload.keys,
181            wrapping_key: Some(wrapping_key),
182            salt: Some(self.salt.clone()),
183        })
184    }
185
186    /// Unlock only if the encrypted locator matches the expected Account.
187    /// A directory hint or copied encrypted blob cannot rebind its locator.
188    pub fn unlock_for(&self, account_id: &ID, password: &str) -> Result<UnlockedCredentials> {
189        let credentials = self.unlock(password)?;
190        if credentials.account_id() != account_id {
191            return Err(invalid_bundle());
192        }
193        Ok(credentials)
194    }
195}
196
197impl UnlockedCredentials {
198    pub(crate) fn from_signer(account_id: ID, keys: Vec<PrivateKey>) -> Result<Self> {
199        validate_keys(&keys)?;
200        Ok(Self {
201            account_id,
202            keys,
203            wrapping_key: None,
204            salt: None,
205        })
206    }
207
208    pub(crate) fn get_signer(&self, key_id: &PublicKey) -> Option<&PrivateKey> {
209        self.keys.iter().find(|key| &key.public_key() == key_id)
210    }
211
212    pub(crate) fn clear(&mut self) {
213        self.keys.clear();
214        self.wrapping_key = None;
215    }
216
217    pub(crate) fn storage(&self) -> Result<super::KeyStorage> {
218        Ok(super::KeyStorage::Encrypted {
219            bundle: self.seal()?,
220        })
221    }
222
223    /// Refresh an observed bundle using this session's wrapping key. A changed
224    /// password/format fails closed; stale sessions cannot overwrite a rewrap.
225    pub(crate) fn unlock_storage(&self, storage: &super::KeyStorage) -> Result<Self> {
226        let super::KeyStorage::Encrypted { bundle } = storage;
227        if Some(&bundle.salt) != self.salt.as_ref() {
228            return Err(invalid_bundle());
229        }
230        let key = self.wrapping_key.as_ref().ok_or_else(invalid_bundle)?;
231        let unlocked = bundle.unlock_with_key(key.clone())?;
232        if unlocked.account_id != self.account_id {
233            return Err(invalid_bundle());
234        }
235        Ok(unlocked)
236    }
237
238    /// The authenticated Account locator. It grants no access by itself.
239    pub fn account_id(&self) -> &ID {
240        &self.account_id
241    }
242
243    /// Public IDs of exactly the explicitly supplied keys, in supplied order.
244    pub fn key_ids(&self) -> Vec<PublicKey> {
245        self.keys.iter().map(PrivateKey::public_key).collect()
246    }
247
248    /// Obtain an independently owned signer for an explicit key selection.
249    /// A missing key is an error, never fallback to a personal/default key.
250    pub fn signer(&self, key_id: &PublicKey) -> Result<PrivateKey> {
251        self.keys
252            .iter()
253            .find(|key| &key.public_key() == key_id)
254            .cloned()
255            .ok_or_else(|| {
256                AccountError::KeyNotFound {
257                    key_id: key_id.to_string(),
258                }
259                .into()
260            })
261    }
262
263    /// Add an explicitly supplied key locally. Neither references nor Auth
264    /// changes call this method automatically. Persist with [`Self::seal`].
265    pub fn add_key(&mut self, key: PrivateKey) -> Result<()> {
266        let key_id = key.public_key();
267        if self.keys.iter().any(|key| key.public_key() == key_id) {
268            return Err(AccountError::KeyAlreadyExists {
269                key_id: key_id.to_string(),
270            }
271            .into());
272        }
273        self.keys.push(key);
274        Ok(())
275    }
276
277    /// Encrypt the current explicit bundle using the unlocked wrapping key and
278    /// a fresh nonce. Does not persist or change the password.
279    pub fn seal(&self) -> Result<CredentialBundle> {
280        CredentialBundle::encrypt(
281            &self.account_id,
282            &self.keys,
283            self.salt.as_deref().ok_or_else(invalid_bundle)?,
284            self.wrapping_key.as_deref().ok_or_else(invalid_bundle)?,
285        )
286    }
287
288    /// Rewrap with a new password and salt. Does not revoke or rotate signing
289    /// keys, invalidate existing handles or render old copies unrecoverable.
290    /// The caller must explicitly persist the result before discarding it.
291    pub fn rewrap(&self, password: &str) -> Result<CredentialBundle> {
292        CredentialBundle::seal(&self.account_id, &self.keys, password)
293    }
294}
295
296struct ByteCount(usize);
297impl std::io::Write for ByteCount {
298    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
299        self.0 += bytes.len();
300        Ok(bytes.len())
301    }
302    fn flush(&mut self) -> std::io::Result<()> {
303        Ok(())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::auth::crypto::generate_keypair;
311
312    #[test]
313    fn credential_bundle_requires_its_domain_aad() {
314        let account = ID::from_bytes("account");
315        let bundle = CredentialBundle::seal(&account, &[generate_keypair().0], "password").unwrap();
316        assert!(bundle.unlock_for(&account, "password").is_ok());
317        let key = Zeroizing::new(crypto::derive_encryption_key("password", &bundle.salt).unwrap());
318        let cipher = Aes256Gcm::new_from_slice(&key).unwrap();
319        assert!(
320            cipher
321                .decrypt(
322                    Nonce::from_slice(&bundle.nonce),
323                    bundle.ciphertext.as_slice()
324                )
325                .is_err()
326        );
327        assert!(
328            cipher
329                .decrypt(
330                    Nonce::from_slice(&bundle.nonce),
331                    Payload {
332                        msg: &bundle.ciphertext,
333                        aad: b"another-format"
334                    }
335                )
336                .is_err()
337        );
338    }
339}