1use std::io::{Read as _, Write as _};
42use std::path::Path;
43
44use chrono::{DateTime, Utc};
45use clawft_types::project::canon::{hex_decode, hex_encode};
46use clawft_types::project::cert::{PopOp, ProjectCert, pop_signed_bytes};
47use clawft_types::project::{CertError, CertRequest, validate_id};
48use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
49
50#[path = "project_identity_journal.rs"]
51mod journal;
52pub use journal::{IdentityJournal, JOURNAL_FILE, JournalLock, JournalRecord, SharedLock};
53#[path = "project_identity_view.rs"]
54mod view;
55pub use view::{Registration, RevocationView};
56pub use crate::user_key_rotation::{
57 KIND_ROTATED, ROTATION_FILE, RotationError, RotationLog, RotationRecord, UserKeyHistory,
58};
59
60pub const SOURCE: &str = "user.projects";
62pub use crate::chain_anchor::ANCHOR_SOURCE;
63pub const KIND_REGISTER: &str = "project.register";
65pub const KIND_REKEY: &str = "project.rekey";
67pub const KIND_REVOKE: &str = "project.revoke";
69
70pub use crate::chain::{KERNEL_SOURCES, RESERVED_SOURCES, is_caller_reserved_source, is_reserved_source};
74
75#[derive(Debug, thiserror::Error)]
77pub enum IdentityError {
78 #[error(transparent)]
80 Cert(#[from] CertError),
81 #[error("project key file {path}: {why}")]
83 KeyFile {
84 path: String,
86 why: String,
88 },
89 #[error("project {project_id} already has key {bound}; use project.rekey to replace it")]
91 KeyConflict {
92 project_id: String,
94 bound: String,
96 },
97 #[error("key {key_id} of project {project_id} was revoked")]
99 KeyRevoked {
100 project_id: String,
102 key_id: String,
104 },
105 #[error("project {0} has no certified key")]
107 NotBound(String),
108 #[error("project {0} was revoked; revoke is terminal for its id (re-identify the tree with `weft project init --fork --force`)")]
112 ProjectRevoked(String),
113 #[error("key {key_id} belongs to project {other_project}; a key cannot certify two projects")]
115 KeyReuse {
116 key_id: String,
118 other_project: String,
120 },
121 #[error("identity journal {path}: {why}; refusing to issue or verify until it is repaired (run `project.identity.repair`; do not delete the file)")]
123 JournalCorrupt {
124 path: String,
126 why: String,
128 },
129 #[error("{0} already exists")]
131 Exists(String),
132 #[error("expires_at must be after issued_at")]
134 BadLifetime,
135 #[error("io: {0}")]
137 Io(#[from] std::io::Error),
138}
139
140fn key_file_err(path: &Path, why: impl Into<String>) -> IdentityError {
141 IdentityError::KeyFile {
142 path: path.display().to_string(),
143 why: why.into(),
144 }
145}
146
147pub fn load_or_create_project_key(path: &Path) -> Result<SigningKey, IdentityError> {
157 for _ in 0..3 {
158 if let Some(k) = read_key_file(path)? {
159 return Ok(k);
160 }
161 let key = SigningKey::generate(&mut rand::rngs::OsRng);
162 match write_private_atomic(path, &key.to_bytes(), true) {
163 Ok(()) => {
164 check_parent(path)?;
165 return Ok(key);
166 }
167 Err(IdentityError::Exists(_)) => {}
168 Err(e) => return Err(e),
169 }
170 }
171 Err(key_file_err(path, "kept appearing and disappearing during load"))
172}
173
174pub fn project_chain_key(path: &Path, seed: Option<[u8; 32]>) -> Result<SigningKey, IdentityError> {
180 let seed = seed.ok_or_else(|| {
181 key_file_err(path, "a project kernel boots with its project key as the node key; none was given")
182 })?;
183 let on_disk = read_key_file(path)?.ok_or_else(|| key_file_err(path, "does not exist"))?;
184 if on_disk.to_bytes() != seed {
185 return Err(key_file_err(path, "no longer holds the key this kernel was started with"));
186 }
187 Ok(SigningKey::from_bytes(&seed))
188}
189
190fn check_parent(path: &Path) -> Result<(), IdentityError> {
191 #[cfg(unix)]
192 {
193 use std::os::unix::fs::PermissionsExt;
194 let dir = match path.parent() {
195 Some(d) if !d.as_os_str().is_empty() => d,
196 _ => Path::new("."),
197 };
198 let mode = std::fs::metadata(dir)?.permissions().mode();
199 if mode & 0o022 != 0 {
200 return Err(key_file_err(
201 path,
202 format!("parent directory mode {:04o} is group/world writable", mode & 0o7777),
203 ));
204 }
205 }
206 #[cfg(not(unix))]
207 let _ = path;
208 Ok(())
209}
210
211fn read_key_file(path: &Path) -> Result<Option<SigningKey>, IdentityError> {
212 if path.parent().is_some_and(|d| d.exists()) {
213 check_parent(path)?;
214 }
215 let mut opts = std::fs::OpenOptions::new();
216 opts.read(true);
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::OpenOptionsExt;
220 opts.custom_flags(libc::O_NOFOLLOW);
221 }
222 let mut f = match opts.open(path) {
223 Ok(f) => f,
224 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
225 #[cfg(unix)]
226 Err(e) if e.raw_os_error() == Some(libc::ELOOP) => {
227 return Err(key_file_err(path, "is a symlink"));
228 }
229 Err(e) => return Err(e.into()),
230 };
231 let meta = f.metadata()?;
232 if !meta.is_file() {
233 return Err(key_file_err(path, "is not a regular file"));
234 }
235 #[cfg(unix)]
236 {
237 use std::os::unix::fs::{MetadataExt, PermissionsExt};
238 let mode = meta.permissions().mode();
239 if mode & 0o077 != 0 {
240 return Err(key_file_err(
241 path,
242 format!("mode {:04o} is group/world accessible (need 0600)", mode & 0o7777),
243 ));
244 }
245 if meta.uid() != unsafe { libc::geteuid() } {
247 return Err(key_file_err(path, "is not owned by this user"));
248 }
249 }
250 if meta.len() != 32 {
251 return Err(key_file_err(path, format!("is {} bytes, expected 32", meta.len())));
252 }
253 let mut seed = [0u8; 32];
254 f.read_exact(&mut seed)?;
255 Ok(Some(SigningKey::from_bytes(&seed)))
256}
257
258pub fn write_private_atomic(path: &Path, bytes: &[u8], exclusive: bool) -> Result<(), IdentityError> {
264 use std::sync::atomic::{AtomicU64, Ordering};
265 static SEQ: AtomicU64 = AtomicU64::new(0);
266 let dir = path.parent().ok_or_else(|| key_file_err(path, "has no parent directory"))?;
267 std::fs::create_dir_all(dir)?;
268 let tmp = dir.join(format!(
269 ".{}.{}.{}.tmp",
270 path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
271 std::process::id(),
272 SEQ.fetch_add(1, Ordering::Relaxed)
273 ));
274 let mut opts = std::fs::OpenOptions::new();
275 opts.write(true).create_new(true);
276 #[cfg(unix)]
277 {
278 use std::os::unix::fs::OpenOptionsExt;
279 opts.mode(0o600);
280 }
281 let result = (|| -> std::io::Result<()> {
282 let mut f = opts.open(&tmp)?;
283 f.write_all(bytes)?;
284 f.sync_all()?;
285 if exclusive {
286 std::fs::hard_link(&tmp, path)
287 } else {
288 std::fs::rename(&tmp, path)
289 }
290 })();
291 if exclusive || result.is_err() {
292 let _ = std::fs::remove_file(&tmp);
293 }
294 #[cfg(unix)]
295 if result.is_ok() {
296 if let Ok(d) = std::fs::File::open(dir) {
298 let _ = d.sync_all();
299 }
300 }
301 result.map_err(|e| {
302 if e.kind() == std::io::ErrorKind::AlreadyExists {
303 IdentityError::Exists(path.display().to_string())
304 } else {
305 e.into()
306 }
307 })
308}
309
310pub fn sign_cert(user_key: &SigningKey, req: &CertRequest) -> Result<ProjectCert, IdentityError> {
313 validate_id(&req.project_id).map_err(|_| CertError::BadProjectId)?;
314 if req.expires_at.is_some_and(|e| e <= req.issued_at) {
315 return Err(IdentityError::BadLifetime);
316 }
317 Ok(ProjectCert::sign(user_key, req))
318}
319
320pub fn verify_cert(cert: &ProjectCert, trusted_user_pubkey: &[u8; 32]) -> Result<(), CertError> {
325 verify_cert_at(cert, trusted_user_pubkey, Utc::now())
326}
327
328pub fn verify_cert_at(
330 cert: &ProjectCert,
331 trusted_user_pubkey: &[u8; 32],
332 now: DateTime<Utc>,
333) -> Result<(), CertError> {
334 cert.verify(trusted_user_pubkey, now)
335}
336
337pub fn verify_signature_only(
341 cert: &ProjectCert,
342 trusted_user_pubkey: &[u8; 32],
343) -> Result<(), CertError> {
344 let at = DateTime::parse_from_rfc3339(&cert.issued_at)
345 .map_err(|_| CertError::BadTimestamp("issued_at"))?
346 .with_timezone(&Utc);
347 cert.verify(trusted_user_pubkey, at)
348}
349
350pub fn verify_cert_historic(cert: &ProjectCert, trust: &UserKeyHistory) -> Result<(), IdentityError> {
355 let pk = trust.key_for(&cert.user_key_id).ok_or(CertError::UntrustedUser)?;
356 let at = DateTime::parse_from_rfc3339(&cert.issued_at)
357 .map_err(|_| CertError::BadTimestamp("issued_at"))?
358 .with_timezone(&Utc);
359 if !trust.accepts(&pk, at) {
360 return Err(CertError::UntrustedUser.into());
361 }
362 verify_signature_only(cert, &pk)?;
363 Ok(())
364}
365
366pub fn pop_sign(
371 project_key: &SigningKey,
372 op: PopOp,
373 user_key_id: &str,
374 nonce: &str,
375 project_id: &str,
376) -> Result<[u8; 64], CertError> {
377 let bytes = pop_signed_bytes(op, user_key_id, nonce, project_id)?;
378 Ok(project_key.sign(&bytes).to_bytes())
379}
380
381pub fn pop_verify(
383 project_pubkey: &[u8; 32],
384 op: PopOp,
385 user_key_id: &str,
386 nonce: &str,
387 project_id: &str,
388 sig: &[u8; 64],
389) -> Result<(), CertError> {
390 let bytes = pop_signed_bytes(op, user_key_id, nonce, project_id)?;
391 VerifyingKey::from_bytes(project_pubkey)
392 .map_err(|_| CertError::BadHex("pubkey"))?
393 .verify_strict(&bytes, &Signature::from_bytes(sig))
394 .map_err(|_| CertError::BadSignature)
395}
396
397pub fn parse_pubkey(hex: &str) -> Option<[u8; 32]> {
399 hex_decode::<32>(hex)
400}
401
402pub fn hex(bytes: &[u8]) -> String {
404 hex_encode(bytes)
405}
406
407#[cfg(test)]
408#[path = "project_identity_tests.rs"]
409mod tests;