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    claims.jti = ctx.jti.clone();
180    let token = sign_token(&claims, realm)?;
181
182    Ok(SettledIssue {
183        token,
184        pca_bytes,
185        checkpoint,
186        continuity_bytes,
187    })
188}
189
190// ---------------------------------------------------------------------------
191// Settlement (centralized advancement)
192// ---------------------------------------------------------------------------
193
194/// The trusted settlement authority for Profile 0.2 centralized advancement.
195pub struct SettlementAuthority<'a> {
196    /// Store answering whether exact PCA bytes are currently trusted.
197    pub trusted: &'a dyn TrustedCheckpoint,
198    /// Proof of Relationship validator for this deployment.
199    pub por: &'a dyn PorValidator,
200    /// Revocation state lookup.
201    pub revocation: &'a dyn RevocationCheck,
202    /// Deployment policy hooks (request binding, conformance, local policy).
203    pub policy: &'a dyn SettlementPolicy,
204    /// The attenuation order used for the non-expansion check.
205    pub order: &'a dyn AttenuationOrder,
206    /// The realm signing key for the settled artifacts.
207    pub realm: &'a dyn ArtifactSigner,
208}
209
210impl SettlementAuthority<'_> {
211    /// Validates a workload-signed candidate PIC Token JWT and, on success,
212    /// materializes checkpoint N+1 and issues the next settled token.
213    ///
214    /// The numbered comments follow the settlement procedure of the Prover
215    /// and Verifier specification (Section 3.1).
216    pub fn settle(
217        &self,
218        candidate_token: &str,
219        ctx: &SettlementContext,
220    ) -> Result<SettledIssue, ContinuityError> {
221        // 1-2. Receive the candidate as untrusted input; parse without
222        //      accepting authenticity; obtain pic.root bytes.
223        let decoded = decode_token(candidate_token)
224            .map_err(|e| RejectReason::Malformed(format!("candidate token: {e}")))?;
225        check_token_type(&decoded)?;
226        check_claims_profile(&decoded.claims)?;
227        let continuity_bytes = decoded
228            .claims
229            .root_bytes()
230            .map_err(|e| RejectReason::Malformed(format!("pic.root: {e}")))?;
231
232        // 3. Parse the candidate Continuity without accepting authenticity;
233        //    validate the presence and shape of root and transitions.
234        let continuity_cose = PicContinuityCose::from_bytes(&continuity_bytes)
235            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
236        let continuity: PicContinuityPayload = continuity_cose
237            .payload_unverified()
238            .map_err(|e| RejectReason::Malformed(format!("candidate continuity: {e}")))?;
239        continuity.check_profile()?;
240        if continuity.root.pca.is_empty() || continuity.root.pca_hash.is_empty() {
241            return Err(RejectReason::Malformed("empty continuity root".into()).into());
242        }
243
244        // 4. Exactly one transition.
245        let transition_bytes = continuity.candidate_transition()?.to_vec();
246
247        // 5. Parse the Transition as untrusted input.
248        let transition_cose = PicTransitionCose::from_bytes(&transition_bytes)
249            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
250        let transition: PicTransitionPayload = transition_cose
251            .payload_unverified()
252            .map_err(|e| RejectReason::Malformed(format!("transition: {e}")))?;
253        transition.check_profile()?;
254
255        // 6-7. Validate the proof_of_relationship structure and type.
256        let por = &transition.proof_of_relationship;
257        if por.por_type != self.por.accepted_type() {
258            return Err(RejectReason::PorType(por.por_type.clone()).into());
259        }
260        if por.evidence.is_empty() {
261            return Err(RejectReason::PorRejected("empty evidence".into()).into());
262        }
263
264        // 8-10. Validate the evidence per the selected schema and obtain the
265        //       accepted workload verification key.
266        let workload = self.por.validate(por)?;
267
268        // 11. Verify the three workload signatures with that key and their
269        //     signer consistency.
270        transition_cose
271            .verify_with(|data, sig| {
272                if workload.verify(data, sig) {
273                    Ok(())
274                } else {
275                    Err(crate::cose::CoseError::VerificationFailed)
276                }
277            })
278            .map_err(|_| RejectReason::WorkloadSignature("PIC Continuity Transition COSE"))?;
279        continuity_cose
280            .verify_with(|data, sig| {
281                if workload.verify(data, sig) {
282                    Ok(())
283                } else {
284                    Err(crate::cose::CoseError::VerificationFailed)
285                }
286            })
287            .map_err(|_| RejectReason::WorkloadSignature("candidate PIC Continuity COSE"))?;
288        if !workload.verify(&decoded.signing_input, &decoded.signature) {
289            return Err(RejectReason::WorkloadSignature("candidate PIC Token JWT").into());
290        }
291
292        // 12. root.pca must be the exact bytes of the currently trusted
293        //     checkpoint.
294        if !self.trusted.is_current_checkpoint(&continuity.root.pca) {
295            return Err(RejectReason::UntrustedCheckpoint.into());
296        }
297        let checkpoint: PicPcaPayload = PicPcaCose::from_bytes(&continuity.root.pca)
298            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?
299            .payload_unverified()
300            .map_err(|e| RejectReason::Malformed(format!("checkpoint: {e}")))?;
301        checkpoint.validate()?;
302
303        // 13. Recompute SHA-256(exact root.pca bytes) and compare.
304        continuity.check_root_hash()?;
305
306        // 14. Position progression.
307        if transition.position != checkpoint.position + 1 {
308            return Err(RejectReason::PositionProgression.into());
309        }
310
311        // 15. Predecessor reference: type "pca", hash over the exact
312        //     trusted checkpoint bytes.
313        if transition.predecessor.predecessor_type != crate::PREDECESSOR_TYPE_PCA {
314            return Err(RejectReason::PredecessorType.into());
315        }
316        if transition.predecessor.hash != artifact_sha256(&continuity.root.pca) {
317            return Err(RejectReason::PredecessorHashMismatch.into());
318        }
319
320        // 16. Challenge continuity and next-challenge validity.
321        if transition.challenge.previous_challenge != checkpoint.challenge.next_challenge {
322            return Err(RejectReason::ChallengeContinuity.into());
323        }
324        if transition.challenge.next_challenge.is_empty() {
325            return Err(RejectReason::NextChallengeInvalid.into());
326        }
327
328        // 17. Validate removal bitmaps and execution-contract additions;
329        //     materialize the successor authority (deterministic ordering
330        //     and index assignment happen here).
331        let attenuations: Attenuations = match &transition.attenuations {
332            Some(wire) => wire.parse()?,
333            None => Attenuations::default(),
334        };
335        let next_authority = materialize(&checkpoint.context_of_authority, &attenuations)?;
336
337        // 18. Request/execution binding and executor evidence / conformance,
338        //     when required by the deployment.
339        if !self.policy.request_binding(&transition) {
340            return Err(RejectReason::RequestBinding.into());
341        }
342        if !self.policy.conformance(&checkpoint, &transition) {
343            return Err(RejectReason::ContractConformance.into());
344        }
345
346        // 19. Non-expansion under the selected attenuation order, revocation,
347        //     and local policy.
348        if !self
349            .order
350            .attenuates(&next_authority, &checkpoint.context_of_authority)
351        {
352            return Err(RejectReason::NonExpansion.into());
353        }
354        if self
355            .revocation
356            .is_revoked(&checkpoint, &continuity.root.pca)
357        {
358            return Err(RejectReason::Revoked.into());
359        }
360        if !self.policy.policy(&checkpoint, &next_authority) {
361            return Err(RejectReason::PolicyDenied.into());
362        }
363
364        // 20. Materialize checkpoint N+1, transfer the accepted next
365        //     challenge, and issue the settled artifacts.
366        let next_checkpoint = PicPcaPayload::new(
367            transition.position,
368            next_authority,
369            transition.challenge.next_challenge.clone(),
370        );
371        issue_settled(next_checkpoint, self.realm, ctx)
372    }
373}
374
375fn check_token_type(decoded: &DecodedToken) -> Result<(), RejectReason> {
376    if decoded.typ == crate::FORMAT_PIC_TOKEN_JWT {
377        Ok(())
378    } else {
379        Err(RejectReason::Malformed(format!(
380            "PIC Token JWT typ must be {}, got {}",
381            crate::FORMAT_PIC_TOKEN_JWT,
382            decoded.typ
383        )))
384    }
385}
386
387fn check_claims_profile(claims: &PicTokenClaims) -> Result<(), RejectReason> {
388    if claims.profile == crate::PROFILE_0_2 {
389        Ok(())
390    } else {
391        Err(RejectReason::ProfileMismatch {
392            artifact: "pic+jwt",
393            expected: crate::PROFILE_0_2.to_string(),
394            got: claims.profile.clone(),
395        })
396    }
397}