Skip to main content

clawft_kernel/licence/
store.rs

1//! [`CheckoutGrantStore`]: the accepted binding, the held grants and the
2//! clock floor (ADR-106 sections 3 and 4). Persisted atomically, 0600,
3//! size-capped, and fail-closed: a file that cannot be read or re-verified
4//! poisons the store, which then serves nothing and never overwrites it.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex, RwLock};
9
10use super::floor::FloorState;
11use super::persist::write_atomic;
12use super::store_load::{SlotFile, StoreFile, load};
13use super::{
14    BindState, BindingRecord, CheckoutGrant, Clock, GRANT_SKEW_SECS, GrantArtifact, LicenceError,
15    LicenceEvent, LicenceEventSink, LocalMeshId, NoopSink, Outcome, SignedEnvelope,
16};
17use crate::revocation::{RevocationKind, RevocationList};
18use crate::workload_pkg::TrustAnchors;
19
20/// File name inside the store directory.
21pub const GRANTS_FILE: &str = "checkout_grants.json";
22/// Persist the clock high-water mark when it moves this far past the saved one.
23const HW_PERSIST_STEP: u64 = 60;
24
25#[derive(Clone)]
26pub(super) struct Held<T> {
27    pub(super) signed: SignedEnvelope,
28    pub(super) body: T,
29}
30
31/// The grants held for one (cog, version).
32#[derive(Default, Clone)]
33pub(super) struct Slot {
34    /// The highest-`seq` accepted grant.
35    pub(super) current: Option<Held<CheckoutGrant>>,
36    /// The newest accepted grant below `current` (restored on a conflict).
37    pub(super) previous: Option<Held<CheckoutGrant>>,
38    /// Seqs at which two different payloads were seen; refused for good.
39    pub(super) conflicted: Vec<u64>,
40}
41
42#[derive(Default, Clone)]
43pub(super) struct Inner {
44    pub(super) binding: Option<Held<BindingRecord>>,
45    pub(super) slots: BTreeMap<(String, String), Slot>,
46    pub(super) floors: BTreeMap<String, FloorState>,
47    pub(super) persisted_hw: u64,
48    pub(super) orphan_reported: bool,
49    /// Memory is ahead of the file (a restrictive record could not be saved).
50    pub(super) dirty: bool,
51    pub(super) poisoned: Option<String>,
52}
53
54/// A grant that passed every check and is valid right now. Only the store
55/// makes one; [`crate::mesh_artifact::ArtifactExchange::grant_checkout`]
56/// takes it.
57#[derive(Debug, Clone)]
58pub struct VerifiedCheckoutGrant {
59    grant: CheckoutGrant,
60    grant_pubkey_hex: String,
61}
62
63impl VerifiedCheckoutGrant {
64    /// A verified grant for the test grant key, without going through a store.
65    #[cfg(test)]
66    pub(super) fn for_test(grant: CheckoutGrant) -> Self {
67        let pk = super::tests_common::pk_hex(&super::tests_common::grant_key());
68        Self { grant, grant_pubkey_hex: pk }
69    }
70
71    /// The grant.
72    pub fn grant(&self) -> &CheckoutGrant {
73        &self.grant
74    }
75
76    /// The bound grant key, lower-case hex (the signer a key revocation names).
77    pub fn grant_pubkey_hex(&self) -> &str {
78        &self.grant_pubkey_hex
79    }
80}
81
82/// Persisted binding and grant state of one node.
83pub struct CheckoutGrantStore {
84    pub(super) path: PathBuf,
85    pub(super) anchors: Arc<TrustAnchors>,
86    pub(super) local: LocalMeshId,
87    pub(super) clock: Clock,
88    pub(super) sink: RwLock<Arc<dyn LicenceEventSink>>,
89    pub(super) revocations: RwLock<Option<Arc<RevocationList>>>,
90    pub(super) inner: Mutex<Inner>,
91}
92
93impl std::fmt::Debug for CheckoutGrantStore {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("CheckoutGrantStore").finish_non_exhaustive()
96    }
97}
98
99pub(super) fn grant_valid(g: &CheckoutGrant, eff_now: u64) -> bool {
100    !g.is_withdrawal()
101        && eff_now < g.expires_at
102        && eff_now < g.licence.expires
103        && g.issued_at <= eff_now.saturating_add(GRANT_SKEW_SECS)
104}
105
106impl CheckoutGrantStore {
107    /// Open the store in `dir`. A missing file is an empty store; a file that
108    /// cannot be read, parsed or re-verified is an error (nothing is written).
109    pub fn open(
110        dir: &Path,
111        anchors: Arc<TrustAnchors>,
112        local: LocalMeshId,
113        clock: Clock,
114    ) -> Result<Self, LicenceError> {
115        let path = dir.join(GRANTS_FILE);
116        let inner = load(&path, &anchors)?;
117        Ok(Self::build(path, anchors, local, clock, inner))
118    }
119
120    /// [`Self::open`], but a bad file gives a poisoned store (it serves
121    /// nothing, refuses writes and leaves the file alone) so the daemon can
122    /// still install its policy, which then behaves like `ManifestPolicy`.
123    pub fn open_or_poisoned(
124        dir: &Path,
125        anchors: Arc<TrustAnchors>,
126        local: LocalMeshId,
127        clock: Clock,
128    ) -> Self {
129        let path = dir.join(GRANTS_FILE);
130        let inner = load(&path, &anchors).unwrap_or_else(|e| Inner {
131            poisoned: Some(e.to_string()),
132            ..Inner::default()
133        });
134        Self::build(path, anchors, local, clock, inner)
135    }
136
137    fn build(
138        path: PathBuf,
139        anchors: Arc<TrustAnchors>,
140        local: LocalMeshId,
141        clock: Clock,
142        inner: Inner,
143    ) -> Self {
144        Self {
145            path,
146            anchors,
147            local,
148            clock,
149            sink: RwLock::new(Arc::new(NoopSink)),
150            revocations: RwLock::new(None),
151            inner: Mutex::new(inner),
152        }
153    }
154
155    /// Report [`LicenceEvent`]s to `sink`.
156    pub fn set_sink(&self, sink: Arc<dyn LicenceEventSink>) {
157        *self.sink.write().unwrap_or_else(|p| p.into_inner()) = sink;
158    }
159
160    /// Use `list` for `SignerKey` and `ArtifactHash` revocations.
161    pub fn attach_revocations(&self, list: Arc<RevocationList>) {
162        *self.revocations.write().unwrap_or_else(|p| p.into_inner()) = Some(list);
163    }
164
165    /// The live local mesh id handle.
166    pub fn local_mesh_id(&self) -> &LocalMeshId {
167        &self.local
168    }
169
170    /// Why the store is poisoned, if it is.
171    pub fn poisoned(&self) -> Option<String> {
172        self.lock().poisoned.clone()
173    }
174
175    pub(super) fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
176        self.inner.lock().unwrap_or_else(|p| p.into_inner())
177    }
178
179    fn emit(&self, ev: LicenceEvent) {
180        self.sink.read().unwrap_or_else(|p| p.into_inner()).emit(ev);
181    }
182
183    /// Run `f` under the state lock, then emit what it queued. Events leave
184    /// only after the lock is dropped, so a sink never runs under it (and
185    /// must not call back into the store regardless).
186    pub(super) fn run<T>(&self, f: impl FnOnce(&mut Inner, &mut Vec<LicenceEvent>) -> T) -> T {
187        let mut events = Vec::new();
188        let out = {
189            let mut g = self.lock();
190            f(&mut g, &mut events)
191        };
192        for e in events {
193            self.emit(e);
194        }
195        out
196    }
197
198    pub(super) fn key_revoked(&self, pk_hex: &str) -> bool {
199        let g = self.revocations.read().unwrap_or_else(|p| p.into_inner());
200        g.as_ref()
201            .is_some_and(|l| l.is_subject_revoked(RevocationKind::SignerKey, pk_hex))
202    }
203
204    /// True when `blake3_hex` is revoked as an `ArtifactHash`.
205    pub fn is_hash_revoked(&self, blake3_hex: &str) -> bool {
206        let g = self.revocations.read().unwrap_or_else(|p| p.into_inner());
207        g.as_ref()
208            .is_some_and(|l| l.is_subject_revoked(RevocationKind::ArtifactHash, blake3_hex))
209    }
210
211    fn save(&self, inner: &mut Inner) -> Result<(), LicenceError> {
212        let file = StoreFile {
213            v: 1,
214            binding: inner.binding.as_ref().map(|b| b.signed.clone()),
215            grants: inner
216                .slots
217                .iter()
218                .map(|((cog_id, version), s)| SlotFile {
219                    cog_id: cog_id.clone(),
220                    version: version.clone(),
221                    current: s.current.as_ref().map(|h| h.signed.clone()),
222                    previous: s.previous.as_ref().map(|h| h.signed.clone()),
223                    conflicted: s.conflicted.clone(),
224                })
225                .collect(),
226            floors: inner.floors.clone(),
227        };
228        let bytes =
229            serde_json::to_vec(&file).map_err(|e| LicenceError::Persist(e.to_string()))?;
230        write_atomic(&self.path, &bytes)?;
231        inner.persisted_hw = inner.floors.values().map(|f| f.hw).max().unwrap_or(0);
232        inner.dirty = false;
233        Ok(())
234    }
235
236    /// Persist `next`, and only then make it the state. On a save error the
237    /// state is unchanged.
238    pub(super) fn commit(&self, inner: &mut Inner, mut next: Inner) -> Result<(), LicenceError> {
239        self.save(&mut next)?;
240        *inner = next;
241        Ok(())
242    }
243
244    /// Make `next` the state even if it cannot be saved (the result is then
245    /// [`Outcome::AppliedUnsaved`], and `tick` retries the save). Only for records that
246    /// *restrict*: an unbind, a withdrawal, a conflict. A disk fault must
247    /// not leave a lapsed grant in force.
248    pub(super) fn commit_restrictive(&self, inner: &mut Inner, mut next: Inner) -> Outcome {
249        let saved = self.save(&mut next);
250        next.dirty = saved.is_err();
251        *inner = next;
252        match saved {
253            Ok(()) => Outcome::Applied,
254            Err(e) => {
255                tracing::warn!(error = %e, "restrictive licence record applied but not saved; tick retries");
256                Outcome::AppliedUnsaved
257            }
258        }
259    }
260
261    /// The accepted binding, or why there is none in effect: not bound, bound
262    /// to a mesh id this node no longer computes (orphaned: chained once),
263    /// no local mesh id, or poisoned.
264    pub(super) fn binding_in_effect(
265        &self,
266        inner: &mut Inner,
267        ev: &mut Vec<LicenceEvent>,
268    ) -> Result<BindingRecord, LicenceError> {
269        if let Some(p) = &inner.poisoned {
270            return Err(LicenceError::Poisoned(p.clone()));
271        }
272        let local = self.local.get().ok_or(LicenceError::NoLocalMesh)?;
273        let b = inner.binding.as_ref().ok_or(LicenceError::NoBinding)?.body.clone();
274        if b.mesh_id != local.to_hex() {
275            if !inner.orphan_reported {
276                inner.orphan_reported = true;
277                ev.push(LicenceEvent::BindingOrphaned {
278                    stored: b.mesh_id.clone(),
279                    local: local.to_hex(),
280                });
281            }
282            return Err(LicenceError::Orphaned);
283        }
284        inner.orphan_reported = false;
285        if b.state != BindState::Bound {
286            return Err(LicenceError::Unbound);
287        }
288        Ok(b)
289    }
290
291    /// The time to judge `key`'s grants by: `max(now, floor)`, after
292    /// recording the clock in memory. Never touches the disk (see `tick`).
293    pub(super) fn eff_now(&self, inner: &mut Inner, key: &str) -> u64 {
294        let now = (self.clock)();
295        let fs = inner.floors.entry(key.to_owned()).or_default();
296        fs.observe(now);
297        fs.effective_now(now)
298    }
299
300    /// The binding in effect, if any.
301    pub fn active_binding(&self) -> Option<BindingRecord> {
302        self.binding_status().ok()
303    }
304
305    /// The binding in effect, or the reason there is none.
306    pub fn binding_status(&self) -> Result<BindingRecord, LicenceError> {
307        self.run(|inner, ev| self.binding_in_effect(inner, ev))
308    }
309
310    /// The stored binding whatever its state (bound, unbound or orphaned), for
311    /// the steward's `seq` rule and for status. Not for gating anything.
312    pub fn held_binding(&self) -> Option<BindingRecord> {
313        self.lock().binding.as_ref().map(|h| h.body.clone())
314    }
315
316    /// The MEMBER profile against this store's anchors and local mesh id,
317    /// without storing anything (the steward verifies before it spends any
318    /// replay state).
319    pub fn verify_member(&self, signed: &super::SignedBinding) -> Result<BindingRecord, LicenceError> {
320        let local = self.local.get().ok_or(LicenceError::NoLocalMesh)?;
321        super::verify_binding_member(signed, &self.anchors, &local)
322    }
323
324    /// Run `f` over every currently valid grant (binding in effect, grant key
325    /// not revoked, grant unexpired by `max(now, floor)`).
326    fn with_valid<T>(&self, mut f: impl FnMut(&CheckoutGrant, &str) -> Option<T>) -> Option<T> {
327        self.run(|inner, ev| {
328            let b = self.binding_in_effect(inner, ev).ok()?;
329            if self.key_revoked(&b.grant_pubkey) {
330                return None;
331            }
332            let eff = self.eff_now(inner, &b.grant_pubkey);
333            inner
334                .slots
335                .values()
336                .filter_map(|s| s.current.as_ref())
337                .filter(|h| h.body.mesh_id == b.mesh_id && grant_valid(&h.body, eff))
338                .find_map(|h| f(&h.body, &b.grant_pubkey))
339        })
340    }
341
342    /// A valid grant that lists BLAKE3 `blake3_hex` for `cog_id` `version`.
343    pub fn valid_grant_covering(
344        &self,
345        blake3_hex: &str,
346        cog_id: &str,
347        version: &str,
348    ) -> Option<CheckoutGrant> {
349        self.with_valid(|g, _| {
350            (g.cog_id == cog_id && g.version == version && g.artifact_by_blake3(blake3_hex).is_some())
351                .then(|| g.clone())
352        })
353    }
354
355    /// A valid grant for `cog_id` `version` that lists a binary whose
356    /// (sha256, blake3) pair is exactly `sha256` and `blake3`.
357    pub fn valid_grant_for_artifact(
358        &self,
359        cog_id: &str,
360        version: &str,
361        sha256: &str,
362        blake3: &str,
363    ) -> Option<(CheckoutGrant, GrantArtifact)> {
364        self.with_valid(|g, _| {
365            if g.cog_id != cog_id || g.version != version {
366                return None;
367            }
368            g.artifacts
369                .iter()
370                .find(|a| a.sha256 == sha256 && a.blake3 == blake3)
371                .map(|a| (g.clone(), a.clone()))
372        })
373    }
374
375    /// Every currently valid grant, ready for `grant_checkout`.
376    pub fn verified_grants(&self) -> Vec<VerifiedCheckoutGrant> {
377        let mut out = Vec::new();
378        self.with_valid::<()>(|g, pk| {
379            out.push(VerifiedCheckoutGrant { grant: g.clone(), grant_pubkey_hex: pk.to_owned() });
380            None
381        });
382        out
383    }
384
385    /// The floor for the bound key, in unix seconds.
386    pub fn floor(&self) -> Option<u64> {
387        self.run(|inner, ev| {
388            let b = self.binding_in_effect(inner, ev).ok()?;
389            self.eff_now(inner, &b.grant_pubkey);
390            inner.floors.get(&b.grant_pubkey).map(FloorState::floor)
391        })
392    }
393
394    /// Admin hook (`weaver cog checkout reset-floor`): forget the clock
395    /// high-water mark and restart it from the current clock. This is the
396    /// only way to undo a forward clock jump. The caller gates it (Admin).
397    pub fn reset_floor(&self) -> Result<(), LicenceError> {
398        self.run(|inner, ev| {
399            let b = self.binding_in_effect(inner, ev)?;
400            let now = (self.clock)();
401            let mut next = inner.clone();
402            next.floors.entry(b.grant_pubkey).or_default().reset(now);
403            ev.push(LicenceEvent::FloorReset(now));
404            self.commit(inner, next)
405        })
406    }
407
408    /// Record the clock and persist the high-water mark (the daemon calls
409    /// this on its tick). The only place the read side writes to disk.
410    pub fn tick(&self) {
411        self.run(|inner, ev| {
412            // Retry an unsaved restrictive record first, even when no binding
413            // is in effect (an unbind is exactly that case).
414            if inner.dirty && inner.poisoned.is_none() {
415                let _ = self.save(inner);
416            }
417            if let Ok(b) = self.binding_in_effect(inner, ev) {
418                self.eff_now(inner, &b.grant_pubkey);
419                let hw = inner.floors.get(&b.grant_pubkey).map_or(0, |f| f.hw);
420                if hw >= inner.persisted_hw.saturating_add(HW_PERSIST_STEP) {
421                    let _ = self.save(inner);
422                }
423            }
424        })
425    }
426}