Skip to main content

clawft_kernel/
project_identity.rs

1//! Project identity: the project key, its certificate and the revocation
2//! view (ADR-103 A6/A7, Phase 2 package C).
3//!
4//! One project key (`<root>/.weftos/project.key`) is the node key, the
5//! chain signing key, the anchor key and the proof-of-possession key. It
6//! therefore never signs bare bytes: every signature it makes carries a
7//! distinct domain tag defined next to the statement it signs
8//! ([`clawft_types::project::cert`]): `weftos-project-anchor-v1` for anchors
9//! and `weftos-mesh-local-pop-v2` for proof of possession (this module's
10//! [`pop_sign`], which also signs the operation and the user key id). The user key signs certificates under
11//! `weftos-project-cert-v1`. Chain events keep their own canonical form.
12//!
13//! # Trust on first use (TOFU), honestly
14//!
15//! The first key presented for a project id is certified; a different key
16//! for the same id is refused until an explicit Admin `project.rekey`.
17//! Nothing here can tell whether the first registrant really is the
18//! project: the only guards for first registration are the one-shot spawn
19//! nonce the user daemon handed to the child it started and the binding to
20//! the project's manifest (the owner's registration act). A same-uid
21//! process that learns the nonce first wins the id. Phase 3 peer
22//! credentials narrow this; they do not remove it.
23//!
24//! # Revocation and crash safety
25//!
26//! [`RevocationView::build`] merges three sources: the user chain's
27//! `project.register|rekey|revoke` events (source [`SOURCE`], reserved:
28//! see [`is_reserved_source`]), the append-only identity journal
29//! ([`IdentityJournal`]), and the certificate files. The chain is saved only
30//! on clean shutdown, so the journal (fsynced before every chain append) is
31//! what keeps bindings and revocations across a crash. Every certificate is
32//! re-verified against the user key while folding. The merge is a union:
33//! the bound key of a project is its highest-serial certificate whose key is
34//! not revoked, so source order cannot resurrect a revoked key.
35//!
36//! Expired certificates still count as bound: an expired cert keeps its id
37//! (and its key) until it is rekeyed or revoked. This is moot while
38//! `expires_at` is null (Phase 2); the phase that sets expiry must decide
39//! whether an expired binding should free the id.
40
41use 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
60/// Chain event source of the project identity events (user chain).
61pub const SOURCE: &str = "user.projects";
62pub use crate::chain_anchor::ANCHOR_SOURCE;
63/// A project key was certified.
64pub const KIND_REGISTER: &str = "project.register";
65/// A project key was replaced.
66pub const KIND_REKEY: &str = "project.rekey";
67/// A project key was revoked.
68pub const KIND_REVOKE: &str = "project.revoke";
69
70/// Chain sources only the daemon's own code may append under. A caller
71/// that chooses its own source (the `chain.append` RPC) must be refused
72/// these: a forged `user.projects` event would otherwise feed the view.
73pub use crate::chain::{KERNEL_SOURCES, RESERVED_SOURCES, is_caller_reserved_source, is_reserved_source};
74
75/// Why an identity operation was refused.
76#[derive(Debug, thiserror::Error)]
77pub enum IdentityError {
78    /// Certificate or proof-of-possession check failed.
79    #[error(transparent)]
80    Cert(#[from] CertError),
81    /// The key file is unsafe or malformed.
82    #[error("project key file {path}: {why}")]
83    KeyFile {
84        /// The key path.
85        path: String,
86        /// What is wrong with it.
87        why: String,
88    },
89    /// A different key is already certified for this project id.
90    #[error("project {project_id} already has key {bound}; use project.rekey to replace it")]
91    KeyConflict {
92        /// Project id.
93        project_id: String,
94        /// The `key_id` currently bound.
95        bound: String,
96    },
97    /// The key id was revoked and may not be certified again.
98    #[error("key {key_id} of project {project_id} was revoked")]
99    KeyRevoked {
100        /// Project id.
101        project_id: String,
102        /// The revoked `key_id`.
103        key_id: String,
104    },
105    /// The operation needs a certified key and there is none.
106    #[error("project {0} has no certified key")]
107    NotBound(String),
108    /// The project was revoked (`project.revoke`). Revoke is terminal for
109    /// the id: no key is ever certified for it again. Give the tree a new
110    /// identity (`weft project init --fork --force`) to run it again.
111    #[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    /// The key id is already used by another project (bound or revoked).
114    #[error("key {key_id} belongs to project {other_project}; a key cannot certify two projects")]
115    KeyReuse {
116        /// The reused key id.
117        key_id: String,
118        /// The project that owns it.
119        other_project: String,
120    },
121    /// The identity journal exists but cannot be trusted.
122    #[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        /// Journal path.
125        path: String,
126        /// What is wrong.
127        why: String,
128    },
129    /// An exclusive create found the file already there.
130    #[error("{0} already exists")]
131    Exists(String),
132    /// `expires_at` is not after `issued_at`.
133    #[error("expires_at must be after issued_at")]
134    BadLifetime,
135    /// Filesystem failure.
136    #[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
147/// Load the project key at `path`, or create it (mode 0600, raw 32-byte
148/// seed: the format of `ChainManager::load_or_create_key`).
149///
150/// Unlike that function this one never "fixes" a loose file: a key that
151/// was readable by anyone may already be copied. Refused: a symlink
152/// (opened `O_NOFOLLOW`), a non-regular file, a file not owned by this
153/// user, group/world access to the file, a group/world-writable parent
154/// directory, a wrong size. The checks run on the open descriptor, not on
155/// the path, and a lost creation race re-loads the winner's key.
156pub 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
174/// The chain signing key of a project kernel: the project key `pre_boot`
175/// already loaded and certified (`seed`, the node key seed), confirmed
176/// against the 0600 `project.key` at `path` with the strict reader (no
177/// symlink, no creation, no chmod). A missing seed, a missing file or a file
178/// that no longer holds that key refuses the boot (ADR-103 A7, one key).
179pub 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        // SAFETY: geteuid has no preconditions.
246        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
258/// Write `bytes` to `path` via a temp file in the same directory and an
259/// atomic rename, mode 0600 from creation (never briefly wider).
260///
261/// With `exclusive` the final step is a hard link that fails when `path`
262/// exists (two creators cannot both win); otherwise the rename replaces.
263pub 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        // Make the new directory entry durable too.
297        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
310/// Certify `req` with the user key. Deterministic. Refuses an invalid
311/// project id and an expiry that is not after the issue time.
312pub 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
320/// Verify `cert` against the user key the caller trusts, at the current
321/// time: signature, both key ids recomputed from their public keys,
322/// issuer, `expires_at`. Revocation is a separate check
323/// ([`RevocationView::check_cert`]).
324pub fn verify_cert(cert: &ProjectCert, trusted_user_pubkey: &[u8; 32]) -> Result<(), CertError> {
325    verify_cert_at(cert, trusted_user_pubkey, Utc::now())
326}
327
328/// [`verify_cert`] against an explicit `now`.
329pub 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
337/// Check only that `cert` is well-formed and signed by `trusted_user_pubkey`,
338/// ignoring expiry and the future-skew rule (judged at its own issue time).
339/// Used when folding history: an expired certificate still occupies its id.
340pub 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
350/// Check that `cert` is well-formed and was sealed by a user key `trust`
351/// accepts for the certificate's `issued_at`: the current key always, a
352/// retired key only up to its rotation point (ADR-103 A13). Expiry and the
353/// future-skew rule are ignored, as in [`verify_signature_only`].
354pub 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
366/// Prove possession of the project key: sign
367/// `weftos-mesh-local-pop-v2\n<op>\n<user_key_id>\n<nonce>\n<project_id>`.
368/// The parts are validated first; the nonce must be one the user daemon
369/// issued (single use), which this function cannot check.
370pub 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
381/// Verify a proof of possession by `project_pubkey`.
382pub 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
397/// Decode a 32-byte lowercase-hex public key.
398pub fn parse_pubkey(hex: &str) -> Option<[u8; 32]> {
399    hex_decode::<32>(hex)
400}
401
402/// Lowercase hex of `bytes` (re-export for callers building payloads).
403pub fn hex(bytes: &[u8]) -> String {
404    hex_encode(bytes)
405}
406
407#[cfg(test)]
408#[path = "project_identity_tests.rs"]
409mod tests;