Skip to main content

nucleus_substrate_sdk/
lib.rs

1//! # nucleus-substrate-sdk
2//!
3//! Demand-side SDK for the Nucleus substrate. Composes:
4//!
5//!   * [`nucleus_substrate_core`] — Session, Receipt, Projection
6//!   * [`nucleus_identity_projection`] — JWT-SVID lifter
7//!   * [`nucleus_flow_projection`] — Denning-lattice lifter
8//!   * [`nucleus_mechanism_vcg`] — Pigouvian-VCG lifter
9//!
10//! Two top-level affordances:
11//!
12//!   * [`Client`] — async HTTP wrapper over the hub's REST surface
13//!     (agent card, JWKS, list auctions, post auction, submit bid,
14//!     match, receipts, counters).
15//!   * [`verify_receipt_fully`] — *the* "did this happen and is it
16//!     consistent?" entry point. Verifies the receipt's top-level
17//!     Ed25519 signature AND walks each projection through its
18//!     lifter's structural verifier.
19//!
20//! ## Example: full verify against a live hub
21//!
22//! ```no_run
23//! use nucleus_substrate_sdk::{Client, verify_receipt_fully};
24//!
25//! # async fn ex() -> anyhow::Result<()> {
26//! let client = Client::new("https://nucleus-auction-hub.fly.dev")?;
27//! let receipt = client.fetch_receipt("auction-id").await?;
28//! let jwks    = client.jwks().await?;
29//! let report  = verify_receipt_fully(&receipt, &jwks)?;
30//! println!("✓ verified {} projections", report.projection_kinds.len());
31//! # Ok(()) }
32//! ```
33
34pub mod client;
35
36pub use client::{Client, HubError};
37
38// Re-export everything an SDK consumer might need so a downstream
39// `cargo add nucleus-substrate-sdk` is the only line they need.
40pub 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/// Result of [`verify_receipt_fully`]. Holds the list of projection
59/// kinds that successfully verified — clients can sanity-check this
60/// against the set they expected to be present.
61#[derive(Debug, Clone)]
62pub struct VerifyReport {
63    pub projection_kinds: Vec<String>,
64    /// Verified identity claims, when an Identity projection was
65    /// present and verified.
66    pub identity_subject: Option<String>,
67    /// Whether a Flow projection was present AND its consistency
68    /// invariants held.
69    pub flow_clean: bool,
70    /// True iff a Flow projection reports `has_adversarial_bid` —
71    /// downstream consumers should refuse to trust the clearing.
72    pub has_adversarial_bid: bool,
73}
74
75/// **The composite verifier.** Runs:
76///
77///   1. [`Receipt::verify`] against the issuer's Ed25519 verifying key
78///      pulled from `jwks` by matching `kid`.
79///   2. For each projection in the receipt:
80///      - `Identity` → [`verify_identity_projection`]
81///      - `Flow`     → [`verify_flow_projection_shape`]
82///      - `Economic` → [`verify_economic_projection_shape`]
83///      - `Capability` → no lifter shipped yet; skipped in v0.1
84///
85/// Any failure → [`SubstrateVerifyError`].
86pub 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                // v0.1: no capability lifter shipped; skip.
135            }
136            _ => {
137                // `Projection` is #[non_exhaustive]; v0.2 variants
138                // get ignored here until a lifter is shipped.
139            }
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
170// ── JWKS helper ───────────────────────────────────────────────
171
172fn 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}