nucleus_substrate_core/
lib.rs1use base64::Engine;
53use ed25519_dalek::{Signer, Verifier};
54use serde::{Deserialize, Serialize};
55
56pub mod mechanism;
57
58pub const RECEIPT_VERSION: u32 = 1;
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct Session {
68 pub session_id: String,
70 pub issuer_kid: String,
72 pub issued_at_micros: u64,
74 pub parent_chain: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91#[serde(tag = "kind", content = "body", rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum Projection {
94 Identity(serde_json::Value),
97 Capability(serde_json::Value),
100 Flow(serde_json::Value),
103 Economic(serde_json::Value),
106}
107
108impl Projection {
109 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct Receipt {
130 pub version: u32,
131 pub session: Session,
132 pub projections: Vec<Projection>,
133 pub root_hash_hex: String,
135 pub signature_b64: String,
137}
138
139impl Receipt {
140 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 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
187pub 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 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 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}