use base64::Engine;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use nucleus_substrate_core::Projection;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityBody {
pub version: u32,
pub subject: String,
pub audience: String,
pub issuer_kid: String,
pub svid_jwt: String,
}
pub const IDENTITY_BODY_VERSION: u32 = 1;
pub fn identity_projection(
subject: impl Into<String>,
audience: impl Into<String>,
issuer_kid: impl Into<String>,
svid_jwt: impl Into<String>,
) -> Projection {
let body = IdentityBody {
version: IDENTITY_BODY_VERSION,
subject: subject.into(),
audience: audience.into(),
issuer_kid: issuer_kid.into(),
svid_jwt: svid_jwt.into(),
};
Projection::Identity(serde_json::to_value(body).expect("IdentityBody serializes"))
}
pub fn verify_identity_projection(
body: &IdentityBody,
jwks_json: &serde_json::Value,
) -> Result<jsonwebtoken::TokenData<JwtSvidClaims>, IdentityVerifyError> {
if body.version != IDENTITY_BODY_VERSION {
return Err(IdentityVerifyError::UnsupportedBodyVersion(body.version));
}
let vk_bytes = extract_ed25519_vk(jwks_json, &body.issuer_kid)
.ok_or_else(|| IdentityVerifyError::JwksMissingKid(body.issuer_kid.clone()))?;
let decoding_key = DecodingKey::from_ed_der(&vk_bytes);
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_audience(&[&body.audience]);
validation.required_spec_claims =
["sub", "aud", "exp"].into_iter().map(String::from).collect();
let token: jsonwebtoken::TokenData<JwtSvidClaims> =
decode(&body.svid_jwt, &decoding_key, &validation)
.map_err(|e| IdentityVerifyError::JwtDecode(e.to_string()))?;
if token.claims.sub != body.subject {
return Err(IdentityVerifyError::SubjectMismatch {
jwt_sub: token.claims.sub.clone(),
body_subject: body.subject.clone(),
});
}
Ok(token)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtSvidClaims {
pub sub: String,
pub aud: String,
pub exp: u64,
#[serde(default)]
pub iss: Option<String>,
#[serde(default)]
pub iat: Option<u64>,
}
#[derive(Debug, thiserror::Error)]
pub enum IdentityVerifyError {
#[error("identity body version {0} not supported by this lifter")]
UnsupportedBodyVersion(u32),
#[error("JWKS has no key with kid {0}")]
JwksMissingKid(String),
#[error("JWT decode failed: {0}")]
JwtDecode(String),
#[error("JWT sub {jwt_sub} does not match body.subject {body_subject}")]
SubjectMismatch {
jwt_sub: String,
body_subject: String,
},
}
fn extract_ed25519_vk(jwks: &serde_json::Value, kid: &str) -> Option<[u8; 32]> {
let keys = jwks.get("keys")?.as_array()?;
for k in keys {
if k.get("kid")?.as_str()? == kid
&& k.get("kty")?.as_str()? == "OKP"
&& k.get("crv")?.as_str()? == "Ed25519"
{
let x = k.get("x")?.as_str()?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(x)
.ok()?;
return bytes.try_into().ok();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode};
fn now_micros_plus(secs: i64) -> u64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
(now + secs) as u64
}
fn fixture(sub: &str, aud: &str, kid: &str, exp_secs: i64) -> (String, serde_json::Value) {
use ed25519_dalek::SigningKey;
let sk = SigningKey::from_bytes(&[42u8; 32]);
let vk_bytes = sk.verifying_key().to_bytes();
let mut pkcs8 = vec![
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
0x04, 0x20,
];
pkcs8.extend_from_slice(&sk.to_bytes());
let pem = format!(
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
base64::engine::general_purpose::STANDARD.encode(&pkcs8)
);
let enc = EncodingKey::from_ed_pem(pem.as_bytes()).expect("ed pem decodes");
let mut header = Header::new(Algorithm::EdDSA);
header.kid = Some(kid.to_string());
let claims = serde_json::json!({
"sub": sub,
"aud": aud,
"exp": now_micros_plus(exp_secs),
"iss": "https://test.local",
"iat": now_micros_plus(0),
});
let token = encode(&header, &claims, &enc).expect("jwt encodes");
let jwks = serde_json::json!({
"keys": [{
"kty": "OKP",
"crv": "Ed25519",
"kid": kid,
"alg": "EdDSA",
"x": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&vk_bytes),
}]
});
(token, jwks)
}
#[test]
fn happy_path_verify_succeeds() {
let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
let body = IdentityBody {
version: IDENTITY_BODY_VERSION,
subject: "spiffe://test/agent".into(),
audience: "test-aud".into(),
issuer_kid: "kid-1".into(),
svid_jwt: token,
};
let result = verify_identity_projection(&body, &jwks).expect("happy path");
assert_eq!(result.claims.sub, "spiffe://test/agent");
}
#[test]
fn tampered_subject_fails_verify() {
let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
let body = IdentityBody {
version: IDENTITY_BODY_VERSION,
subject: "spiffe://attacker/imposter".into(),
audience: "test-aud".into(),
issuer_kid: "kid-1".into(),
svid_jwt: token,
};
let err = verify_identity_projection(&body, &jwks).unwrap_err();
assert!(matches!(err, IdentityVerifyError::SubjectMismatch { .. }));
}
#[test]
fn missing_kid_in_jwks_fails_verify() {
let (token, _wrong_jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
let empty_jwks = serde_json::json!({"keys": []});
let body = IdentityBody {
version: IDENTITY_BODY_VERSION,
subject: "spiffe://test/agent".into(),
audience: "test-aud".into(),
issuer_kid: "kid-1".into(),
svid_jwt: token,
};
let err = verify_identity_projection(&body, &empty_jwks).unwrap_err();
assert!(matches!(err, IdentityVerifyError::JwksMissingKid(_)));
}
#[test]
fn identity_projection_helper_packs_correct_wire_shape() {
let projection = identity_projection(
"spiffe://test/agent",
"test-aud",
"kid-1",
"eyJhbGciOi.fake.token",
);
assert_eq!(projection.kind(), "identity");
let v = serde_json::to_value(&projection).unwrap();
assert_eq!(v["kind"], "identity");
assert_eq!(v["body"]["subject"], "spiffe://test/agent");
assert_eq!(v["body"]["audience"], "test-aud");
assert_eq!(v["body"]["version"], IDENTITY_BODY_VERSION);
}
#[test]
fn body_version_mismatch_fails_verify() {
let (token, jwks) = fixture("spiffe://test/agent", "test-aud", "kid-1", 60);
let mut body = IdentityBody {
version: IDENTITY_BODY_VERSION,
subject: "spiffe://test/agent".into(),
audience: "test-aud".into(),
issuer_kid: "kid-1".into(),
svid_jwt: token,
};
body.version = 99;
let err = verify_identity_projection(&body, &jwks).unwrap_err();
assert!(matches!(err, IdentityVerifyError::UnsupportedBodyVersion(99)));
}
}