Skip to main content

nucleus_substrate_core/
lib.rs

1//! # nucleus-substrate-core — the categorical core
2//!
3//! This crate ships **three things**:
4//!
5//! 1. [`Session`] — the agent-action object every projection projects from.
6//! 2. [`Projection`] — a sealed, adjacently-tagged sum type whose
7//!    variants are one-per-projection-functor (Identity, Capability,
8//!    Flow, Economic, …).
9//! 3. [`Receipt`] — the Ed25519-signed colimit envelope holding a
10//!    session and any subset of its projections.
11//!
12//! The categorical model is documented at
13//! `docs/architecture/substrate.md` in the repo.
14//!
15//! ## Quick example
16//!
17//! ```
18//! use nucleus_substrate_core::{Session, Receipt, Projection};
19//! use ed25519_dalek::SigningKey;
20//!
21//! let sk = SigningKey::from_bytes(&[7u8; 32]);
22//! let session = Session {
23//!     session_id: "spiffe://test/agent-x".into(),
24//!     issuer_kid: "test-kid".into(),
25//!     issued_at_micros: 1_717_000_000_000_000,
26//!     parent_chain: vec![],
27//! };
28//!
29//! let projection = Projection::Identity(serde_json::json!({
30//!     "sub": "spiffe://test/agent-x",
31//!     "aud": "nucleus-substrate-test",
32//! }));
33//!
34//! let receipt = Receipt::sign(session, vec![projection], &sk);
35//!
36//! // Anyone with the issuer's verifying key can verify offline.
37//! let vk: [u8; 32] = sk.verifying_key().to_bytes();
38//! receipt.verify(&vk).expect("self-built receipt verifies");
39//! ```
40//!
41//! ## What's NOT in scope here
42//!
43//! - The concrete types each `Projection` variant carries. Those
44//!   live in the projection-lifter crates
45//!   (`nucleus-identity-projection`, `nucleus-flow-projection`, etc.).
46//!   This crate keeps them as `serde_json::Value` to stay
47//!   dependency-light.
48//! - The HTTP wire (REST routes, axum) — that's the hub.
49//! - The mechanism kernels (VCG, Pigouvian re-weighting) — those
50//!   live in `nucleus-econ-kernels` etc.
51
52use base64::Engine;
53use ed25519_dalek::{Signer, Verifier};
54use serde::{Deserialize, Serialize};
55
56pub mod mechanism;
57
58pub const RECEIPT_VERSION: u32 = 1;
59
60/// The unit of authorship. Every projection projects from this.
61///
62/// Composition: a child session adds one delegation hop to
63/// `parent_chain` and gets its own `session_id` (the SPIFFE id minted
64/// by the issuer for this hop). The issuer's pinned key id (`kid`)
65/// stays the same across hops within one boot.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct Session {
68    /// SPIFFE id of this session's subject.
69    pub session_id: String,
70    /// Key id of the issuer that signed this session's delegation.
71    pub issuer_kid: String,
72    /// Microseconds since UNIX epoch when the session was issued.
73    pub issued_at_micros: u64,
74    /// Delegation chain — SPIFFE ids of every prior hop. Empty for
75    /// root sessions, non-empty for child sessions.
76    pub parent_chain: Vec<String>,
77}
78
79/// One projection of a session into a verifiable record.
80///
81/// **Adjacently tagged** for wire compatibility with in-toto and SLSA
82/// predicates. **`non_exhaustive`** so new projection kinds can be
83/// added in minor releases without breaking external matchers.
84///
85/// Each variant's body is currently a `serde_json::Value` because
86/// the concrete type per kind lives in a lifter crate (see
87/// `nucleus-identity-projection`, `nucleus-flow-projection`,
88/// `nucleus-mechanism-vcg`, etc.). substrate-core stays dep-light;
89/// lifters narrow the JSON to their typed shape.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91#[serde(tag = "kind", content = "body", rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum Projection {
94    /// Identity functor — a JWT-SVID + delegation-chain record.
95    /// Lifter: `nucleus-identity-projection`.
96    Identity(serde_json::Value),
97    /// Capability functor — a point in the Portcullis quotient
98    /// lattice. Lifter: `nucleus-capability-projection`.
99    Capability(serde_json::Value),
100    /// Flow functor — a FlowTracker DAG snapshot (Denning lattice).
101    /// Lifter: `nucleus-flow-projection`.
102    Flow(serde_json::Value),
103    /// Economic functor — a bid+match record with Clarke-pivot
104    /// payments. Lifter: `nucleus-mechanism-vcg`.
105    Economic(serde_json::Value),
106}
107
108impl Projection {
109    /// The discriminant string used on the wire. Stable across versions.
110    pub fn kind(&self) -> &'static str {
111        match self {
112            Projection::Identity(_) => "identity",
113            Projection::Capability(_) => "capability",
114            Projection::Flow(_) => "flow",
115            Projection::Economic(_) => "economic",
116        }
117    }
118}
119
120/// **The colimit envelope.** Holds a session + any subset of its
121/// projections, signed with the issuer's Ed25519 key.
122///
123/// Verifiers re-canonicalize the signing input (session + projections
124/// + issued-at), recompute the BLAKE3 root hash, and re-verify the
125/// signature. Each projection MAY ADDITIONALLY be re-checked by the
126/// lifter that produced it; the lifter's per-kind verifier is
127/// independent of this top-level signature check.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct Receipt {
130    pub version: u32,
131    pub session: Session,
132    pub projections: Vec<Projection>,
133    /// BLAKE3 over [`canonical_signing_bytes`].
134    pub root_hash_hex: String,
135    /// Ed25519 signature over the same canonical bytes.
136    pub signature_b64: String,
137}
138
139impl Receipt {
140    /// Build + sign a fresh receipt with the supplied issuer key.
141    pub fn sign(
142        session: Session,
143        projections: Vec<Projection>,
144        signing_key: &ed25519_dalek::SigningKey,
145    ) -> Self {
146        let canonical = canonical_signing_bytes(&session, &projections);
147        let root_hash_hex = hex::encode(blake3::hash(&canonical).as_bytes());
148        let sig = signing_key.sign(&canonical);
149        let signature_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
150        Self {
151            version: RECEIPT_VERSION,
152            session,
153            projections,
154            root_hash_hex,
155            signature_b64,
156        }
157    }
158
159    /// Verify this receipt against the supplied 32-byte Ed25519
160    /// verifying key (as found in the issuer's JWKS `x` field).
161    /// Re-canonicalizes + re-hashes independently of how the receipt
162    /// was built.
163    pub fn verify(&self, verifying_key_bytes: &[u8; 32]) -> Result<(), ReceiptError> {
164        let vk = ed25519_dalek::VerifyingKey::from_bytes(verifying_key_bytes)
165            .map_err(|e| ReceiptError::InvalidKey(e.to_string()))?;
166        let canonical = canonical_signing_bytes(&self.session, &self.projections);
167        let computed_hash_hex = hex::encode(blake3::hash(&canonical).as_bytes());
168        if computed_hash_hex != self.root_hash_hex {
169            return Err(ReceiptError::RootHashMismatch {
170                expected: self.root_hash_hex.clone(),
171                actual: computed_hash_hex,
172            });
173        }
174        let sig_bytes = base64::engine::general_purpose::STANDARD
175            .decode(&self.signature_b64)
176            .map_err(|e| ReceiptError::InvalidSignatureEncoding(e.to_string()))?;
177        let sig_array: [u8; 64] = sig_bytes
178            .try_into()
179            .map_err(|_| ReceiptError::InvalidSignatureEncoding("len != 64".into()))?;
180        let sig = ed25519_dalek::Signature::from_bytes(&sig_array);
181        vk.verify(&canonical, &sig)
182            .map_err(|e| ReceiptError::SignatureMismatch(e.to_string()))?;
183        Ok(())
184    }
185}
186
187/// Canonical signing input for a receipt. Same function is called
188/// when *building* and when *verifying* — that's the entire trust
189/// surface for the colimit identity.
190pub fn canonical_signing_bytes(session: &Session, projections: &[Projection]) -> Vec<u8> {
191    let envelope = serde_json::json!({
192        "version": RECEIPT_VERSION,
193        "session": session,
194        "projections": projections,
195    });
196    serde_json::to_vec(&envelope).expect("envelope serializes deterministically")
197}
198
199#[derive(Debug, thiserror::Error)]
200pub enum ReceiptError {
201    #[error("verifying key invalid: {0}")]
202    InvalidKey(String),
203    #[error("signature encoding invalid: {0}")]
204    InvalidSignatureEncoding(String),
205    #[error("root hash mismatch: expected {expected}, computed {actual}")]
206    RootHashMismatch { expected: String, actual: String },
207    #[error("signature did not verify: {0}")]
208    SignatureMismatch(String),
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn dummy_session() -> Session {
216        Session {
217            session_id: "spiffe://test/agent".into(),
218            issuer_kid: "kid-1".into(),
219            issued_at_micros: 1_717_000_000_000_000,
220            parent_chain: vec![],
221        }
222    }
223
224    fn dummy_projections() -> Vec<Projection> {
225        vec![
226            Projection::Identity(serde_json::json!({"sub": "spiffe://test/agent"})),
227            Projection::Flow(serde_json::json!({"node_count": 3, "any_adversarial": false})),
228        ]
229    }
230
231    #[test]
232    fn receipt_round_trips_through_verify() {
233        let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
234        let vk: [u8; 32] = sk.verifying_key().to_bytes();
235        let receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
236        receipt.verify(&vk).expect("fresh receipt must verify");
237    }
238
239    #[test]
240    fn tampered_session_fails_verify() {
241        let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
242        let vk: [u8; 32] = sk.verifying_key().to_bytes();
243        let mut receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
244        receipt.session.session_id = "spiffe://attacker/imposter".into();
245        assert!(matches!(
246            receipt.verify(&vk),
247            Err(ReceiptError::RootHashMismatch { .. })
248        ));
249    }
250
251    #[test]
252    fn projection_added_after_signing_fails_verify() {
253        let sk = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]);
254        let vk: [u8; 32] = sk.verifying_key().to_bytes();
255        let mut receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk);
256        receipt.projections.push(Projection::Economic(
257            serde_json::json!({"forged": "payment"}),
258        ));
259        assert!(matches!(
260            receipt.verify(&vk),
261            Err(ReceiptError::RootHashMismatch { .. })
262        ));
263    }
264
265    #[test]
266    fn wrong_verifying_key_fails_verify() {
267        let sk_a = ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]);
268        let sk_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]);
269        let receipt = Receipt::sign(dummy_session(), dummy_projections(), &sk_a);
270        let vk_b: [u8; 32] = sk_b.verifying_key().to_bytes();
271        assert!(matches!(
272            receipt.verify(&vk_b),
273            Err(ReceiptError::SignatureMismatch(_))
274        ));
275    }
276
277    #[test]
278    fn projection_wire_format_is_adjacent_tagged() {
279        // Stability check: the wire format must stay
280        // `{"kind": "...", "body": ...}` for downstream consumers.
281        let p = Projection::Capability(serde_json::json!({"label": "trusted"}));
282        let v: serde_json::Value = serde_json::to_value(&p).unwrap();
283        assert_eq!(v["kind"], "capability");
284        assert!(v["body"].is_object());
285    }
286
287    #[test]
288    fn projection_kind_strings_are_stable() {
289        // External code may dispatch on `kind()` — assert wire names.
290        assert_eq!(Projection::Identity(serde_json::Value::Null).kind(), "identity");
291        assert_eq!(Projection::Capability(serde_json::Value::Null).kind(), "capability");
292        assert_eq!(Projection::Flow(serde_json::Value::Null).kind(), "flow");
293        assert_eq!(Projection::Economic(serde_json::Value::Null).kind(), "economic");
294    }
295}