nucleus_identity_projection/
lib.rs1use base64::Engine;
40use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
41use nucleus_substrate_core::Projection;
42use serde::{Deserialize, Serialize};
43
44#[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 pub svid_jwt: String,
54}
55
56pub const IDENTITY_BODY_VERSION: u32 = 1;
57
58pub 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
77pub 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#[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
137fn 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 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 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}