inherence-verifier 0.1.0

Reference verifier for Inherence receipts (verification protocol v1).
Documentation
//! Bare-bones JWT (RFC 7519) parsing + EdDSA signature verification.
//! We intentionally avoid `jsonwebtoken` to keep WASM bundle size
//! small.

use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde_json::Value;
use sha2::{Digest, Sha256};

use crate::{base64_url_decode, VerifyConfig};
use crate::error::VerifyError;

pub struct ParsedReceipt {
    pub header: Value,
    pub payload: Value,
    pub signing_pubkey: [u8; 32],
}

/// First 16 hex chars of SHA-256(pubkey_bytes). Matches the SDK's
/// `SigningKey::kid()` convention.
pub fn kid_for_pubkey(pubkey: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(pubkey);
    let digest = h.finalize();
    hex::encode(&digest[..])[..16].to_string()
}

/// Parse and verify the JWT signature against the verifier's pinned
/// authority key set. The pinned key is selected by `kid` from the
/// receipt's header — but the verifier ONLY accepts kids that are
/// in the pinned set, ignoring the embedded `jwk` for trust.
pub fn parse_and_verify(jwt: &str, cfg: &VerifyConfig) -> Result<ParsedReceipt, VerifyError> {
    let parts: Vec<&str> = jwt.split('.').collect();
    if parts.len() != 3 {
        return Err(VerifyError::SchemaViolation(
            format!("JWT must have 3 segments, got {}", parts.len())));
    }

    let header_bytes = base64_url_decode(parts[0])
        .map_err(|_| VerifyError::SchemaViolation("header is not base64url".into()))?;
    let header: Value = serde_json::from_slice(&header_bytes)
        .map_err(|_| VerifyError::SchemaViolation("header is not JSON".into()))?;

    let alg = header.get("alg").and_then(|v| v.as_str()).unwrap_or("");
    if alg != "EdDSA" {
        return Err(VerifyError::SchemaViolation(
            format!("alg must be EdDSA, got {alg:?}")));
    }
    let kid = header.get("kid").and_then(|v| v.as_str()).unwrap_or("");
    if kid.is_empty() {
        return Err(VerifyError::SchemaViolation("header missing kid".into()));
    }

    // The verifier MUST consult its pinned authority set keyed by
    // kid. The receipt's own jwk is informational only.
    let pubkey_bytes = cfg.lookup_authority(kid).ok_or(VerifyError::WrongSigner)?;
    let pubkey_arr: [u8; 32] = pubkey_bytes.try_into()
        .map_err(|_| VerifyError::SchemaViolation("pinned authority key wrong length".into()))?;

    let vk = VerifyingKey::from_bytes(&pubkey_arr)
        .map_err(|e| VerifyError::SignatureInvalid(format!("bad authority pubkey: {e}")))?;

    let signing_input = format!("{}.{}", parts[0], parts[1]);
    let sig_bytes = base64_url_decode(parts[2])
        .map_err(|_| VerifyError::SignatureInvalid("signature segment not base64url".into()))?;
    let sig = Signature::from_slice(&sig_bytes)
        .map_err(|e| VerifyError::SignatureInvalid(format!("malformed signature: {e}")))?;
    vk.verify(signing_input.as_bytes(), &sig)
        .map_err(|e| VerifyError::SignatureInvalid(format!("EdDSA verify failed: {e}")))?;

    let payload_bytes = base64_url_decode(parts[1])
        .map_err(|_| VerifyError::SchemaViolation("payload is not base64url".into()))?;
    let payload: Value = serde_json::from_slice(&payload_bytes)
        .map_err(|_| VerifyError::SchemaViolation("payload is not JSON".into()))?;

    Ok(ParsedReceipt { header, payload, signing_pubkey: pubkey_arr })
}