nucleus-identity-projection 0.1.0

Identity projection lifter for the Nucleus substrate. Lifts a JWT-SVID (SPIFFE 2.0) into a Projection::Identity body that the substrate Receipt envelope can carry. Verifier path validates the SVID against a published JWKS — fully offline once the JWKS is fetched.
Documentation
//! # nucleus-identity-projection — JWT-SVID adapter for the substrate
//!
//! Implements the **Identity projection** functor from
//! [`nucleus_substrate_core`]: lifts a [JWT-SVID][spiffe-spec] into
//! the typed body of a [`Projection::Identity`] variant, and verifies
//! it offline against a published JWKS.
//!
//! [spiffe-spec]: https://spiffe.io/docs/latest/spiffe-specs/jwt-svid/
//! [`Projection::Identity`]: nucleus_substrate_core::Projection::Identity
//!
//! ## Wire shape
//!
//! ```json
//! {
//!   "kind": "identity",
//!   "body": {
//!     "version": 1,
//!     "subject": "spiffe://example.local/agent",
//!     "audience": "nucleus-substrate",
//!     "issuer_kid": "...",
//!     "svid_jwt": "eyJ..."
//!   }
//! }
//! ```
//!
//! ## Verifier path
//!
//! [`verify_identity_projection`] takes the body + the issuer's JWKS
//! JSON, locates the Ed25519 verifying key by `issuer_kid`, and runs
//! the standard [`jsonwebtoken`] decode + validate path. Claim checks:
//!
//! 1. JWT signature verifies against the JWKS key for `issuer_kid`.
//! 2. JWT `sub` matches `body.subject`.
//! 3. JWT `aud` matches `body.audience`.
//! 4. JWT `exp` is in the future.
//!
//! Any failure → [`IdentityVerifyError`].

use base64::Engine;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use nucleus_substrate_core::Projection;
use serde::{Deserialize, Serialize};

/// Wire-stable shape for the Identity projection body.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityBody {
    pub version: u32,
    pub subject: String,
    pub audience: String,
    pub issuer_kid: String,
    /// The actual JWT-SVID. Carries `sub`, `aud`, `exp`, and a `kid`
    /// header pointing at the issuer's JWKS entry.
    pub svid_jwt: String,
}

pub const IDENTITY_BODY_VERSION: u32 = 1;

/// Build a `Projection::Identity` carrying the supplied JWT-SVID.
/// Callers will typically place this into the `projections` field of
/// a [`nucleus_substrate_core::Receipt`].
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"))
}

/// Verify an Identity projection offline against the issuer's JWKS.
///
/// `jwks_json` is the raw JWKS document (`{"keys": [...]}`) as
/// fetched from the issuer's `/.well-known/jwks.json` endpoint —
/// passed as parsed JSON so callers can cache it as they like.
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)
}

/// Minimum required JWT-SVID claims per the spec. Extra claims are
/// allowed (and ignored here); `serde_json::Value` capture is left for
/// callers that need provider-specific extensions.
#[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,
    },
}

// ── JWKS helpers ──────────────────────────────────────────────

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
    }

    /// Build a fixture: signing key, matching JWKS, and an Ed25519 JWT.
    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();

        // Build PKCS#8 PEM for jsonwebtoken's EncodingKey::from_ed_pem.
        // PKCS#8 for Ed25519 = 30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20 + 32 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)));
    }
}