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    if !realm.verify(&decoded.signing_input, &decoded.signature) {
74        return Err(RejectReason::RealmSignature("PIC Token JWT").into());
75    }
76    let claims = decoded.claims;
77    check_claims_profile(&claims)?;
78
79    let continuity_bytes = claims.root_bytes()?;
80    let continuity_cose = PicContinuityCose::from_bytes(&continuity_bytes)?;
81    let continuity: PicContinuityPayload = continuity_cose
82        .verify_with(|data, sig| {
83            if realm.verify(data, sig) {
84                Ok(())
85            } else {
86                Err(crate::cose::CoseError::VerificationFailed)
87            }
88        })
89        .map_err(|_| RejectReason::RealmSignature("PIC Continuity COSE"))?;
90    continuity.check_profile()?;
91    continuity.require_settled()?;
92    continuity.check_root_hash()?;
93
94    let pca_bytes = continuity.root.pca.clone();
95    let pca_cose = PicPcaCose::from_bytes(&pca_bytes)?;
96    let checkpoint: PicPcaPayload = pca_cose
97        .verify_with(|data, sig| {
98            if realm.verify(data, sig) {
99                Ok(())
100            } else {
101                Err(crate::cose::CoseError::VerificationFailed)
102            }
103        })
104        .map_err(|_| RejectReason::RealmSignature("PIC PCA COSE"))?;
105    checkpoint.validate()?;
106
107    Ok(SettledState {
108        claims,
109        continuity,
110        pca_bytes,
111        checkpoint,
112    })
113}
114
115// ---------------------------------------------------------------------------
116// Settlement (initialization)
117// ---------------------------------------------------------------------------
118
119/// Claims metadata for a settled token.
120#[derive(Debug, Clone, Default)]
121pub struct SettlementContext {
122    /// Realm issuer identity, e.g. `https://pic-x.example.com/realms/acme`.
123    pub iss: String,
124    /// Subject claim for the settled token.
125    pub sub: Option<String>,
126    /// Audience claim for the settled token.
127    pub aud: Option<String>,
128    /// Issued-at (seconds since the Unix epoch).
129    pub iat: Option<i64>,
130    /// Expiry (seconds since the Unix epoch).
131    pub exp: Option<i64>,
132    /// Token identifier.
133    pub jti: Option<String>,
134}
135
136/// A newly settled continuity state.
137#[derive(Debug, Clone)]
138pub struct SettledIssue {
139    /// The settled PIC Token JWT (compact JWS).
140    pub token: String,
141    /// Exact signed bytes of the new PIC PCA COSE checkpoint.
142    pub pca_bytes: Vec<u8>,
143    /// The new checkpoint payload: position, authority, challenge.
144    pub checkpoint: PicPcaPayload,
145    /// Exact signed bytes of the settled PIC Continuity COSE.
146    pub continuity_bytes: Vec<u8>,
147}
148
149/// Signs a checkpoint into the settled artifact chain:
150/// PIC PCA COSE → settled PIC Continuity COSE (`transitions = null`) →
151/// settled PIC Token JWT. This is the initialization path (checkpoint 0,
152/// e.g. after an OAuth-to-PIC exchange) and the tail of every settlement.
153pub fn issue_settled(
154    checkpoint: PicPcaPayload,
155    realm: &dyn ArtifactSigner,
156    ctx: &SettlementContext,
157) -> Result<SettledIssue, ContinuityError> {
158    checkpoint.validate()?;
159
160    let pca_cose: PicPcaCose =
161        CoseSigned::sign_with(&checkpoint, realm.kid(), realm.cose_algorithm(), |data| {
162            realm.sign(data)
163        })?;
164    let pca_bytes = pca_cose.to_bytes()?;
165
166    let continuity = PicContinuityPayload::settled(pca_bytes.clone());
167    let continuity_cose: PicContinuityCose =
168        CoseSigned::sign_with(&continuity, realm.kid(), realm.cose_algorithm(), |data| {
169            realm.sign(data)
170        })?;
171    let continuity_bytes = continuity_cose.to_bytes()?;
172
173    let mut claims = PicTokenClaims::for_continuity(&continuity_bytes);
174    claims.iss = Some(ctx.iss.clone());
175    claims.sub = ctx.sub.clone();
176    claims.aud = ctx.aud.clone();
177    claims.iat = ctx.iat;
178    claims.exp = ctx.exp;
179    if let (Some(checkpoint_lineage), Some(context_jti)) = (&checkpoint.lineage_id, &ctx.jti)
180        && checkpoint_lineage != context_jti
181    {
182        return Err(RejectReason::Malformed(
183            "settlement context jti does not match pca.lineage_id".to_owned(),
184        )
185        .into());
186    }
187    claims.jti = ctx.jti.clone().or_else(|| checkpoint.lineage_id.clone());
188    let token = sign_token(&claims, realm)?;
189
190    Ok(SettledIssue {
191        token,
192        pca_bytes,
193        checkpoint,
194        continuity_bytes,
195    })
196}
197
198// ---------------------------------------------------------------------------
199// Settlement (centralized advancement)
200// ---------------------------------------------------------------------------
201
202/// The trusted settlement authority for Profile 0.2 centralized advancement.
203pub struct SettlementAuthority<'a> {
204    /// Store answering whether exact PCA bytes are currently trusted.
205    pub trusted: &'a dyn TrustedCheckpoint,
206    /// Proof of Relationship validator for this deployment.
207    pub por: &'a dyn PorValidator,
208    /// Revocation state lookup.
209    pub revocation: &'a dyn RevocationCheck,
210    /// Deployment policy hooks (request binding, conformance, local policy).
211    pub policy: &'a dyn SettlementPolicy,
212    /// The attenuation order used for the non-expansion check.
213    pub order: &'a dyn AttenuationOrder,
214    /// The realm signing key for the settled artifacts.
215    pub realm: &'a dyn ArtifactSigner,
216}
217
218impl SettlementAuthority<'_> {
219    /// Validates a workload-signed candidate PIC Token JWT and, on success,
220    /// materializes checkpoint N+1 and issues the next settled token.
221    ///
222    /// The numbered comments follow the settlement procedure of the Prover
223    /// and Verifier specification (Section 3.1).
224    pub fn settle(
225        &self,
226        candidate_token: &str,
227        ctx: &SettlementContext,
228    ) -> Result<SettledIssue, ContinuityError> {
229        // 1-2. Receive the candidate as untrusted input; parse without
230        //      accepting authenticity; obtain pic.root bytes.
231        let decoded = decode_token(candidate_token)
232            .map_err(|e| RejectReason::Malformed(format!("candidate token: {e}")))?;
233        check_token_type(&decoded)?;
234        check_claims_profile(&decoded.claims)?;
235        let continuity_bytes = decoded
236            .claims
237            .root_bytes()
238            .map_err(|e| RejectReason::Malformed(format!("pic.root: {e}")))?;
239
240        // 3. Parse the candidate Continuity without accepting authenticity;
241        //    validate the presence and shape of root and transitions.
242        let continuity_cose = PicContinuityCose::from_bytes(&continuity_bytes)
243            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
244        let continuity: PicContinuityPayload = continuity_cose
245            .payload_unverified()
246            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
247        continuity.check_profile()?;
248        if continuity.root.pca.is_empty() || continuity.root.pca_hash.is_empty() {
249            return Err(RejectReason::Malformed("empty continuity root".into()).into());
250        }
251
252        // 4. Exactly one transition.
253        let transition_bytes = continuity.candidate_transition()?.to_vec();
254
255        // 5. Parse the Transition as untrusted input.
256        let transition_cose = PicTransitionCose::from_bytes(&transition_bytes)
257            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
258        let transition: PicTransitionPayload = transition_cose
259            .payload_unverified()
260            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
261        transition.check_profile()?;
262
263        // 6-7. Validate the proof_of_relationship structure and type.
264        let por = &transition.proof_of_relationship;
265        if por.por_type != self.por.accepted_type() {
266            return Err(RejectReason::PorType(por.por_type.clone()).into());
267        }
268        if por.evidence.is_empty() {
269            return Err(RejectReason::PorRejected("empty evidence".into()).into());
270        }
271
272        // 8-10. Validate the evidence per the selected schema and obtain the
273        //       accepted workload verification key.
274        let workload = self.por.validate(por)?;
275
276        // 11. Verify the three workload signatures with that key and their
277        //     signer consistency.
278        transition_cose
279            .verify_with(|data, sig| {
280                if workload.verify(data, sig) {
281                    Ok(())
282                } else {
283                    Err(crate::cose::CoseError::VerificationFailed)
284                }
285            })
286            .map_err(|_| RejectReason::WorkloadSignature("PIC Continuity Transition COSE"))?;
287        continuity_cose
288            .verify_with(|data, sig| {
289                if workload.verify(data, sig) {
290                    Ok(())
291                } else {
292                    Err(crate::cose::CoseError::VerificationFailed)
293                }
294            })
295            .map_err(|_| RejectReason::WorkloadSignature("candidate PIC Continuity COSE"))?;
296        if !workload.verify(&decoded.signing_input, &decoded.signature) {
297            return Err(RejectReason::WorkloadSignature("candidate PIC Token JWT").into());
298        }
299
300        // 12. root.pca must be the exact bytes of the currently trusted
301        //     checkpoint.
302        if !self.trusted.is_current_checkpoint(&continuity.root.pca) {
303            return Err(RejectReason::UntrustedCheckpoint.into());
304        }
305        let checkpoint: PicPcaPayload = PicPcaCose::from_bytes(&continuity.root.pca)
306            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?
307            .payload_unverified()
308            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?;
309        checkpoint.validate()?;
310        if let Some(checkpoint_lineage) = checkpoint.lineage_id.as_deref() {
311            match decoded.claims.jti.as_deref() {
312                Some(candidate_jti) if candidate_jti == checkpoint_lineage => {}
313                Some(_) => {
314                    return Err(RejectReason::Malformed(
315                        "candidate token jti does not match checkpoint lineage_id".to_owned(),
316                    )
317                    .into());
318                }
319                None => {
320                    return Err(RejectReason::Malformed(
321                        "candidate token is missing jti for checkpoint lineage_id".to_owned(),
322                    )
323                    .into());
324                }
325            }
326        }
327
328        // 13. Recompute SHA-256(exact root.pca bytes) and compare.
329        continuity.check_root_hash()?;
330
331        // 14. Position progression.
332        if transition.position != checkpoint.position + 1 {
333            return Err(RejectReason::PositionProgression.into());
334        }
335
336        // 15. Predecessor reference: type "pca", hash over the exact
337        //     trusted checkpoint bytes.
338        if transition.predecessor.predecessor_type != crate::PREDECESSOR_TYPE_PCA {
339            return Err(RejectReason::PredecessorType.into());
340        }
341        if transition.predecessor.hash != artifact_sha256(&continuity.root.pca) {
342            return Err(RejectReason::PredecessorHashMismatch.into());
343        }
344
345        // 16. Challenge continuity and next-challenge validity.
346        if transition.challenge.previous_challenge != checkpoint.challenge.next_challenge {
347            return Err(RejectReason::ChallengeContinuity.into());
348        }
349        if transition.challenge.next_challenge.is_empty() {
350            return Err(RejectReason::NextChallengeInvalid.into());
351        }
352
353        // 17. Validate removal bitmaps and execution-contract additions;
354        //     materialize the successor authority (deterministic ordering
355        //     and index assignment happen here).
356        let attenuations: Attenuations = match &transition.attenuations {
357            Some(wire) => wire.parse()?,
358            None => Attenuations::default(),
359        };
360        let next_authority = materialize(&checkpoint.context_of_authority, &attenuations)?;
361
362        // 18. Request/execution binding and executor evidence / conformance,
363        //     when required by the deployment.
364        if !self.policy.request_binding(&transition) {
365            return Err(RejectReason::RequestBinding.into());
366        }
367        if !self.policy.conformance(&checkpoint, &transition) {
368            return Err(RejectReason::ContractConformance.into());
369        }
370
371        // 19. Non-expansion under the selected attenuation order, revocation,
372        //     and local policy.
373        if !self
374            .order
375            .attenuates(&next_authority, &checkpoint.context_of_authority)
376        {
377            return Err(RejectReason::NonExpansion.into());
378        }
379        if self
380            .revocation
381            .is_revoked(&checkpoint, &continuity.root.pca)
382        {
383            return Err(RejectReason::Revoked.into());
384        }
385        if !self.policy.policy(&checkpoint, &next_authority) {
386            return Err(RejectReason::PolicyDenied.into());
387        }
388
389        // 20. Materialize checkpoint N+1, transfer the accepted next
390        //     challenge, and issue the settled artifacts.
391        let next_checkpoint = PicPcaPayload::new(
392            transition.position,
393            next_authority,
394            transition.challenge.next_challenge.clone(),
395        )
396        .with_optional_lineage_id(checkpoint.lineage_id.clone());
397        issue_settled(next_checkpoint, self.realm, ctx)
398    }
399}
400
401fn check_token_type(decoded: &DecodedToken) -> Result<(), RejectReason> {
402    if decoded.typ == crate::FORMAT_PIC_TOKEN_JWT {
403        Ok(())
404    } else {
405        Err(RejectReason::Malformed(format!(
406            "PIC Token JWT typ must be {}, got {}",
407            crate::FORMAT_PIC_TOKEN_JWT,
408            decoded.typ
409        )))
410    }
411}
412
413fn check_claims_profile(claims: &PicTokenClaims) -> Result<(), RejectReason> {
414    if claims.profile == crate::PROFILE_0_2 {
415        Ok(())
416    } else {
417        Err(RejectReason::ProfileMismatch {
418            artifact: "pic+jwt",
419            expected: crate::PROFILE_0_2.to_string(),
420            got: claims.profile.clone(),
421        })
422    }
423}