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 !verifier.verify(&decoded.signing_input, &decoded.signature) {
195 return Err(ContinuityError::Jws("signature verification failed".into()));
196 }
197 Ok(decoded.claims)
198}
199
200#[cfg(all(test, feature = "ed25519"))]
201mod tests {
202 use super::*;
203 use crate::trust::{Ed25519Signer, Ed25519Verifier};
204 use ed25519_dalek::SigningKey;
205 use rand::rngs::OsRng;
206
207 #[test]
208 fn sign_decode_verify_roundtrip() {
209 let key = SigningKey::generate(&mut OsRng);
210 let signer = Ed25519Signer::new(key.clone(), "https://realm.example.com/keys/1");
211 let verifier = Ed25519Verifier::new(key.verifying_key());
212
213 let mut claims = PicTokenClaims::for_continuity(b"exact-continuity-bytes");
214 claims.iss = Some("https://pic-x.example.com/realms/acme".into());
215 claims.iat = Some(1786700400);
216
217 let token = sign_token(&claims, &signer).unwrap();
218 let decoded = decode_token(&token).unwrap();
219 assert_eq!(decoded.typ, crate::FORMAT_PIC_TOKEN_JWT);
220 assert_eq!(decoded.alg, "EdDSA");
221
222 let verified = verify_token(&token, &verifier).unwrap();
223 assert_eq!(verified, claims);
224 assert_eq!(verified.root_bytes().unwrap(), b"exact-continuity-bytes");
225 }
226
227 #[test]
228 fn wrong_key_fails() {
229 let key = SigningKey::generate(&mut OsRng);
230 let other = SigningKey::generate(&mut OsRng);
231 let signer = Ed25519Signer::new(key, "kid");
232 let claims = PicTokenClaims::for_continuity(b"bytes");
233 let token = sign_token(&claims, &signer).unwrap();
234
235 let verifier = Ed25519Verifier::new(other.verifying_key());
236 assert!(verify_token(&token, &verifier).is_err());
237 }
238
239 #[test]
240 fn tampered_payload_fails() {
241 let key = SigningKey::generate(&mut OsRng);
242 let signer = Ed25519Signer::new(key.clone(), "kid");
243 let claims = PicTokenClaims::for_continuity(b"bytes");
244 let token = sign_token(&claims, &signer).unwrap();
245
246 let other = PicTokenClaims::for_continuity(b"different");
248 let fake_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
249 .encode(serde_json::to_vec(&other).unwrap());
250 let mut parts: Vec<&str> = token.split('.').collect();
251 parts[1] = &fake_payload;
252 let tampered = parts.join(".");
253
254 let verifier = Ed25519Verifier::new(key.verifying_key());
255 assert!(verify_token(&tampered, &verifier).is_err());
256 }
257
258 #[test]
259 fn wrong_typ_fails_even_with_valid_signature() {
260 let key = SigningKey::generate(&mut OsRng);
261 let signer = Ed25519Signer::new(key.clone(), "kid");
262 let verifier = Ed25519Verifier::new(key.verifying_key());
263 let claims = PicTokenClaims::for_continuity(b"bytes");
264
265 let header = JwsHeader {
266 alg: "EdDSA".into(),
267 typ: "at+jwt".into(),
268 };
269 let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
270 .encode(serde_json::to_vec(&header).unwrap());
271 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
272 .encode(serde_json::to_vec(&claims).unwrap());
273 let signing_input = format!("{header_b64}.{payload_b64}");
274 let signature = signer.sign(signing_input.as_bytes()).unwrap();
275 let token = format!(
276 "{signing_input}.{}",
277 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature)
278 );
279
280 assert!(verify_token(&token, &verifier).is_err());
281 }
282}