Skip to main content

pic_continuity/artifacts/
transition.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 Continuity Transition COSE (`pic-continuity-transition+cose`): the
18//! workload-signed causal authority transition.
19
20use super::check_profile;
21use crate::authority::indexed::KvTuple;
22use crate::cose::CoseSigned;
23use crate::error::RejectReason;
24use serde::{Deserialize, Serialize};
25
26/// Custom serializer for `Option<Vec<u8>>` with serde_bytes.
27mod optional_bytes {
28    use serde::{Deserialize, Deserializer, Serialize, Serializer};
29
30    pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
31    where
32        S: Serializer,
33    {
34        match value {
35            Some(bytes) => serde_bytes::Bytes::new(bytes).serialize(serializer),
36            None => serializer.serialize_none(),
37        }
38    }
39
40    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
41    where
42        D: Deserializer<'de>,
43    {
44        let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(deserializer)?;
45        Ok(opt.map(|b| b.into_vec()))
46    }
47}
48
49/// The exactly-one predecessor reference. Current Profile 0.2 requires
50/// `type = "pca"` and `hash = SHA-256(exact signed current root.pca bytes)`.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Predecessor {
53    /// Predecessor reference type; current Profile 0.2 requires
54    /// [`crate::PREDECESSOR_TYPE_PCA`].
55    #[serde(rename = "type")]
56    pub predecessor_type: String,
57    /// SHA-256 over the exact signed bytes of the current trusted
58    /// PIC PCA COSE checkpoint.
59    #[serde(with = "serde_bytes")]
60    pub hash: Vec<u8>,
61}
62
63/// Challenge pair of a transition: it answers the predecessor's challenge
64/// and emits the next one.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct TransitionChallenge {
67    /// Echo of the predecessor checkpoint's `challenge.next_challenge`.
68    #[serde(with = "serde_bytes")]
69    pub previous_challenge: Vec<u8>,
70    /// Fresh challenge for the successor checkpoint; must differ from
71    /// `previous_challenge` and be non-empty.
72    #[serde(with = "serde_bytes")]
73    pub next_challenge: Vec<u8>,
74}
75
76/// Typed Proof of Relationship container. `type` controls how `evidence`
77/// is parsed and validated; current Profile 0.2 requires `"sd-jwt"` with the
78/// exact UTF-8 bytes of the issuer-signed SD-JWT presentation.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ProofOfRelationship {
81    /// Evidence type; current Profile 0.2 requires
82    /// [`crate::POR_TYPE_SD_JWT`].
83    #[serde(rename = "type")]
84    pub por_type: String,
85    /// Type-specific evidence bytes; for `"sd-jwt"`, the exact UTF-8 bytes
86    /// of the issuer-signed SD-JWT presentation.
87    #[serde(with = "serde_bytes")]
88    pub evidence: Vec<u8>,
89}
90
91impl ProofOfRelationship {
92    /// An SD-JWT Proof of Relationship from the exact textual RFC 9901
93    /// presentation string (issuer-signed JWT plus selected disclosures).
94    /// The evidence stores its exact UTF-8 bytes; the presentation is not
95    /// re-encoded.
96    pub fn sd_jwt(presentation: &str) -> Self {
97        Self {
98            por_type: crate::POR_TYPE_SD_JWT.to_string(),
99            evidence: presentation.as_bytes().to_vec(),
100        }
101    }
102}
103
104/// A section removal attenuation on the wire.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct BitmapAttenuation {
107    /// Canonical LSB-first removal bitmap over the section's
108    /// section-local indexes (see [`crate::authority::bitmap`]).
109    #[serde(with = "serde_bytes")]
110    pub remove_bitmap: Vec<u8>,
111}
112
113/// Execution-contract additions on the wire: canonical `[key, value]`
114/// tuples, unindexed (the settlement verifier assigns indexes).
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct ContractAdditions {
117    /// Proposed constraint entries; the settlement verifier deduplicates,
118    /// sorts, and assigns the next section-local indexes.
119    pub additions: Vec<KvTuple>,
120}
121
122/// The optional `attenuations` member of a transition.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
124pub struct AttenuationsWire {
125    /// Removal-only attenuation of the `identity_context` section.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub identity_context: Option<BitmapAttenuation>,
128    /// Removal-only attenuation of the `invariants` section.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub invariants: Option<BitmapAttenuation>,
131    /// Additions-only attenuation of the `execution_contract` section.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub execution_contract: Option<ContractAdditions>,
134}
135
136impl AttenuationsWire {
137    /// `true` when no section carries an attenuation; such a member should
138    /// be omitted from the transition entirely.
139    pub fn is_empty(&self) -> bool {
140        self.identity_context.is_none()
141            && self.invariants.is_none()
142            && self.execution_contract.is_none()
143    }
144
145    /// Parses the wire form into validated
146    /// [`Attenuations`](crate::authority::Attenuations), rejecting
147    /// non-canonical bitmaps.
148    pub fn parse(&self) -> Result<crate::authority::attenuation::Attenuations, RejectReason> {
149        use crate::authority::attenuation::Attenuations;
150        use crate::authority::bitmap::RemoveBitmap;
151        Ok(Attenuations {
152            identity_context: self
153                .identity_context
154                .as_ref()
155                .map(|b| RemoveBitmap::from_bytes(&b.remove_bitmap))
156                .transpose()?,
157            invariants: self
158                .invariants
159                .as_ref()
160                .map(|b| RemoveBitmap::from_bytes(&b.remove_bitmap))
161                .transpose()?,
162            execution_contract_additions: self
163                .execution_contract
164                .as_ref()
165                .map(|a| a.additions.clone())
166                .unwrap_or_default(),
167        })
168    }
169}
170
171/// PIC Continuity Transition COSE payload: the workload-signed causal
172/// authority transition.
173///
174/// No `Eq`: `executor_evidence` may carry arbitrary CBOR, including floats.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct PicTransitionPayload {
177    /// PIC profile identifier; must equal [`crate::PROFILE_0_2`].
178    pub profile: String,
179    /// Proposed successor position: exactly predecessor position + 1.
180    pub position: u64,
181    /// The exactly-one reference to the current trusted checkpoint.
182    pub predecessor: Predecessor,
183    /// Challenge pair: answers the predecessor's challenge, emits the next.
184    pub challenge: TransitionChallenge,
185    /// Optional monotonic attenuations; omitted means "carry authority
186    /// forward unchanged".
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub attenuations: Option<AttenuationsWire>,
189    /// Proof of Relationship binding the proposing workload to the lineage.
190    pub proof_of_relationship: ProofOfRelationship,
191    /// Optional digest binding the transition to a concrete request, when
192    /// the deployment requires request binding.
193    #[serde(
194        default,
195        skip_serializing_if = "Option::is_none",
196        with = "optional_bytes"
197    )]
198    pub request_digest: Option<Vec<u8>>,
199    /// Optional executor-supplied evidence (arbitrary CBOR) evaluated by
200    /// deployment conformance policy.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub executor_evidence: Option<ciborium::Value>,
203}
204
205impl PicTransitionPayload {
206    /// Rejects the payload unless `profile` is [`crate::PROFILE_0_2`].
207    pub fn check_profile(&self) -> Result<(), RejectReason> {
208        check_profile("pic-continuity-transition+cose", &self.profile)
209    }
210}
211
212/// COSE-signed Transition.
213pub type PicTransitionCose = CoseSigned<PicTransitionPayload>;
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn transition_cbor_roundtrip() {
221        let t = PicTransitionPayload {
222            profile: crate::PROFILE_0_2.into(),
223            position: 1,
224            predecessor: Predecessor {
225                predecessor_type: crate::PREDECESSOR_TYPE_PCA.into(),
226                hash: vec![0xAA; 32],
227            },
228            challenge: TransitionChallenge {
229                previous_challenge: b"c0".to_vec(),
230                next_challenge: b"c1".to_vec(),
231            },
232            attenuations: Some(AttenuationsWire {
233                invariants: Some(BitmapAttenuation {
234                    remove_bitmap: vec![0x01],
235                }),
236                ..Default::default()
237            }),
238            proof_of_relationship: ProofOfRelationship {
239                por_type: crate::POR_TYPE_SD_JWT.into(),
240                evidence: b"<sd-jwt presentation bytes>".to_vec(),
241            },
242            request_digest: None,
243            executor_evidence: None,
244        };
245
246        let mut buf = Vec::new();
247        ciborium::into_writer(&t, &mut buf).unwrap();
248        let decoded: PicTransitionPayload = ciborium::from_reader(buf.as_slice()).unwrap();
249        assert_eq!(t, decoded);
250        let parsed = decoded.attenuations.unwrap().parse().unwrap();
251        assert_eq!(parsed.invariants.unwrap().indices(), vec![0]);
252    }
253
254    #[test]
255    fn sd_jwt_constructor_keeps_exact_bytes() {
256        let presentation = "<issuer-signed jwt>~<disclosure>~";
257        let por = ProofOfRelationship::sd_jwt(presentation);
258        assert_eq!(por.por_type, crate::POR_TYPE_SD_JWT);
259        assert_eq!(por.evidence, presentation.as_bytes());
260    }
261}