pic_continuity/artifacts/
pca.rs1use super::check_profile;
20use crate::authority::indexed::IndexedAuthorityMap;
21use crate::cose::CoseSigned;
22use crate::error::RejectReason;
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PcaChallenge {
29 #[serde(with = "serde_bytes")]
32 pub next_challenge: Vec<u8>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct PicPcaPayload {
38 pub profile: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub lineage_id: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub expires_at: Option<i64>,
54 pub position: u64,
56 pub context_of_authority: IndexedAuthorityMap,
58 pub challenge: PcaChallenge,
60}
61
62impl PicPcaPayload {
63 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 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 pub fn with_optional_lineage_id(mut self, lineage_id: Option<String>) -> Self {
84 self.lineage_id = lineage_id;
85 self
86 }
87
88 pub fn with_expires_at(mut self, expires_at: i64) -> Self {
90 self.expires_at = Some(expires_at);
91 self
92 }
93
94 pub fn with_optional_expires_at(mut self, expires_at: Option<i64>) -> Self {
96 self.expires_at = expires_at;
97 self
98 }
99
100 pub fn check_profile(&self) -> Result<(), RejectReason> {
102 check_profile("pic-pca+cose", &self.profile)
103 }
104
105 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
125pub 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}