Skip to main content

pic_continuity/
verifier.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 Verifier (Profile 0.2).
18//!
19//! Two roles:
20//!
21//! - [`verify_settled`] — ordinary verification of a settled PIC Token JWT
22//!   before any authority is exercised;
23//! - [`SettlementAuthority`] — the trusted settlement role (PIC-X is one
24//!   realization): validates a workload-signed advancement candidate through
25//!   the complete numbered procedure of the specification and, on success,
26//!   materializes the next checkpoint and issues the next settled token.
27//!   [`issue_settled`] covers initialization (checkpoint 0).
28//!
29//! A valid signature establishes *integrity*, not *semantic validity*: the
30//! semantic checks run independently and are never skipped because a
31//! signature verified.
32
33use crate::artifacts::token::{DecodedToken, PicTokenClaims, decode_token, sign_token};
34use crate::artifacts::{
35    PicContinuityCose, PicContinuityPayload, PicPcaCose, PicPcaPayload, PicTransitionCose,
36    PicTransitionPayload, artifact_sha256,
37};
38use crate::authority::attenuation::{AttenuationOrder, Attenuations, materialize};
39use crate::cose::CoseSigned;
40use crate::error::{ContinuityError, RejectReason};
41use crate::por::PorValidator;
42use crate::trust::{
43    ArtifactSigner, ArtifactVerifier, RevocationCheck, SettlementPolicy, TrustedCheckpoint,
44};
45
46// ---------------------------------------------------------------------------
47// Ordinary verification of settled artifacts
48// ---------------------------------------------------------------------------
49
50/// A verified settled continuity state.
51#[derive(Debug, Clone)]
52pub struct SettledState {
53    /// The verified PIC Token JWT claim set.
54    pub claims: PicTokenClaims,
55    /// The verified settled PIC Continuity payload (`transitions = null`).
56    pub continuity: PicContinuityPayload,
57    /// Exact signed PIC PCA COSE bytes of the current trusted checkpoint.
58    pub pca_bytes: Vec<u8>,
59    /// The decoded checkpoint payload: position, authority, challenge.
60    pub checkpoint: PicPcaPayload,
61}
62
63/// Verifies a settled PIC Token JWT end to end:
64/// realm JWT signature → `pic.root` exact bytes → realm Continuity signature
65/// → `transitions = null` → exact `root.pca` bytes → recomputed
66/// `root.pca_hash` → realm PCA signature → materialized authority.
67pub fn verify_settled(
68    token: &str,
69    realm: &dyn ArtifactVerifier,
70) -> Result<SettledState, ContinuityError> {
71    let decoded = decode_token(token)?;
72    check_token_type(&decoded)?;
73    check_expected_jws_algorithm(&decoded, realm, "PIC Token JWT")?;
74    if !realm.verify(&decoded.signing_input, &decoded.signature) {
75        return Err(RejectReason::RealmSignature("PIC Token JWT").into());
76    }
77    let claims = decoded.claims;
78    check_claims_profile(&claims)?;
79
80    let continuity_bytes = claims.root_bytes()?;
81    let continuity_cose = PicContinuityCose::from_bytes(&continuity_bytes)?;
82    check_expected_cose_algorithm(continuity_cose.algorithm(), realm, "PIC Continuity COSE")?;
83    let continuity: PicContinuityPayload = continuity_cose
84        .verify_with(|data, sig| {
85            if realm.verify(data, sig) {
86                Ok(())
87            } else {
88                Err(crate::cose::CoseError::VerificationFailed)
89            }
90        })
91        .map_err(|_| RejectReason::RealmSignature("PIC Continuity COSE"))?;
92    continuity.check_profile()?;
93    continuity.require_settled()?;
94    continuity.check_root_hash()?;
95
96    let pca_bytes = continuity.root.pca.clone();
97    let pca_cose = PicPcaCose::from_bytes(&pca_bytes)?;
98    check_expected_cose_algorithm(pca_cose.algorithm(), realm, "PIC PCA COSE")?;
99    let checkpoint: PicPcaPayload = pca_cose
100        .verify_with(|data, sig| {
101            if realm.verify(data, sig) {
102                Ok(())
103            } else {
104                Err(crate::cose::CoseError::VerificationFailed)
105            }
106        })
107        .map_err(|_| RejectReason::RealmSignature("PIC PCA COSE"))?;
108    checkpoint.validate()?;
109    check_exp_matches_checkpoint(&claims, &checkpoint, "PIC Token JWT")?;
110
111    Ok(SettledState {
112        claims,
113        continuity,
114        pca_bytes,
115        checkpoint,
116    })
117}
118
119// ---------------------------------------------------------------------------
120// Settlement (initialization)
121// ---------------------------------------------------------------------------
122
123/// Claims metadata for a settled token.
124#[derive(Debug, Clone, Default)]
125pub struct SettlementContext {
126    /// Realm issuer identity, e.g. `https://pic-x.example.com/realms/acme`.
127    pub iss: String,
128    /// Subject claim for the settled token.
129    pub sub: Option<String>,
130    /// Audience claim for the settled token.
131    pub aud: Option<String>,
132    /// Issued-at (seconds since the Unix epoch).
133    pub iat: Option<i64>,
134    /// Expiry (seconds since the Unix epoch).
135    pub exp: Option<i64>,
136    /// Token identifier.
137    pub jti: Option<String>,
138}
139
140/// A newly settled continuity state.
141#[derive(Debug, Clone)]
142pub struct SettledIssue {
143    /// The settled PIC Token JWT (compact JWS).
144    pub token: String,
145    /// Exact signed bytes of the new PIC PCA COSE checkpoint.
146    pub pca_bytes: Vec<u8>,
147    /// The new checkpoint payload: position, authority, challenge.
148    pub checkpoint: PicPcaPayload,
149    /// Exact signed bytes of the settled PIC Continuity COSE.
150    pub continuity_bytes: Vec<u8>,
151}
152
153/// Signs a checkpoint into the settled artifact chain:
154/// PIC PCA COSE → settled PIC Continuity COSE (`transitions = null`) →
155/// settled PIC Token JWT. This is the initialization path (checkpoint 0,
156/// e.g. after an OAuth-to-PIC exchange) and the tail of every settlement.
157pub fn issue_settled(
158    checkpoint: PicPcaPayload,
159    realm: &dyn ArtifactSigner,
160    ctx: &SettlementContext,
161) -> Result<SettledIssue, ContinuityError> {
162    checkpoint.validate()?;
163    let exp = match (checkpoint.expires_at, ctx.exp) {
164        (Some(checkpoint_exp), Some(context_exp)) if checkpoint_exp != context_exp => {
165            return Err(RejectReason::Malformed(
166                "settlement context exp does not match pca.expires_at".to_owned(),
167            )
168            .into());
169        }
170        (Some(checkpoint_exp), _) => Some(checkpoint_exp),
171        (None, context_exp) => context_exp,
172    };
173
174    let pca_cose: PicPcaCose =
175        CoseSigned::sign_with(&checkpoint, realm.kid(), realm.cose_algorithm(), |data| {
176            realm.sign(data)
177        })?;
178    let pca_bytes = pca_cose.to_bytes()?;
179
180    let continuity = PicContinuityPayload::settled(pca_bytes.clone());
181    let continuity_cose: PicContinuityCose =
182        CoseSigned::sign_with(&continuity, realm.kid(), realm.cose_algorithm(), |data| {
183            realm.sign(data)
184        })?;
185    let continuity_bytes = continuity_cose.to_bytes()?;
186
187    let mut claims = PicTokenClaims::for_continuity(&continuity_bytes);
188    claims.iss = Some(ctx.iss.clone());
189    claims.sub = ctx.sub.clone();
190    claims.aud = ctx.aud.clone();
191    claims.iat = ctx.iat;
192    claims.exp = exp;
193    if let (Some(checkpoint_lineage), Some(context_jti)) = (&checkpoint.lineage_id, &ctx.jti)
194        && checkpoint_lineage != context_jti
195    {
196        return Err(RejectReason::Malformed(
197            "settlement context jti does not match pca.lineage_id".to_owned(),
198        )
199        .into());
200    }
201    claims.jti = ctx.jti.clone().or_else(|| checkpoint.lineage_id.clone());
202    let token = sign_token(&claims, realm)?;
203
204    Ok(SettledIssue {
205        token,
206        pca_bytes,
207        checkpoint,
208        continuity_bytes,
209    })
210}
211
212// ---------------------------------------------------------------------------
213// Settlement (centralized advancement)
214// ---------------------------------------------------------------------------
215
216/// The trusted settlement authority for Profile 0.2 centralized advancement.
217pub struct SettlementAuthority<'a> {
218    /// Store answering whether exact PCA bytes are currently trusted.
219    pub trusted: &'a dyn TrustedCheckpoint,
220    /// Proof of Relationship validator for this deployment.
221    pub por: &'a dyn PorValidator,
222    /// Revocation state lookup.
223    pub revocation: &'a dyn RevocationCheck,
224    /// Deployment policy hooks (request binding, conformance, local policy).
225    pub policy: &'a dyn SettlementPolicy,
226    /// The attenuation order used for the non-expansion check.
227    pub order: &'a dyn AttenuationOrder,
228    /// The realm signing key for the settled artifacts.
229    pub realm: &'a dyn ArtifactSigner,
230}
231
232impl SettlementAuthority<'_> {
233    /// Validates a workload-signed candidate PIC Token JWT and, on success,
234    /// materializes checkpoint N+1 and issues the next settled token.
235    ///
236    /// The numbered comments follow the settlement procedure of the Prover
237    /// and Verifier specification (Section 3.1).
238    pub fn settle(
239        &self,
240        candidate_token: &str,
241        ctx: &SettlementContext,
242    ) -> Result<SettledIssue, ContinuityError> {
243        // 1-2. Receive the candidate as untrusted input; parse without
244        //      accepting authenticity; obtain pic.root bytes.
245        let decoded = decode_token(candidate_token)
246            .map_err(|e| RejectReason::Malformed(format!("candidate token: {e}")))?;
247        check_token_type(&decoded)?;
248        check_claims_profile(&decoded.claims)?;
249        let continuity_bytes = decoded
250            .claims
251            .root_bytes()
252            .map_err(|e| RejectReason::Malformed(format!("pic.root: {e}")))?;
253
254        // 3. Parse the candidate Continuity without accepting authenticity;
255        //    validate the presence and shape of root and transitions.
256        let continuity_cose = PicContinuityCose::from_bytes(&continuity_bytes)
257            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
258        let continuity: PicContinuityPayload = continuity_cose
259            .payload_unverified()
260            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
261        continuity.check_profile()?;
262        if continuity.root.pca.is_empty() || continuity.root.pca_hash.is_empty() {
263            return Err(RejectReason::Malformed("empty continuity root".into()).into());
264        }
265
266        // 4. Exactly one transition.
267        let transition_bytes = continuity.candidate_transition()?.to_vec();
268
269        // 5. Parse the Transition as untrusted input.
270        let transition_cose = PicTransitionCose::from_bytes(&transition_bytes)
271            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
272        let transition: PicTransitionPayload = transition_cose
273            .payload_unverified()
274            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
275        transition.check_profile()?;
276
277        // 6-7. Validate the proof_of_relationship structure and type.
278        let por = &transition.proof_of_relationship;
279        if por.por_type != self.por.accepted_type() {
280            return Err(RejectReason::PorType(por.por_type.clone()).into());
281        }
282        if por.evidence.is_empty() {
283            return Err(RejectReason::PorRejected("empty evidence".into()).into());
284        }
285
286        // 8-10. Validate the evidence per the selected schema and obtain the
287        //       accepted workload verification key.
288        let workload = self.por.validate(por)?;
289
290        // 11. Verify the three workload signatures with that key and their
291        //     signer consistency.
292        check_expected_cose_algorithm(
293            transition_cose.algorithm(),
294            workload.as_ref(),
295            "PIC Continuity Transition COSE",
296        )?;
297        transition_cose
298            .verify_with(|data, sig| {
299                if workload.verify(data, sig) {
300                    Ok(())
301                } else {
302                    Err(crate::cose::CoseError::VerificationFailed)
303                }
304            })
305            .map_err(|_| RejectReason::WorkloadSignature("PIC Continuity Transition COSE"))?;
306        check_expected_cose_algorithm(
307            continuity_cose.algorithm(),
308            workload.as_ref(),
309            "candidate PIC Continuity COSE",
310        )?;
311        continuity_cose
312            .verify_with(|data, sig| {
313                if workload.verify(data, sig) {
314                    Ok(())
315                } else {
316                    Err(crate::cose::CoseError::VerificationFailed)
317                }
318            })
319            .map_err(|_| RejectReason::WorkloadSignature("candidate PIC Continuity COSE"))?;
320        check_expected_jws_algorithm(&decoded, workload.as_ref(), "candidate PIC Token JWT")?;
321        if !workload.verify(&decoded.signing_input, &decoded.signature) {
322            return Err(RejectReason::WorkloadSignature("candidate PIC Token JWT").into());
323        }
324
325        // 12. root.pca must be the exact bytes of the currently trusted
326        //     checkpoint.
327        if !self.trusted.is_current_checkpoint(&continuity.root.pca) {
328            return Err(RejectReason::UntrustedCheckpoint.into());
329        }
330        let checkpoint: PicPcaPayload = PicPcaCose::from_bytes(&continuity.root.pca)
331            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?
332            .payload_unverified()
333            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?;
334        checkpoint.validate()?;
335        if let Some(checkpoint_lineage) = checkpoint.lineage_id.as_deref() {
336            match decoded.claims.jti.as_deref() {
337                Some(candidate_jti) if candidate_jti == checkpoint_lineage => {}
338                Some(_) => {
339                    return Err(RejectReason::Malformed(
340                        "candidate token jti does not match checkpoint lineage_id".to_owned(),
341                    )
342                    .into());
343                }
344                None => {
345                    return Err(RejectReason::Malformed(
346                        "candidate token is missing jti for checkpoint lineage_id".to_owned(),
347                    )
348                    .into());
349                }
350            }
351        }
352        check_exp_matches_checkpoint(&decoded.claims, &checkpoint, "candidate PIC Token JWT")?;
353
354        // 13. Recompute SHA-256(exact root.pca bytes) and compare.
355        continuity.check_root_hash()?;
356
357        // 14. Position progression.
358        if transition.position != checkpoint.position + 1 {
359            return Err(RejectReason::PositionProgression.into());
360        }
361
362        // 15. Predecessor reference: type "pca", hash over the exact
363        //     trusted checkpoint bytes.
364        if transition.predecessor.predecessor_type != crate::PREDECESSOR_TYPE_PCA {
365            return Err(RejectReason::PredecessorType.into());
366        }
367        if transition.predecessor.hash != artifact_sha256(&continuity.root.pca) {
368            return Err(RejectReason::PredecessorHashMismatch.into());
369        }
370
371        // 16. Challenge continuity and next-challenge validity.
372        if transition.challenge.previous_challenge != checkpoint.challenge.next_challenge {
373            return Err(RejectReason::ChallengeContinuity.into());
374        }
375        if transition.challenge.next_challenge.is_empty() {
376            return Err(RejectReason::NextChallengeInvalid.into());
377        }
378
379        // 17. Validate removal bitmaps and execution-contract additions;
380        //     materialize the successor authority (deterministic ordering
381        //     and index assignment happen here).
382        let attenuations: Attenuations = match &transition.attenuations {
383            Some(wire) => wire.parse()?,
384            None => Attenuations::default(),
385        };
386        let next_authority = materialize(&checkpoint.context_of_authority, &attenuations)?;
387
388        // 18. Request/execution binding and executor evidence / conformance,
389        //     when required by the deployment.
390        if !self.policy.request_binding(&transition) {
391            return Err(RejectReason::RequestBinding.into());
392        }
393        if !self.policy.conformance(&checkpoint, &transition) {
394            return Err(RejectReason::ContractConformance.into());
395        }
396
397        // 19. Non-expansion under the selected attenuation order, revocation,
398        //     and local policy.
399        if !self
400            .order
401            .attenuates(&next_authority, &checkpoint.context_of_authority)
402        {
403            return Err(RejectReason::NonExpansion.into());
404        }
405        if self
406            .revocation
407            .is_revoked(&checkpoint, &continuity.root.pca)
408        {
409            return Err(RejectReason::Revoked.into());
410        }
411        if !self.policy.policy(&checkpoint, &next_authority) {
412            return Err(RejectReason::PolicyDenied.into());
413        }
414
415        // 20. Materialize checkpoint N+1, transfer the accepted next
416        //     challenge, and issue the settled artifacts.
417        let next_checkpoint = PicPcaPayload::new(
418            transition.position,
419            next_authority,
420            transition.challenge.next_challenge.clone(),
421        )
422        .with_optional_lineage_id(checkpoint.lineage_id.clone())
423        .with_optional_expires_at(checkpoint.expires_at);
424        issue_settled(next_checkpoint, self.realm, ctx)
425    }
426}
427
428fn check_token_type(decoded: &DecodedToken) -> Result<(), RejectReason> {
429    if decoded.typ == crate::FORMAT_PIC_TOKEN_JWT {
430        Ok(())
431    } else {
432        Err(RejectReason::Malformed(format!(
433            "PIC Token JWT typ must be {}, got {}",
434            crate::FORMAT_PIC_TOKEN_JWT,
435            decoded.typ
436        )))
437    }
438}
439
440fn check_claims_profile(claims: &PicTokenClaims) -> Result<(), RejectReason> {
441    if claims.profile == crate::PROFILE_0_2 {
442        Ok(())
443    } else {
444        Err(RejectReason::ProfileMismatch {
445            artifact: "pic+jwt",
446            expected: crate::PROFILE_0_2.to_string(),
447            got: claims.profile.clone(),
448        })
449    }
450}
451
452fn check_expected_jws_algorithm(
453    decoded: &DecodedToken,
454    verifier: &dyn ArtifactVerifier,
455    artifact: &'static str,
456) -> Result<(), RejectReason> {
457    let Some(expected) = verifier.expected_jws_algorithm() else {
458        return Ok(());
459    };
460    if decoded.alg == expected {
461        return Ok(());
462    }
463    Err(RejectReason::Malformed(format!(
464        "{artifact} alg must be {expected}, got {}",
465        decoded.alg
466    )))
467}
468
469fn check_expected_cose_algorithm(
470    actual: Option<crate::cose::SigningAlgorithm>,
471    verifier: &dyn ArtifactVerifier,
472    artifact: &'static str,
473) -> Result<(), RejectReason> {
474    let Some(expected) = verifier.expected_cose_algorithm() else {
475        return Ok(());
476    };
477    if actual == Some(expected) {
478        return Ok(());
479    }
480    Err(RejectReason::Malformed(format!(
481        "{artifact} alg must be {expected}, got {}",
482        actual
483            .map(|algorithm| algorithm.to_string())
484            .unwrap_or_else(|| "None".to_owned())
485    )))
486}
487
488fn check_exp_matches_checkpoint(
489    claims: &PicTokenClaims,
490    checkpoint: &PicPcaPayload,
491    artifact: &'static str,
492) -> Result<(), RejectReason> {
493    let Some(expires_at) = checkpoint.expires_at else {
494        return Ok(());
495    };
496    match claims.exp {
497        Some(exp) if exp == expires_at => Ok(()),
498        Some(exp) => Err(RejectReason::Malformed(format!(
499            "{artifact} exp does not match pca.expires_at: expected {expires_at}, got {exp}"
500        ))),
501        None => Err(RejectReason::Malformed(format!(
502            "{artifact} is missing exp for pca.expires_at"
503        ))),
504    }
505}