Skip to main content

pic_continuity/artifacts/
pca.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 PCA COSE (`pic-pca+cose`): the signed trusted authority checkpoint.
18
19use super::check_profile;
20use crate::authority::indexed::IndexedAuthorityMap;
21use crate::cose::CoseSigned;
22use crate::error::RejectReason;
23use serde::{Deserialize, Serialize};
24
25/// Challenge state carried by a PCA checkpoint: the challenge the next
26/// transition must answer.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PcaChallenge {
29    /// The verifier-issued challenge the next transition must echo as its
30    /// `previous_challenge`.
31    #[serde(with = "serde_bytes")]
32    pub next_challenge: Vec<u8>,
33}
34
35/// PIC PCA COSE payload: the signed trusted authority checkpoint.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct PicPcaPayload {
38    /// PIC profile identifier; must equal [`crate::PROFILE_0_2`].
39    pub profile: String,
40    /// Stable lineage identifier for this continuity chain.
41    ///
42    /// The same value is mirrored into PIC Token JWT `jti`, giving logs and
43    /// offline inspectors one correlation handle that survives advancement.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub lineage_id: Option<String>,
46    /// Absolute NumericDate after which this lineage should no longer be
47    /// advanced or accepted as a live token.
48    ///
49    /// When present, the same value is mirrored into PIC Token JWT `exp`. It is
50    /// fixed at initialization and preserved by advancement, so exchanging a
51    /// candidate cannot extend authority by minting a fresh token lifetime.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub expires_at: Option<i64>,
54    /// Position of this checkpoint in its lineage, starting at `0`.
55    pub position: u64,
56    /// The canonical Indexed Authority Map in force at this checkpoint.
57    pub context_of_authority: IndexedAuthorityMap,
58    /// Challenge state binding the next transition to this checkpoint.
59    pub challenge: PcaChallenge,
60}
61
62impl PicPcaPayload {
63    /// A Profile 0.2 checkpoint payload at `position` carrying `context`
64    /// and the verifier-issued `next_challenge`.
65    pub fn new(position: u64, context: IndexedAuthorityMap, next_challenge: Vec<u8>) -> Self {
66        Self {
67            profile: crate::PROFILE_0_2.to_string(),
68            lineage_id: None,
69            expires_at: None,
70            position,
71            context_of_authority: context,
72            challenge: PcaChallenge { next_challenge },
73        }
74    }
75
76    /// Adds the stable lineage identifier mirrored into PIC Token JWT `jti`.
77    pub fn with_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
78        self.lineage_id = Some(lineage_id.into());
79        self
80    }
81
82    /// Carries a lineage identifier when the predecessor had one.
83    pub fn with_optional_lineage_id(mut self, lineage_id: Option<String>) -> Self {
84        self.lineage_id = lineage_id;
85        self
86    }
87
88    /// Adds the absolute lineage expiration mirrored into PIC Token JWT `exp`.
89    pub fn with_expires_at(mut self, expires_at: i64) -> Self {
90        self.expires_at = Some(expires_at);
91        self
92    }
93
94    /// Carries an absolute lineage expiration when the predecessor had one.
95    pub fn with_optional_expires_at(mut self, expires_at: Option<i64>) -> Self {
96        self.expires_at = expires_at;
97        self
98    }
99
100    /// Rejects the payload unless `profile` is [`crate::PROFILE_0_2`].
101    pub fn check_profile(&self) -> Result<(), RejectReason> {
102        check_profile("pic-pca+cose", &self.profile)
103    }
104
105    /// Validates the checkpoint payload before it is signed or accepted.
106    pub fn validate(&self) -> Result<(), RejectReason> {
107        self.check_profile()?;
108        if self.challenge.next_challenge.is_empty() {
109            return Err(RejectReason::NextChallengeInvalid);
110        }
111        if self.lineage_id.as_deref().is_some_and(str::is_empty) {
112            return Err(RejectReason::Malformed(
113                "pca.lineage_id must not be empty".to_owned(),
114            ));
115        }
116        if self.expires_at.is_some_and(|expires_at| expires_at <= 0) {
117            return Err(RejectReason::Malformed(
118                "pca.expires_at must be a positive NumericDate".to_owned(),
119            ));
120        }
121        self.context_of_authority.validate()
122    }
123}
124
125/// COSE-signed PCA checkpoint.
126pub type PicPcaCose = CoseSigned<PicPcaPayload>;
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::authority::{AuthorityValue, Invariant, LogicalAuthority};
132    use std::collections::BTreeMap;
133
134    fn sample_map() -> IndexedAuthorityMap {
135        let mut contract = BTreeMap::new();
136        contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
137        let logical = LogicalAuthority::new(
138            None,
139            vec![Invariant::new("storage:save", "save", "storage", "*")],
140            contract,
141        );
142        IndexedAuthorityMap::from_logical(&logical).unwrap()
143    }
144
145    #[test]
146    fn pca_cbor_roundtrip() {
147        let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec());
148        let mut buf = Vec::new();
149        ciborium::into_writer(&pca, &mut buf).unwrap();
150        let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
151        assert_eq!(pca, decoded);
152        assert!(decoded.check_profile().is_ok());
153    }
154
155    #[test]
156    fn lineage_id_roundtrips_when_present() {
157        let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec())
158            .with_lineage_id("picx-lineage-1");
159        let mut buf = Vec::new();
160        ciborium::into_writer(&pca, &mut buf).unwrap();
161        let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
162
163        assert_eq!(decoded.lineage_id.as_deref(), Some("picx-lineage-1"));
164        assert!(decoded.validate().is_ok());
165    }
166
167    #[test]
168    fn expires_at_roundtrips_when_present() {
169        let pca =
170            PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_expires_at(1234);
171        let mut buf = Vec::new();
172        ciborium::into_writer(&pca, &mut buf).unwrap();
173        let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
174
175        assert_eq!(decoded.expires_at, Some(1234));
176        assert!(decoded.validate().is_ok());
177    }
178
179    #[test]
180    fn empty_lineage_id_is_rejected() {
181        let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_lineage_id("");
182
183        assert!(matches!(
184            pca.validate(),
185            Err(RejectReason::Malformed(message)) if message.contains("lineage_id")
186        ));
187    }
188
189    #[test]
190    fn non_positive_expires_at_is_rejected() {
191        let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_expires_at(0);
192
193        assert!(matches!(
194            pca.validate(),
195            Err(RejectReason::Malformed(message)) if message.contains("expires_at")
196        ));
197    }
198}