use crate::error::ContinuityError;
use crate::trust::{ArtifactSigner, ArtifactVerifier};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PicClaim {
pub root: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub compositions: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PicTokenClaims {
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
pub profile: String,
pub pic: PicClaim,
}
impl PicTokenClaims {
pub fn for_continuity(continuity_bytes: &[u8]) -> Self {
Self {
iss: None,
sub: None,
aud: None,
iat: None,
exp: None,
jti: None,
profile: crate::PROFILE_0_2.to_string(),
pic: PicClaim {
root: URL_SAFE_NO_PAD.encode(continuity_bytes),
compositions: None,
},
}
}
pub fn root_bytes(&self) -> Result<Vec<u8>, ContinuityError> {
URL_SAFE_NO_PAD
.decode(&self.pic.root)
.map_err(|e| ContinuityError::Jws(format!("pic.root is not valid base64url: {e}")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct JwsHeader {
alg: String,
typ: String,
}
#[derive(Debug, Clone)]
pub struct DecodedToken {
pub alg: String,
pub typ: String,
pub claims: PicTokenClaims,
pub signing_input: Vec<u8>,
pub signature: Vec<u8>,
}
pub fn sign_token(
claims: &PicTokenClaims,
signer: &dyn ArtifactSigner,
) -> Result<String, ContinuityError> {
let header = JwsHeader {
alg: signer.jws_algorithm().to_string(),
typ: crate::FORMAT_PIC_TOKEN_JWT.to_string(),
};
let header_b64 = URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&header).map_err(|e| ContinuityError::Jws(e.to_string()))?);
let payload_b64 = URL_SAFE_NO_PAD
.encode(serde_json::to_vec(claims).map_err(|e| ContinuityError::Jws(e.to_string()))?);
let signing_input = format!("{header_b64}.{payload_b64}");
let signature = signer.sign(signing_input.as_bytes())?;
Ok(format!(
"{signing_input}.{}",
URL_SAFE_NO_PAD.encode(signature)
))
}
pub fn decode_token(token: &str) -> Result<DecodedToken, ContinuityError> {
let mut parts = token.split('.');
let (h, p, s) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(h), Some(p), Some(s), None) => (h, p, s),
_ => {
return Err(ContinuityError::Jws(
"token is not a compact JWS with three segments".into(),
));
}
};
let header_bytes = URL_SAFE_NO_PAD
.decode(h)
.map_err(|e| ContinuityError::Jws(format!("header: {e}")))?;
let header: JwsHeader = serde_json::from_slice(&header_bytes)
.map_err(|e| ContinuityError::Jws(format!("header: {e}")))?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(p)
.map_err(|e| ContinuityError::Jws(format!("payload: {e}")))?;
let claims: PicTokenClaims = serde_json::from_slice(&payload_bytes)
.map_err(|e| ContinuityError::Jws(format!("payload: {e}")))?;
let signature = URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| ContinuityError::Jws(format!("signature: {e}")))?;
Ok(DecodedToken {
alg: header.alg,
typ: header.typ,
claims,
signing_input: format!("{h}.{p}").into_bytes(),
signature,
})
}
pub fn verify_token(
token: &str,
verifier: &dyn ArtifactVerifier,
) -> Result<PicTokenClaims, ContinuityError> {
let decoded = decode_token(token)?;
if decoded.typ != crate::FORMAT_PIC_TOKEN_JWT {
return Err(ContinuityError::Jws(format!(
"typ must be {}, got {}",
crate::FORMAT_PIC_TOKEN_JWT,
decoded.typ
)));
}
if let Some(expected) = verifier.expected_jws_algorithm()
&& decoded.alg != expected
{
return Err(ContinuityError::Jws(format!(
"alg must be {expected}, got {}",
decoded.alg
)));
}
if !verifier.verify(&decoded.signing_input, &decoded.signature) {
return Err(ContinuityError::Jws("signature verification failed".into()));
}
Ok(decoded.claims)
}
#[cfg(all(test, feature = "ed25519"))]
mod tests {
use super::*;
use crate::trust::{Ed25519Signer, Ed25519Verifier};
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
#[test]
fn sign_decode_verify_roundtrip() {
let key = SigningKey::generate(&mut OsRng);
let signer = Ed25519Signer::new(key.clone(), "https://realm.example.com/keys/1");
let verifier = Ed25519Verifier::new(key.verifying_key());
let mut claims = PicTokenClaims::for_continuity(b"exact-continuity-bytes");
claims.iss = Some("https://pic-x.example.com/realms/acme".into());
claims.iat = Some(1786700400);
let token = sign_token(&claims, &signer).unwrap();
let decoded = decode_token(&token).unwrap();
assert_eq!(decoded.typ, crate::FORMAT_PIC_TOKEN_JWT);
assert_eq!(decoded.alg, "EdDSA");
let verified = verify_token(&token, &verifier).unwrap();
assert_eq!(verified, claims);
assert_eq!(verified.root_bytes().unwrap(), b"exact-continuity-bytes");
}
#[test]
fn wrong_key_fails() {
let key = SigningKey::generate(&mut OsRng);
let other = SigningKey::generate(&mut OsRng);
let signer = Ed25519Signer::new(key, "kid");
let claims = PicTokenClaims::for_continuity(b"bytes");
let token = sign_token(&claims, &signer).unwrap();
let verifier = Ed25519Verifier::new(other.verifying_key());
assert!(verify_token(&token, &verifier).is_err());
}
#[test]
fn tampered_payload_fails() {
let key = SigningKey::generate(&mut OsRng);
let signer = Ed25519Signer::new(key.clone(), "kid");
let claims = PicTokenClaims::for_continuity(b"bytes");
let token = sign_token(&claims, &signer).unwrap();
let other = PicTokenClaims::for_continuity(b"different");
let fake_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&other).unwrap());
let mut parts: Vec<&str> = token.split('.').collect();
parts[1] = &fake_payload;
let tampered = parts.join(".");
let verifier = Ed25519Verifier::new(key.verifying_key());
assert!(verify_token(&tampered, &verifier).is_err());
}
#[test]
fn wrong_typ_fails_even_with_valid_signature() {
let key = SigningKey::generate(&mut OsRng);
let signer = Ed25519Signer::new(key.clone(), "kid");
let verifier = Ed25519Verifier::new(key.verifying_key());
let claims = PicTokenClaims::for_continuity(b"bytes");
let header = JwsHeader {
alg: "EdDSA".into(),
typ: "at+jwt".into(),
};
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&header).unwrap());
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&claims).unwrap());
let signing_input = format!("{header_b64}.{payload_b64}");
let signature = signer.sign(signing_input.as_bytes()).unwrap();
let token = format!(
"{signing_input}.{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
);
assert!(verify_token(&token, &verifier).is_err());
}
}