1pub mod client;
35
36pub use client::{Client, HubError};
37
38pub use nucleus_substrate_core::{Projection, Receipt, ReceiptError, Session};
41pub use nucleus_identity_projection::{
42 IdentityBody, IdentityVerifyError, JwtSvidClaims, identity_projection,
43 verify_identity_projection,
44};
45pub use nucleus_flow_projection::{
46 FlowBody, FlowVerifyError, flow_projection, verify_flow_projection_shape,
47};
48pub use nucleus_mechanism_vcg::{
49 EconomicBody, EconomicVerifyError, vickrey_projection,
50 vcg_knapsack_projection, verify_economic_projection_shape,
51 VickreyBody, VickreyOutcome, VcgKnapsackBody, VcgKnapsackOutcome,
52};
53pub use nucleus_substrate_core::mechanism::vcg::{
54 AgentBid, ExternalityProfile, MatchResult, OpaqueSignedClaim,
55 PostedAuction, ResourceDim, VcgMatchResult,
56};
57
58#[derive(Debug, Clone)]
62pub struct VerifyReport {
63 pub projection_kinds: Vec<String>,
64 pub identity_subject: Option<String>,
67 pub flow_clean: bool,
70 pub has_adversarial_bid: bool,
73}
74
75pub fn verify_receipt_fully(
87 receipt: &Receipt,
88 jwks: &serde_json::Value,
89) -> Result<VerifyReport, SubstrateVerifyError> {
90 let vk_bytes = extract_ed25519_vk(jwks, &receipt.session.issuer_kid).ok_or_else(|| {
91 SubstrateVerifyError::JwksMissingKid(receipt.session.issuer_kid.clone())
92 })?;
93 receipt
94 .verify(&vk_bytes)
95 .map_err(SubstrateVerifyError::Receipt)?;
96
97 let mut projection_kinds = Vec::new();
98 let mut identity_subject = None;
99 let mut flow_clean = false;
100 let mut has_adversarial_bid = false;
101
102 for p in &receipt.projections {
103 projection_kinds.push(p.kind().to_string());
104 match p {
105 Projection::Identity(body) => {
106 let typed: IdentityBody = serde_json::from_value(body.clone())
107 .map_err(|e| SubstrateVerifyError::ProjectionParse {
108 kind: "identity",
109 error: e.to_string(),
110 })?;
111 let claims = verify_identity_projection(&typed, jwks)
112 .map_err(SubstrateVerifyError::Identity)?;
113 identity_subject = Some(claims.claims.sub);
114 }
115 Projection::Flow(body) => {
116 let typed: FlowBody = serde_json::from_value(body.clone()).map_err(|e| {
117 SubstrateVerifyError::ProjectionParse {
118 kind: "flow",
119 error: e.to_string(),
120 }
121 })?;
122 verify_flow_projection_shape(&typed)
123 .map_err(SubstrateVerifyError::Flow)?;
124 flow_clean = true;
125 if typed.has_adversarial_bid {
126 has_adversarial_bid = true;
127 }
128 }
129 Projection::Economic(body) => {
130 verify_economic_projection_shape(body)
131 .map_err(SubstrateVerifyError::Economic)?;
132 }
133 Projection::Capability(_) => {
134 }
136 _ => {
137 }
140 }
141 }
142
143 Ok(VerifyReport {
144 projection_kinds,
145 identity_subject,
146 flow_clean,
147 has_adversarial_bid,
148 })
149}
150
151#[derive(Debug, thiserror::Error)]
152pub enum SubstrateVerifyError {
153 #[error("JWKS missing key with kid {0}")]
154 JwksMissingKid(String),
155 #[error("receipt signature/hash check failed: {0}")]
156 Receipt(ReceiptError),
157 #[error("identity projection failed: {0}")]
158 Identity(IdentityVerifyError),
159 #[error("flow projection failed: {0}")]
160 Flow(FlowVerifyError),
161 #[error("economic projection failed: {0}")]
162 Economic(EconomicVerifyError),
163 #[error("could not parse {kind} projection body: {error}")]
164 ProjectionParse {
165 kind: &'static str,
166 error: String,
167 },
168}
169
170fn extract_ed25519_vk(jwks: &serde_json::Value, kid: &str) -> Option<[u8; 32]> {
173 use base64::Engine;
174 let keys = jwks.get("keys")?.as_array()?;
175 for k in keys {
176 if k.get("kid")?.as_str()? == kid
177 && k.get("kty")?.as_str()? == "OKP"
178 && k.get("crv")?.as_str()? == "Ed25519"
179 {
180 let x = k.get("x")?.as_str()?;
181 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
182 .decode(x)
183 .ok()?;
184 return bytes.try_into().ok();
185 }
186 }
187 None
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use ed25519_dalek::SigningKey;
194
195 fn build_jwks(sk: &SigningKey, kid: &str) -> serde_json::Value {
196 use base64::Engine;
197 let vk_bytes = sk.verifying_key().to_bytes();
198 serde_json::json!({
199 "keys": [{
200 "kty": "OKP",
201 "crv": "Ed25519",
202 "kid": kid,
203 "alg": "EdDSA",
204 "x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&vk_bytes),
205 }]
206 })
207 }
208
209 #[test]
210 fn verify_receipt_fully_walks_flow_and_economic() {
211 let sk = SigningKey::from_bytes(&[5u8; 32]);
212 let session = Session {
213 session_id: "spiffe://test/agent".into(),
214 issuer_kid: "kid-1".into(),
215 issued_at_micros: 1_717_000_000_000_000,
216 parent_chain: vec![],
217 };
218 let flow = flow_projection(
219 3,
220 "internal",
221 "trusted",
222 "informational",
223 "user_derived",
224 false,
225 false,
226 true,
227 );
228 let auction = PostedAuction {
229 auction_id: "a1".into(),
230 required_capabilities: Default::default(),
231 reward_micro_usd: 1_000_000,
232 pigouvian_rates: vec![],
233 scale: 1_000_000,
234 };
235 let bid = AgentBid {
236 agent_spiffe_id: "spiffe://test/agent".into(),
237 auction_id: "a1".into(),
238 effective_value_micro_usd: 500_000,
239 externality_profile: None,
240 };
241 let mr = MatchResult {
242 auction_id: "a1".into(),
243 winner_spiffe_id: Some("spiffe://test/agent".into()),
244 clearing_price_micro_usd: 250_000,
245 };
246 let economic = vickrey_projection(auction, vec![bid], mr);
247 let receipt = Receipt::sign(session, vec![flow, economic], &sk);
248 let jwks = build_jwks(&sk, "kid-1");
249 let report = verify_receipt_fully(&receipt, &jwks).expect("happy path");
250 assert_eq!(report.projection_kinds, vec!["flow", "economic"]);
251 assert!(report.flow_clean);
252 assert!(!report.has_adversarial_bid);
253 }
254
255 #[test]
256 fn tampered_receipt_fails_top_level_verify() {
257 let sk = SigningKey::from_bytes(&[5u8; 32]);
258 let session = Session {
259 session_id: "spiffe://test/agent".into(),
260 issuer_kid: "kid-1".into(),
261 issued_at_micros: 1_717_000_000_000_000,
262 parent_chain: vec![],
263 };
264 let mut receipt = Receipt::sign(session, vec![], &sk);
265 receipt.session.session_id = "spiffe://attacker".into();
266 let jwks = build_jwks(&sk, "kid-1");
267 let err = verify_receipt_fully(&receipt, &jwks).unwrap_err();
268 assert!(matches!(err, SubstrateVerifyError::Receipt(_)));
269 }
270
271 #[test]
272 fn missing_kid_in_jwks_short_circuits() {
273 let sk = SigningKey::from_bytes(&[5u8; 32]);
274 let session = Session {
275 session_id: "spiffe://test/agent".into(),
276 issuer_kid: "kid-1".into(),
277 issued_at_micros: 1_717_000_000_000_000,
278 parent_chain: vec![],
279 };
280 let receipt = Receipt::sign(session, vec![], &sk);
281 let empty_jwks = serde_json::json!({"keys": []});
282 let err = verify_receipt_fully(&receipt, &empty_jwks).unwrap_err();
283 assert!(matches!(err, SubstrateVerifyError::JwksMissingKid(_)));
284 }
285
286 #[test]
287 fn adversarial_flow_propagates_to_report() {
288 let sk = SigningKey::from_bytes(&[5u8; 32]);
289 let session = Session {
290 session_id: "spiffe://test/agent".into(),
291 issuer_kid: "kid-1".into(),
292 issued_at_micros: 1_717_000_000_000_000,
293 parent_chain: vec![],
294 };
295 let flow = flow_projection(
296 2,
297 "internal",
298 "adversarial",
299 "informational",
300 "user_derived",
301 true,
302 false,
303 true,
304 );
305 let receipt = Receipt::sign(session, vec![flow], &sk);
306 let jwks = build_jwks(&sk, "kid-1");
307 let report = verify_receipt_fully(&receipt, &jwks).expect("happy path");
308 assert!(report.has_adversarial_bid);
309 }
310}