Skip to main content

pic_continuity/artifacts/
token.rs

1/*
2 * Copyright Nitro Agility S.r.l.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! PIC Token JWT (`pic+jwt`): the external envelope.
18//!
19//! `pic.root` carries the unpadded Base64url encoding of the **exact binary
20//! PIC Continuity COSE bytes**. Candidate tokens are workload-signed (`iss`
21//! optional — identity metadata, not the source of trust); settled tokens are
22//! signed by the trusted settlement authority.
23//!
24//! Signing is delegated to [`crate::trust::ArtifactSigner`], so any JOSE
25//! stack can plug in; an Ed25519 implementation ships behind the `ed25519`
26//! feature.
27
28use 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/// The `pic` claim.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct PicClaim {
37    /// Unpadded Base64url of the exact PIC Continuity COSE bytes.
38    pub root: String,
39    /// Reserved for future composition: additional PIC Continuity COSE
40    /// values, each unpadded Base64url of exact bytes. Not defined by
41    /// Profile 0.2 processing; carried for representation fidelity only.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub compositions: Option<Vec<String>>,
44}
45
46/// PIC Token JWT claims.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct PicTokenClaims {
49    /// Issuer: the realm settlement-authority identity.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub iss: Option<String>,
52    /// Subject.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub sub: Option<String>,
55    /// Audience.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub aud: Option<String>,
58    /// Issued-at (seconds since the Unix epoch).
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub iat: Option<i64>,
61    /// Expiry (seconds since the Unix epoch).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub exp: Option<i64>,
64    /// Token identifier.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub jti: Option<String>,
67    /// PIC profile identifier; must equal [`crate::PROFILE_0_2`].
68    pub profile: String,
69    /// The PIC claim carrying the continuity state.
70    pub pic: PicClaim,
71}
72
73impl PicTokenClaims {
74    /// Builds claims around exact PIC Continuity COSE bytes.
75    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    /// Decodes `pic.root` back into the exact PIC Continuity COSE bytes.
92    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/// A decoded (not yet verified) PIC Token JWT.
106#[derive(Debug, Clone)]
107pub struct DecodedToken {
108    /// JOSE `alg` header value.
109    pub alg: String,
110    /// JOSE `typ` header value; `"pic+jwt"` for a PIC Token.
111    pub typ: String,
112    /// The decoded claim set. Untrusted until the signature is verified.
113    pub claims: PicTokenClaims,
114    /// The JWS signing input (`b64(header) . b64(payload)`).
115    pub signing_input: Vec<u8>,
116    /// The raw JWS signature bytes.
117    pub signature: Vec<u8>,
118}
119
120/// Signs claims into a compact JWS with `typ = "pic+jwt"`.
121pub 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
141/// Decodes a compact JWS without verifying the signature.
142///
143/// Use only where the specification treats the token as untrusted input to
144/// be parsed before validation.
145pub 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
181/// Decodes and verifies a PIC Token JWT signature.
182pub 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        // Swap the payload segment with another encoded payload.
247        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}