Skip to main content

clawft_kernel/
user_key_rotation.rs

1//! User-key rotation records and the trust history they build (ADR-103 A13).
2//!
3//! The user key signs project certificates, anchor records, parent policies
4//! and the user chain. Rotating it must not orphan what the old key sealed.
5//! A [`RotationRecord`] hands the identity from one key to the next and is
6//! signed by BOTH (dual-signed handover): the old key proves the owner of the
7//! old identity agreed, the new key proves possession. Records are
8//! hash-linked (`prev` is the SHA-256 of the previous record including its
9//! signatures) and stored in an append-only file ([`RotationLog`]); the user
10//! daemon also chains each one (`user.key.rotated`, source
11//! [`crate::project_identity::SOURCE`]).
12//!
13//! [`UserKeyHistory`] folds the records into "which key is current, which
14//! keys were retired and when". A verifier asks [`UserKeyHistory::accepts`]:
15//! the current key always, a retired key only for material dated at or before
16//! its rotation point. The final record must name the key the daemon
17//! actually holds, otherwise the history is refused (a record forged with a
18//! stolen old key alone names a key nobody holds).
19//!
20//! Honest limit: the rotation point is a wall-clock time, compared with the
21//! time a record claims (a certificate's `issued_at`, an anchor statement's
22//! `at`). Someone who still holds the OLD private key can backdate such a
23//! claim; rotation after a suspected compromise must therefore also revoke or
24//! rekey the affected projects. Material sealed after the rotation point
25//! with the old key honestly dated is refused.
26
27use std::path::{Path, PathBuf};
28
29use chrono::{DateTime, Utc};
30use clawft_types::project::canon::{canonical_json, hex_decode, hex_encode};
31use clawft_types::project::cert::{key_id, ts};
32use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35use sha2::{Digest, Sha256};
36
37/// Domain tag of both signatures of a rotation record.
38pub const ROTATION_DOMAIN: &str = "weftos-user-key-rotation-v1\n";
39/// Record format version.
40pub const ROTATION_VERSION: u32 = 1;
41/// Chain event kind appended by the user daemon for every record.
42pub const KIND_ROTATED: &str = "user.key.rotated";
43/// Name of the append-only log inside the manifest store.
44pub const ROTATION_FILE: &str = "user-key-rotations.jsonl";
45
46/// Why a rotation record or history is not trusted.
47#[derive(Debug, thiserror::Error)]
48pub enum RotationError {
49    /// A record is malformed (version, hex, key ids, timestamp).
50    #[error("rotation record {seq}: {why}")]
51    Malformed {
52        /// Record sequence.
53        seq: u64,
54        /// What is wrong.
55        why: String,
56    },
57    /// A signature does not verify.
58    #[error("rotation record {seq}: the {which} key's signature does not verify")]
59    BadSignature {
60        /// Record sequence.
61        seq: u64,
62        /// `old` or `new`.
63        which: &'static str,
64    },
65    /// The records do not form one chain (sequence, `prev` or key continuity).
66    #[error("rotation record {seq}: {why}")]
67    Broken {
68        /// Record sequence.
69        seq: u64,
70        /// What is wrong.
71        why: String,
72    },
73    /// The last record does not hand over to the key this daemon holds.
74    #[error("the rotation history ends at key {history} but the user key in use is {current}; refusing to trust the history")]
75    NotCurrent {
76        /// `key_id` the history ends at.
77        history: String,
78        /// `key_id` of the key in use.
79        current: String,
80    },
81    /// The log file cannot be read or parsed.
82    #[error("rotation log {path}: {why}; refusing to verify until it is repaired")]
83    Log {
84        /// File path.
85        path: String,
86        /// What is wrong.
87        why: String,
88    },
89}
90
91/// One dual-signed handover from `old_pubkey` to `new_pubkey`.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct RotationRecord {
95    /// [`ROTATION_VERSION`].
96    pub v: u32,
97    /// 1-based position in the log.
98    pub seq: u64,
99    /// Key being retired, 64 lowercase hex.
100    pub old_pubkey: String,
101    /// `key_id(old_pubkey)`.
102    pub old_key_id: String,
103    /// Key taking over, 64 lowercase hex.
104    pub new_pubkey: String,
105    /// `key_id(new_pubkey)`.
106    pub new_key_id: String,
107    /// The rotation point, `YYYY-MM-DDTHH:MM:SSZ`.
108    pub rotated_at: String,
109    /// SHA-256 ([`Self::hash`]) of the previous record; absent for the first.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub prev: Option<String>,
112    /// Hex signature by the old key.
113    pub sig_old: String,
114    /// Hex signature by the new key.
115    pub sig_new: String,
116}
117
118impl RotationRecord {
119    fn body(&self) -> Value {
120        json!({
121            "v": self.v,
122            "seq": self.seq,
123            "old_pubkey": self.old_pubkey,
124            "old_key_id": self.old_key_id,
125            "new_pubkey": self.new_pubkey,
126            "new_key_id": self.new_key_id,
127            "rotated_at": self.rotated_at,
128            "prev": self.prev,
129        })
130    }
131
132    fn signed_bytes(&self) -> Vec<u8> {
133        let mut out = ROTATION_DOMAIN.as_bytes().to_vec();
134        out.extend_from_slice(canonical_json(&self.body()).as_bytes());
135        out
136    }
137
138    /// Sign a handover with both keys. `prev` is the previous record, if any.
139    pub fn sign(
140        old: &SigningKey,
141        new: &SigningKey,
142        prev: Option<&RotationRecord>,
143        rotated_at: DateTime<Utc>,
144    ) -> Self {
145        let (op, np) = (old.verifying_key().to_bytes(), new.verifying_key().to_bytes());
146        let mut r = Self {
147            v: ROTATION_VERSION,
148            seq: prev.map_or(1, |p| p.seq + 1),
149            old_pubkey: hex_encode(&op),
150            old_key_id: key_id(&op),
151            new_pubkey: hex_encode(&np),
152            new_key_id: key_id(&np),
153            rotated_at: ts(rotated_at),
154            prev: prev.map(Self::hash),
155            sig_old: String::new(),
156            sig_new: String::new(),
157        };
158        let bytes = r.signed_bytes();
159        r.sig_old = hex_encode(&old.sign(&bytes).to_bytes());
160        r.sig_new = hex_encode(&new.sign(&bytes).to_bytes());
161        r
162    }
163
164    /// SHA-256 (hex) of the record including both signatures.
165    pub fn hash(&self) -> String {
166        let mut v = self.body();
167        v["sig_old"] = json!(self.sig_old);
168        v["sig_new"] = json!(self.sig_new);
169        hex_encode(&Sha256::digest(canonical_json(&v).as_bytes()))
170    }
171
172    /// The rotation point as a time.
173    pub fn rotated(&self) -> Option<DateTime<Utc>> {
174        DateTime::parse_from_rfc3339(&self.rotated_at)
175            .ok()
176            .map(|t| t.with_timezone(&Utc))
177            .filter(|t| ts(*t) == self.rotated_at)
178    }
179
180    /// Shape and both signatures.
181    pub fn verify(&self) -> Result<(), RotationError> {
182        let bad = |why: &str| RotationError::Malformed { seq: self.seq, why: why.to_owned() };
183        if self.v != ROTATION_VERSION {
184            return Err(bad("unsupported version"));
185        }
186        let op: [u8; 32] = hex_decode(&self.old_pubkey).ok_or_else(|| bad("old_pubkey is not 64 lowercase hex"))?;
187        let np: [u8; 32] = hex_decode(&self.new_pubkey).ok_or_else(|| bad("new_pubkey is not 64 lowercase hex"))?;
188        if self.old_key_id != key_id(&op) || self.new_key_id != key_id(&np) {
189            return Err(bad("a key id does not match its public key"));
190        }
191        if op == np {
192            return Err(bad("old and new keys are the same"));
193        }
194        if self.rotated().is_none() {
195            return Err(bad("rotated_at is not canonical RFC 3339 (YYYY-MM-DDTHH:MM:SSZ)"));
196        }
197        let bytes = self.signed_bytes();
198        for (which, pk, sig) in [("old", op, &self.sig_old), ("new", np, &self.sig_new)] {
199            let sig: [u8; 64] = hex_decode(sig).ok_or_else(|| bad("a signature is not 128 lowercase hex"))?;
200            VerifyingKey::from_bytes(&pk)
201                .map_err(|_| bad("a public key is not a valid point"))?
202                .verify_strict(&bytes, &Signature::from_bytes(&sig))
203                .map_err(|_| RotationError::BadSignature { seq: self.seq, which })?;
204        }
205        Ok(())
206    }
207}
208
209#[derive(Debug, Clone)]
210struct Retired {
211    pubkey: [u8; 32],
212    rotated_at: DateTime<Utc>,
213}
214
215/// The current user key plus every key it replaced and when.
216#[derive(Debug, Clone)]
217pub struct UserKeyHistory {
218    current: [u8; 32],
219    retired: Vec<Retired>,
220}
221
222impl UserKeyHistory {
223    /// No rotation ever happened.
224    pub fn single(current: &[u8; 32]) -> Self {
225        Self { current: *current, retired: Vec::new() }
226    }
227
228    /// Fold `records` (oldest first) into a history ending at `current`.
229    /// Every record must verify, link to its predecessor and continue its key.
230    pub fn from_records(current: &[u8; 32], records: &[RotationRecord]) -> Result<Self, RotationError> {
231        let mut retired: Vec<Retired> = Vec::new();
232        let mut prev: Option<&RotationRecord> = None;
233        for r in records {
234            r.verify()?;
235            let broken = |why: &str| RotationError::Broken { seq: r.seq, why: why.to_owned() };
236            match prev {
237                None if r.seq != 1 || r.prev.is_some() => return Err(broken("the first record must be seq 1 with no prev")),
238                Some(p) => {
239                    if r.seq != p.seq + 1 {
240                        return Err(broken("sequence is not previous + 1"));
241                    }
242                    if r.prev.as_deref() != Some(p.hash().as_str()) {
243                        return Err(broken("prev does not match the previous record"));
244                    }
245                    if r.old_pubkey != p.new_pubkey {
246                        return Err(broken("old key is not the previous record's new key"));
247                    }
248                    if r.rotated() < p.rotated() {
249                        return Err(broken("rotated_at goes backwards"));
250                    }
251                }
252                None => {}
253            }
254            let op = hex_decode::<32>(&r.old_pubkey).ok_or_else(|| broken("old_pubkey"))?;
255            let when = r.rotated().ok_or_else(|| broken("rotated_at"))?;
256            retired.push(Retired { pubkey: op, rotated_at: when });
257            prev = Some(r);
258        }
259        let end = match prev {
260            Some(l) => hex_decode::<32>(&l.new_pubkey).unwrap_or([0; 32]),
261            None => *current,
262        };
263        if &end != current {
264            return Err(RotationError::NotCurrent { history: key_id(&end), current: key_id(current) });
265        }
266        Ok(Self { current: *current, retired })
267    }
268
269    /// The key currently in use.
270    pub fn current(&self) -> &[u8; 32] {
271        &self.current
272    }
273
274    /// Number of retired keys.
275    pub fn rotations(&self) -> usize {
276        self.retired.len()
277    }
278
279    /// The public key with this `key_id`, current or retired.
280    pub fn key_for(&self, user_key_id: &str) -> Option<[u8; 32]> {
281        if key_id(&self.current) == user_key_id {
282            return Some(self.current);
283        }
284        self.retired.iter().find(|r| key_id(&r.pubkey) == user_key_id).map(|r| r.pubkey)
285    }
286
287    /// May `pubkey` have sealed something dated `at`? The current key always;
288    /// a retired key only up to (and including) its rotation point.
289    pub fn accepts(&self, pubkey: &[u8; 32], at: DateTime<Utc>) -> bool {
290        if pubkey == &self.current {
291            return true;
292        }
293        self.retired.iter().any(|r| &r.pubkey == pubkey && at <= r.rotated_at)
294    }
295
296    /// Public keys of every retired key, oldest first.
297    pub fn retired_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
298        self.retired.iter().map(|r| r.pubkey)
299    }
300
301    /// The rotation point of a retired key.
302    pub fn rotated_at(&self, pubkey: &[u8; 32]) -> Option<DateTime<Utc>> {
303        self.retired.iter().find(|r| &r.pubkey == pubkey).map(|r| r.rotated_at)
304    }
305}
306
307/// The append-only rotation log in a manifest store.
308#[derive(Debug, Clone)]
309pub struct RotationLog {
310    path: PathBuf,
311}
312
313impl RotationLog {
314    /// The log for the manifest store `dir`.
315    pub fn new(dir: &Path) -> Self {
316        Self { path: dir.join(ROTATION_FILE) }
317    }
318
319    /// File path.
320    pub fn path(&self) -> &Path {
321        &self.path
322    }
323
324    /// Every record. A missing file is an empty log; an unreadable or
325    /// unparseable one is an error (fail closed).
326    pub fn read(&self) -> Result<Vec<RotationRecord>, RotationError> {
327        let log = |why: String| RotationError::Log { path: self.path.display().to_string(), why };
328        let text = match std::fs::read_to_string(&self.path) {
329            Ok(t) => t,
330            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
331            Err(e) => return Err(log(e.to_string())),
332        };
333        text.lines()
334            .filter(|l| !l.trim().is_empty())
335            .enumerate()
336            .map(|(i, l)| serde_json::from_str(l).map_err(|e| log(format!("line {}: {e}", i + 1))))
337            .collect()
338    }
339
340    /// The history for `current`: single when the log is empty.
341    pub fn history(&self, current: &[u8; 32]) -> Result<UserKeyHistory, RotationError> {
342        UserKeyHistory::from_records(current, &self.read()?)
343    }
344
345    /// Replace the whole log with `records` (the chain's copy, for rebuilding a
346    /// deleted or truncated file). The records must form one history.
347    pub fn replace(&self, records: &[RotationRecord]) -> Result<(), RotationError> {
348        let end = records.last().and_then(|r| hex_decode::<32>(&r.new_pubkey)).ok_or_else(|| RotationError::Log {
349            path: self.path.display().to_string(),
350            why: "nothing to write".into(),
351        })?;
352        UserKeyHistory::from_records(&end, records)?;
353        let mut body = String::new();
354        for r in records {
355            body.push_str(&serde_json::to_string(r).unwrap_or_default());
356            body.push('\n');
357        }
358        crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
359            .map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
360    }
361
362    /// Append `record` (the whole file is rewritten atomically, 0600). The
363    /// record must extend the existing log.
364    pub fn append(&self, record: &RotationRecord) -> Result<(), RotationError> {
365        let mut all = self.read()?;
366        all.push(record.clone());
367        let end = hex_decode::<32>(&record.new_pubkey).unwrap_or([0; 32]);
368        UserKeyHistory::from_records(&end, &all)?;
369        let mut body = String::new();
370        for r in &all {
371            body.push_str(&serde_json::to_string(r).unwrap_or_default());
372            body.push('\n');
373        }
374        crate::project_identity::write_private_atomic(&self.path, body.as_bytes(), false)
375            .map_err(|e| RotationError::Log { path: self.path.display().to_string(), why: e.to_string() })
376    }
377}
378
379#[cfg(test)]
380#[path = "user_key_rotation_tests.rs"]
381mod tests;