Skip to main content

qualia_client_core/identity_plane/
person.rs

1//! Natural-person principal — not a machine and not an OS login.
2//!
3//! At rest the secret material is sealed with the install vault key
4//! (XChaCha20-Poly1305 via `wrap_key`). Cleartext files from older builds are
5//! accepted once and rewritten sealed.
6
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use qualia_core_db::crypto::sanctuary_audit::{unwrap_key, wrap_key};
9use serde::{Deserialize, Serialize};
10use std::path::PathBuf;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13const PERSON_AAD: &[u8] = b"qualia:person_identity:v1";
14const SEALED_FORMAT: &str = "qualia.person.sealed.v1";
15
16/// On-disk person principal (secret material).
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PersonPrincipal {
19    /// Stable DID: `did:q42:person:{verifying_key_hex}`.
20    pub person_id: String,
21    /// Raw Ed25519 signing secret (32 bytes).
22    pub ed25519_secret: [u8; 32],
23    #[serde(default)]
24    pub created_at_unix: u64,
25    /// Optional preferred label (not a legal name).
26    #[serde(default)]
27    pub display_hint: String,
28}
29
30/// Public half of a person principal (safe to show in UI / share with peers).
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct PersonPublic {
33    pub person_id: String,
34    pub verifying_key_hex: String,
35    #[serde(default)]
36    pub created_at_unix: u64,
37    #[serde(default)]
38    pub display_hint: String,
39}
40
41/// Portable bundle for installing the *same person* on another machine.
42///
43/// Treat like recovery material: never post publicly; import only on devices
44/// the person controls.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PersonTransferBundle {
47    pub format: String,
48    pub person: PersonPrincipal,
49}
50
51pub const PERSON_TRANSFER_FORMAT: &str = "qualia.person.transfer.v1";
52
53fn now_unix() -> u64 {
54    SystemTime::now()
55        .duration_since(UNIX_EPOCH)
56        .map(|d| d.as_secs())
57        .unwrap_or(0)
58}
59
60fn person_path() -> PathBuf {
61    crate::state::app_meta_dir().join("person_identity.json")
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65struct SealedPersonFile {
66    format: String,
67    ciphertext_hex: String,
68}
69
70/// Derive a 32-byte wrapping key for person identity at rest.
71/// Prefers the install KeyVault when APP_STATE is live; otherwise a
72/// machine-local key file under app meta (so cold tools can still open).
73fn person_wrapping_key() -> Result<[u8; 32], String> {
74    if let Some(state) = crate::state::APP_STATE.get() {
75        if let Ok(vault) = state.key_vault.lock() {
76            if !vault.is_locked() {
77                return Ok(vault.derive_key("person_identity_v1").to_bytes());
78            }
79        }
80    }
81    // Fallback: dedicated local wrapping secret (not OS account).
82    let path = crate::state::app_meta_dir().join("person_wrap.key");
83    if path.exists() {
84        let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
85        if bytes.len() == 32 {
86            let mut k = [0u8; 32];
87            k.copy_from_slice(&bytes);
88            return Ok(k);
89        }
90    }
91    let mut k = [0u8; 32];
92    rand::fill(&mut k);
93    if let Some(parent) = path.parent() {
94        let _ = std::fs::create_dir_all(parent);
95    }
96    std::fs::write(&path, k).map_err(|e| e.to_string())?;
97    Ok(k)
98}
99
100impl PersonPrincipal {
101    pub fn signing_key(&self) -> SigningKey {
102        SigningKey::from_bytes(&self.ed25519_secret)
103    }
104
105    pub fn verifying_key_hex(&self) -> String {
106        hex::encode(VerifyingKey::from(&self.signing_key()).to_bytes())
107    }
108
109    pub fn to_public(&self) -> PersonPublic {
110        PersonPublic {
111            person_id: self.person_id.clone(),
112            verifying_key_hex: self.verifying_key_hex(),
113            created_at_unix: self.created_at_unix,
114            display_hint: self.display_hint.clone(),
115        }
116    }
117
118    pub fn did_from_verifying_key_bytes(vk: &[u8; 32]) -> String {
119        format!("did:q42:person:{}", hex::encode(vk))
120    }
121
122    pub fn generate(display_hint: impl Into<String>) -> Result<Self, String> {
123        let mut secret = [0u8; 32];
124        rand::fill(&mut secret);
125        let sk = SigningKey::from_bytes(&secret);
126        let vk = VerifyingKey::from(&sk);
127        let person_id = Self::did_from_verifying_key_bytes(&vk.to_bytes());
128        Ok(Self {
129            person_id,
130            ed25519_secret: secret,
131            created_at_unix: now_unix(),
132            display_hint: display_hint.into(),
133        })
134    }
135
136    /// Load existing person identity, or mint a new one (first install).
137    pub fn load_or_create(display_hint: Option<&str>) -> Result<Self, String> {
138        let path = person_path();
139        if path.exists() {
140            let bytes = std::fs::read(&path)
141                .map_err(|e| format!("failed to read {}: {e}", path.display()))?;
142            if let Ok(person) = Self::load_from_bytes(&bytes) {
143                // Migrate cleartext → sealed if needed.
144                let _ = person.persist();
145                return Ok(person);
146            }
147        }
148        let person = Self::generate(display_hint.unwrap_or(""))?;
149        person.persist()?;
150        Ok(person)
151    }
152
153    fn load_from_bytes(bytes: &[u8]) -> Result<Self, String> {
154        if let Ok(sealed) = serde_json::from_slice::<SealedPersonFile>(bytes) {
155            if sealed.format == SEALED_FORMAT {
156                let wrap = person_wrapping_key()?;
157                let ct = hex::decode(&sealed.ciphertext_hex)
158                    .map_err(|e| format!("person ciphertext hex: {e}"))?;
159                let plain = unwrap_key(&wrap, &ct, PERSON_AAD)
160                    .map_err(|e| format!("person unwrap failed: {e:?}"))?;
161                let person: PersonPrincipal = serde_json::from_slice(&plain)
162                    .map_err(|e| format!("person plaintext parse: {e}"))?;
163                if person.person_id.starts_with("did:q42:person:")
164                    && person.ed25519_secret != [0u8; 32]
165                {
166                    return Ok(person);
167                }
168                return Err("sealed person identity failed integrity checks".into());
169            }
170        }
171        // Legacy cleartext
172        let person: PersonPrincipal =
173            serde_json::from_slice(bytes).map_err(|e| format!("person identity parse: {e}"))?;
174        if person.person_id.starts_with("did:q42:person:") && person.ed25519_secret != [0u8; 32] {
175            return Ok(person);
176        }
177        Err("invalid person identity file".into())
178    }
179
180    pub fn persist(&self) -> Result<(), String> {
181        let path = person_path();
182        if let Some(parent) = path.parent() {
183            std::fs::create_dir_all(parent)
184                .map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
185        }
186        let plain = serde_json::to_vec(self)
187            .map_err(|e| format!("failed to encode person identity: {e}"))?;
188        let wrap = person_wrapping_key()?;
189        let sealed = wrap_key(&wrap, &plain, PERSON_AAD)
190            .map_err(|e| format!("person seal failed: {e:?}"))?;
191        let file = SealedPersonFile {
192            format: SEALED_FORMAT.to_string(),
193            ciphertext_hex: hex::encode(sealed),
194        };
195        let json = serde_json::to_string_pretty(&file)
196            .map_err(|e| format!("failed to encode sealed person: {e}"))?;
197        std::fs::write(&path, json)
198            .map_err(|e| format!("failed to write {}: {e}", path.display()))?;
199        Ok(())
200    }
201
202    /// Sign a message with the person principal (for fleet job envelopes).
203    pub fn sign_message(&self, message: &[u8]) -> [u8; 64] {
204        self.signing_key().sign(message).to_bytes()
205    }
206
207    pub fn verify_message(
208        person_id: &str,
209        verifying_key_hex: &str,
210        message: &[u8],
211        sig: &[u8; 64],
212    ) -> Result<(), String> {
213        let key_hex = verifying_key_hex.trim().to_ascii_lowercase();
214        let expected = format!("did:q42:person:{key_hex}");
215        if person_id != expected {
216            return Err("person_id does not match verifying key".into());
217        }
218        let bytes = hex::decode(&key_hex).map_err(|e| format!("verifying key hex: {e}"))?;
219        if bytes.len() != 32 {
220            return Err("verifying key must be 32 bytes".into());
221        }
222        let mut arr = [0u8; 32];
223        arr.copy_from_slice(&bytes);
224        let vk = VerifyingKey::from_bytes(&arr).map_err(|e| format!("verifying key: {e}"))?;
225        let signature = Signature::from_bytes(sig);
226        vk.verify(message, &signature)
227            .map_err(|_| "person signature invalid".to_string())
228    }
229
230    pub fn replace_with(person: PersonPrincipal) -> Result<PersonPrincipal, String> {
231        if !person.person_id.starts_with("did:q42:person:") {
232            return Err("person_id must be a did:q42:person:… identifier".into());
233        }
234        let expected = Self::did_from_verifying_key_bytes(
235            &VerifyingKey::from(&person.signing_key()).to_bytes(),
236        );
237        if person.person_id != expected {
238            return Err("person_id does not match signing key material".into());
239        }
240        person.persist()?;
241        Ok(person)
242    }
243
244    pub fn transfer_bundle(&self) -> PersonTransferBundle {
245        PersonTransferBundle {
246            format: PERSON_TRANSFER_FORMAT.to_string(),
247            person: self.clone(),
248        }
249    }
250
251    pub fn from_transfer_bundle(bundle: PersonTransferBundle) -> Result<Self, String> {
252        if bundle.format != PERSON_TRANSFER_FORMAT {
253            return Err(format!(
254                "unsupported person transfer format: {}",
255                bundle.format
256            ));
257        }
258        Self::replace_with(bundle.person)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn person_id_is_not_an_os_username() {
268        let p = PersonPrincipal::generate("").unwrap();
269        assert!(p.person_id.starts_with("did:q42:person:"));
270        assert!(!p.person_id.contains('\\'));
271        assert_ne!(p.person_id, whoami_username_fallback());
272    }
273
274    fn whoami_username_fallback() -> String {
275        std::env::var("USERNAME")
276            .or_else(|_| std::env::var("USER"))
277            .unwrap_or_default()
278    }
279
280    #[test]
281    fn public_half_exposes_no_secret() {
282        let p = PersonPrincipal::generate("Ada").unwrap();
283        let pub_ = p.to_public();
284        let json = serde_json::to_string(&pub_).unwrap();
285        assert!(!json.contains("ed25519_secret"));
286        assert_eq!(pub_.person_id, p.person_id);
287        assert_eq!(pub_.verifying_key_hex.len(), 64);
288    }
289}