Skip to main content

nucleus_identity_projection/
lib.rs

1//! # nucleus-identity-projection — JWT-SVID adapter for the substrate
2//!
3//! Implements the **Identity projection** functor from
4//! [`nucleus_substrate_core`]: lifts a [JWT-SVID][spiffe-spec] into
5//! the typed body of a [`Projection::Identity`] variant, and verifies
6//! it offline against a published JWKS.
7//!
8//! [spiffe-spec]: https://spiffe.io/docs/latest/spiffe-specs/jwt-svid/
9//! [`Projection::Identity`]: nucleus_substrate_core::Projection::Identity
10//!
11//! ## Wire shape
12//!
13//! ```json
14//! {
15//!   "kind": "identity",
16//!   "body": {
17//!     "version": 1,
18//!     "subject": "spiffe://example.local/agent",
19//!     "audience": "nucleus-substrate",
20//!     "issuer_kid": "...",
21//!     "svid_jwt": "eyJ..."
22//!   }
23//! }
24//! ```
25//!
26//! ## Verifier path
27//!
28//! [`verify_identity_projection`] takes the body + the issuer's JWKS
29//! JSON, locates the Ed25519 verifying key by `issuer_kid`, and runs
30//! the standard [`jsonwebtoken`] decode + validate path. Claim checks:
31//!
32//! 1. JWT signature verifies against the JWKS key for `issuer_kid`.
33//! 2. JWT `sub` matches `body.subject`.
34//! 3. JWT `aud` matches `body.audience`.
35//! 4. JWT `exp` is in the future.
36//!
37//! Any failure → [`IdentityVerifyError`].
38
39use base64::Engine;
40use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
41use nucleus_substrate_core::Projection;
42use serde::{Deserialize, Serialize};
43
44/// Wire-stable shape for the Identity projection body.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct IdentityBody {
47    pub version: u32,
48    pub subject: String,
49    pub audience: String,
50    pub issuer_kid: String,
51    /// The actual JWT-SVID. Carries `sub`, `aud`, `exp`, and a `kid`
52    /// header pointing at the issuer's JWKS entry.
53    pub svid_jwt: String,
54}
55
56pub const IDENTITY_BODY_VERSION: u32 = 1;
57
58/// Build a `Projection::Identity` carrying the supplied JWT-SVID.
59/// Callers will typically place this into the `projections` field of
60/// a [`nucleus_substrate_core::Receipt`].
61pub fn identity_projection(
62    subject: impl Into<String>,
63    audience: impl Into<String>,
64    issuer_kid: impl Into<String>,
65    svid_jwt: impl Into<String>,
66) -> Projection {
67    let body = IdentityBody {
68        version: IDENTITY_BODY_VERSION,
69        subject: subject.into(),
70        audience: audience.into(),
71        issuer_kid: issuer_kid.into(),
72        svid_jwt: svid_jwt.into(),
73    };
74    Projection::Identity(serde_json::to_value(body).expect("IdentityBody serializes"))
75}
76
77/// Verify an Identity projection offline against the issuer's JWKS.
78///
79/// `jwks_json` is the raw JWKS document (`{"keys": [...]}`) as
80/// fetched from the issuer's `/.well-known/jwks.json` endpoint —
81/// passed as parsed JSON so callers can cache it as they like.
82pub fn verify_identity_projection(
83    body: &IdentityBody,
84    jwks_json: &serde_json::Value,
85) -> Result<jsonwebtoken::TokenData<JwtSvidClaims>, IdentityVerifyError> {
86    if body.version != IDENTITY_BODY_VERSION {
87        return Err(IdentityVerifyError::UnsupportedBodyVersion(body.version));
88    }
89    let vk_bytes = extract_ed25519_vk(jwks_json, &body.issuer_kid)
90        .ok_or_else(|| IdentityVerifyError::JwksMissingKid(body.issuer_kid.clone()))?;
91    let decoding_key = DecodingKey::from_ed_der(&vk_bytes);
92    let mut validation = Validation::new(Algorithm::EdDSA);
93    validation.set_audience(&[&body.audience]);
94    validation.required_spec_claims =
95        ["sub", "aud", "exp"].into_iter().map(String::from).collect();
96    let token: jsonwebtoken::TokenData<JwtSvidClaims> =
97        decode(&body.svid_jwt, &decoding_key, &validation)
98            .map_err(|e| IdentityVerifyError::JwtDecode(e.to_string()))?;
99    if token.claims.sub != body.subject {
100        return Err(IdentityVerifyError::SubjectMismatch {
101            jwt_sub: token.claims.sub.clone(),
102            body_subject: body.subject.clone(),
103        });
104    }
105    Ok(token)
106}
107
108/// Minimum required JWT-SVID claims per the spec. Extra claims are
109/// allowed (and ignored here); `serde_json::Value` capture is left for
110/// callers that need provider-specific extensions.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct JwtSvidClaims {
113    pub sub: String,
114    pub aud: String,
115    pub exp: u64,
116    #[serde(default)]
117    pub iss: Option<String>,
118    #[serde(default)]
119    pub iat: Option<u64>,
120}
121
122#[derive(Debug, thiserror::Error)]
123pub enum IdentityVerifyError {
124    #[error("identity body version {0} not supported by this lifter")]
125    UnsupportedBodyVersion(u32),
126    #[error("JWKS has no key with kid {0}")]
127    JwksMissingKid(String),
128    #[error("JWT decode failed: {0}")]
129    JwtDecode(String),
130    #[error("JWT sub {jwt_sub} does not match body.subject {body_subject}")]
131    SubjectMismatch {
132        jwt_sub: String,
133        body_subject: String,
134    },
135}
136
137// ── JWKS helpers ──────────────────────────────────────────────
138
139fn extract_ed25519_vk(jwks: &serde_json::Value, kid: &str) -> Option<[u8; 32]> {
140    let keys = jwks.get("keys")?.as_array()?;
141    for k in keys {
142        if k.get("kid")?.as_str()? == kid
143            && k.get("kty")?.as_str()? == "OKP"
144            && k.get("crv")?.as_str()? == "Ed25519"
145        {
146            let x = k.get("x")?.as_str()?;
147            let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
148                .decode(x)
149                .ok()?;
150            return bytes.try_into().ok();
151        }
152    }
153    None
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use jsonwebtoken::{EncodingKey, Header, encode};
160
161    fn now_micros_plus(secs: i64) -> u64 {
162        let now = std::time::SystemTime::now()
163            .duration_since(std::time::UNIX_EPOCH)
164            .map(|d| d.as_secs())
165            .unwrap_or(0) as i64;
166        (now + secs) as u64
167    }
168
169    /// Build a fixture: signing key, matching JWKS, and an Ed25519 JWT.
170    fn fixture(sub: &str, aud: &str, kid: &str, exp_secs: i64) -> (String, serde_json::Value) {
171        use ed25519_dalek::SigningKey;
172        let sk = SigningKey::from_bytes(&[42u8; 32]);
173        let vk_bytes = sk.verifying_key().to_bytes();
174
175        // Build PKCS#8 PEM for jsonwebtoken's EncodingKey::from_ed_pem.
176        // PKCS#8 for Ed25519 = 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 + 32 bytes
177        let mut pkcs8 = vec![
178            0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
179            0x04, 0x20,
180        ];
181        pkcs8.extend_from_slice(&sk.to_bytes());
182        let pem = format!(
183            "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
184            base64::engine::general_purpose::STANDARD.encode(&pkcs8)
185        );
186        let enc = EncodingKey::from_ed_pem(pem.as_bytes()).expect("ed pem decodes");
187
188        let mut header = Header::new(Algorithm::EdDSA);
189        header.kid = Some(kid.to_string());
190        let claims = serde_json::json!({
191            "sub": sub,
192            "aud": aud,
193            "exp": now_micros_plus(exp_secs),
194            "iss": "https://test.local",
195            "iat": now_micros_plus(0),
196        });
197        let token = encode(&header, &claims, &enc).expect("jwt encodes");
198
199        let jwks = serde_json::json!({
200            "keys": [{
201                "kty": "OKP",
202                "crv": "Ed25519",
203                "kid": kid,
204                "alg": "EdDSA",
205                "x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&vk_bytes),
206            }]
207        });
208        (token, jwks)
209    }
210
211    #[test]
212    fn happy_path_verify_succeeds() {
213        let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
214        let body = IdentityBody {
215            version: IDENTITY_BODY_VERSION,
216            subject: "spiffe://test/agent".into(),
217            audience: "test-aud".into(),
218            issuer_kid: "kid-1".into(),
219            svid_jwt: token,
220        };
221        let result = verify_identity_projection(&body, &jwks).expect("happy path");
222        assert_eq!(result.claims.sub, "spiffe://test/agent");
223    }
224
225    #[test]
226    fn tampered_subject_fails_verify() {
227        let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
228        let body = IdentityBody {
229            version: IDENTITY_BODY_VERSION,
230            subject: "spiffe://attacker/imposter".into(),
231            audience: "test-aud".into(),
232            issuer_kid: "kid-1".into(),
233            svid_jwt: token,
234        };
235        let err = verify_identity_projection(&body, &jwks).unwrap_err();
236        assert!(matches!(err, IdentityVerifyError::SubjectMismatch { .. }));
237    }
238
239    #[test]
240    fn missing_kid_in_jwks_fails_verify() {
241        let (token, _wrong_jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
242        let empty_jwks = serde_json::json!({"keys": []});
243        let body = IdentityBody {
244            version: IDENTITY_BODY_VERSION,
245            subject: "spiffe://test/agent".into(),
246            audience: "test-aud".into(),
247            issuer_kid: "kid-1".into(),
248            svid_jwt: token,
249        };
250        let err = verify_identity_projection(&body, &empty_jwks).unwrap_err();
251        assert!(matches!(err, IdentityVerifyError::JwksMissingKid(_)));
252    }
253
254    #[test]
255    fn identity_projection_helper_packs_correct_wire_shape() {
256        let projection = identity_projection(
257            "spiffe://test/agent",
258            "test-aud",
259            "kid-1",
260            "eyJhbGciOi.fake.token",
261        );
262        assert_eq!(projection.kind(), "identity");
263        let v = serde_json::to_value(&projection).unwrap();
264        assert_eq!(v["kind"], "identity");
265        assert_eq!(v["body"]["subject"], "spiffe://test/agent");
266        assert_eq!(v["body"]["audience"], "test-aud");
267        assert_eq!(v["body"]["version"], IDENTITY_BODY_VERSION);
268    }
269
270    #[test]
271    fn body_version_mismatch_fails_verify() {
272        let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
273        let mut body = IdentityBody {
274            version: IDENTITY_BODY_VERSION,
275            subject: "spiffe://test/agent".into(),
276            audience: "test-aud".into(),
277            issuer_kid: "kid-1".into(),
278            svid_jwt: token,
279        };
280        body.version = 99;
281        let err = verify_identity_projection(&body, &jwks).unwrap_err();
282        assert!(matches!(err, IdentityVerifyError::UnsupportedBodyVersion(99)));
283    }
284}