pic_continuity/artifacts/
token.rs1use crate::error::ContinuityError;
29use crate::trust::{ArtifactSigner, ArtifactVerifier};
30use base64::Engine;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use serde::{Deserialize, Serialize};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct PicClaim {
37 pub root: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
43 pub compositions: Option<Vec<String>>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct PicTokenClaims {
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub iss: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub sub: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub aud: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub iat: Option<i64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub exp: Option<i64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub jti: Option<String>,
67 pub profile: String,
69 pub pic: PicClaim,
71}
72
73impl PicTokenClaims {
74 pub fn for_continuity(continuity_bytes: &[u8]) -> Self {
76 Self {
77 iss: None,
78 sub: None,
79 aud: None,
80 iat: None,
81 exp: None,
82 jti: None,
83 profile: crate::PROFILE_0_2.to_string(),
84 pic: PicClaim {
85 root: URL_SAFE_NO_PAD.encode(continuity_bytes),
86 compositions: None,
87 },
88 }
89 }
90
91 pub fn root_bytes(&self) -> Result<Vec<u8>, ContinuityError> {
93 URL_SAFE_NO_PAD
94 .decode(&self.pic.root)
95 .map_err(|e| ContinuityError::Jws(format!("pic.root is not valid base64url: {e}")))
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct JwsHeader {
101 alg: String,
102 typ: String,
103}
104
105#[derive(Debug, Clone)]
107pub struct DecodedToken {
108 pub alg: String,
110 pub typ: String,
112 pub claims: PicTokenClaims,
114 pub signing_input: Vec<u8>,
116 pub signature: Vec<u8>,
118}
119
120pub fn sign_token(
122 claims: &PicTokenClaims,
123 signer: &dyn ArtifactSigner,
124) -> Result<String, ContinuityError> {
125 let header = JwsHeader {
126 alg: signer.jws_algorithm().to_string(),
127 typ: crate::FORMAT_PIC_TOKEN_JWT.to_string(),
128 };
129 let header_b64 = URL_SAFE_NO_PAD
130 .encode(serde_json::to_vec(&header).map_err(|e| ContinuityError::Jws(e.to_string()))?);
131 let payload_b64 = URL_SAFE_NO_PAD
132 .encode(serde_json::to_vec(claims).map_err(|e| ContinuityError::Jws(e.to_string()))?);
133 let signing_input = format!("{header_b64}.{payload_b64}");
134 let signature = signer.sign(signing_input.as_bytes())?;
135 Ok(format!(
136 "{signing_input}.{}",
137 URL_SAFE_NO_PAD.encode(signature)
138 ))
139}
140
141pub fn decode_token(token: &str) -> Result<DecodedToken, ContinuityError> {
146 let mut parts = token.split('.');
147 let (h, p, s) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
148 (Some(h), Some(p), Some(s), None) => (h, p, s),
149 _ => {
150 return Err(ContinuityError::Jws(
151 "token is not a compact JWS with three segments".into(),
152 ));
153 }
154 };
155
156 let header_bytes = URL_SAFE_NO_PAD
157 .decode(h)
158 .map_err(|e| ContinuityError::Jws(format!("header: {e}")))?;
159 let header: JwsHeader = serde_json::from_slice(&header_bytes)
160 .map_err(|e| ContinuityError::Jws(format!("header: {e}")))?;
161
162 let payload_bytes = URL_SAFE_NO_PAD
163 .decode(p)
164 .map_err(|e| ContinuityError::Jws(format!("payload: {e}")))?;
165 let claims: PicTokenClaims = serde_json::from_slice(&payload_bytes)
166 .map_err(|e| ContinuityError::Jws(format!("payload: {e}")))?;
167
168 let signature = URL_SAFE_NO_PAD
169 .decode(s)
170 .map_err(|e| ContinuityError::Jws(format!("signature: {e}")))?;
171
172 Ok(DecodedToken {
173 alg: header.alg,
174 typ: header.typ,
175 claims,
176 signing_input: format!("{h}.{p}").into_bytes(),
177 signature,
178 })
179}
180
181pub fn verify_token(
183 token: &str,
184 verifier: &dyn ArtifactVerifier,
185) -> Result<PicTokenClaims, ContinuityError> {
186 let decoded = decode_token(token)?;
187 if decoded.typ != crate::FORMAT_PIC_TOKEN_JWT {
188 return Err(ContinuityError::Jws(format!(
189 "typ must be {}, got {}",
190 crate::FORMAT_PIC_TOKEN_JWT,
191 decoded.typ
192 )));
193 }
194 if let Some(expected) = verifier.expected_jws_algorithm()
195 && decoded.alg != expected
196 {
197 return Err(ContinuityError::Jws(format!(
198 "alg must be {expected}, got {}",
199 decoded.alg
200 )));
201 }
202 if !verifier.verify(&decoded.signing_input, &decoded.signature) {
203 return Err(ContinuityError::Jws("signature verification failed".into()));
204 }
205 Ok(decoded.claims)
206}
207
208#[cfg(all(test, feature = "ed25519"))]
209mod tests {
210 use super::*;
211 use crate::trust::{Ed25519Signer, Ed25519Verifier};
212 use ed25519_dalek::SigningKey;
213 use rand::rngs::OsRng;
214
215 #[test]
216 fn sign_decode_verify_roundtrip() {
217 let key = SigningKey::generate(&mut OsRng);
218 let signer = Ed25519Signer::new(key.clone(), "https://realm.example.com/keys/1");
219 let verifier = Ed25519Verifier::new(key.verifying_key());
220
221 let mut claims = PicTokenClaims::for_continuity(b"exact-continuity-bytes");
222 claims.iss = Some("https://pic-x.example.com/realms/acme".into());
223 claims.iat = Some(1786700400);
224
225 let token = sign_token(&claims, &signer).unwrap();
226 let decoded = decode_token(&token).unwrap();
227 assert_eq!(decoded.typ, crate::FORMAT_PIC_TOKEN_JWT);
228 assert_eq!(decoded.alg, "EdDSA");
229
230 let verified = verify_token(&token, &verifier).unwrap();
231 assert_eq!(verified, claims);
232 assert_eq!(verified.root_bytes().unwrap(), b"exact-continuity-bytes");
233 }
234
235 #[test]
236 fn wrong_key_fails() {
237 let key = SigningKey::generate(&mut OsRng);
238 let other = SigningKey::generate(&mut OsRng);
239 let signer = Ed25519Signer::new(key, "kid");
240 let claims = PicTokenClaims::for_continuity(b"bytes");
241 let token = sign_token(&claims, &signer).unwrap();
242
243 let verifier = Ed25519Verifier::new(other.verifying_key());
244 assert!(verify_token(&token, &verifier).is_err());
245 }
246
247 #[test]
248 fn tampered_payload_fails() {
249 let key = SigningKey::generate(&mut OsRng);
250 let signer = Ed25519Signer::new(key.clone(), "kid");
251 let claims = PicTokenClaims::for_continuity(b"bytes");
252 let token = sign_token(&claims, &signer).unwrap();
253
254 let other = PicTokenClaims::for_continuity(b"different");
256 let fake_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
257 .encode(serde_json::to_vec(&other).unwrap());
258 let mut parts: Vec<&str> = token.split('.').collect();
259 parts[1] = &fake_payload;
260 let tampered = parts.join(".");
261
262 let verifier = Ed25519Verifier::new(key.verifying_key());
263 assert!(verify_token(&tampered, &verifier).is_err());
264 }
265
266 #[test]
267 fn wrong_typ_fails_even_with_valid_signature() {
268 let key = SigningKey::generate(&mut OsRng);
269 let signer = Ed25519Signer::new(key.clone(), "kid");
270 let verifier = Ed25519Verifier::new(key.verifying_key());
271 let claims = PicTokenClaims::for_continuity(b"bytes");
272
273 let header = JwsHeader {
274 alg: "EdDSA".into(),
275 typ: "at+jwt".into(),
276 };
277 let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
278 .encode(serde_json::to_vec(&header).unwrap());
279 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
280 .encode(serde_json::to_vec(&claims).unwrap());
281 let signing_input = format!("{header_b64}.{payload_b64}");
282 let signature = signer.sign(signing_input.as_bytes()).unwrap();
283 let token = format!(
284 "{signing_input}.{}",
285 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
286 );
287
288 assert!(verify_token(&token, &verifier).is_err());
289 }
290}