qualia_client_core/identity_plane/
person.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PersonPrincipal {
19 pub person_id: String,
21 pub ed25519_secret: [u8; 32],
23 #[serde(default)]
24 pub created_at_unix: u64,
25 #[serde(default)]
27 pub display_hint: String,
28}
29
30#[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#[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
70fn 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 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 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 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 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 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}