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 pub position: u64,
48 pub context_of_authority: IndexedAuthorityMap,
50 pub challenge: PcaChallenge,
52}
53
54impl PicPcaPayload {
55 pub fn new(position: u64, context: IndexedAuthorityMap, next_challenge: Vec<u8>) -> Self {
58 Self {
59 profile: crate::PROFILE_0_2.to_string(),
60 lineage_id: None,
61 position,
62 context_of_authority: context,
63 challenge: PcaChallenge { next_challenge },
64 }
65 }
66
67 pub fn with_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
69 self.lineage_id = Some(lineage_id.into());
70 self
71 }
72
73 pub fn with_optional_lineage_id(mut self, lineage_id: Option<String>) -> Self {
75 self.lineage_id = lineage_id;
76 self
77 }
78
79 pub fn check_profile(&self) -> Result<(), RejectReason> {
81 check_profile("pic-pca+cose", &self.profile)
82 }
83
84 pub fn validate(&self) -> Result<(), RejectReason> {
86 self.check_profile()?;
87 if self.challenge.next_challenge.is_empty() {
88 return Err(RejectReason::NextChallengeInvalid);
89 }
90 if self.lineage_id.as_deref().is_some_and(str::is_empty) {
91 return Err(RejectReason::Malformed(
92 "pca.lineage_id must not be empty".to_owned(),
93 ));
94 }
95 self.context_of_authority.validate()
96 }
97}
98
99pub type PicPcaCose = CoseSigned<PicPcaPayload>;
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::authority::{AuthorityValue, Invariant, LogicalAuthority};
106 use std::collections::BTreeMap;
107
108 fn sample_map() -> IndexedAuthorityMap {
109 let mut contract = BTreeMap::new();
110 contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
111 let logical = LogicalAuthority::new(
112 None,
113 vec![Invariant::new("storage:save", "save", "storage", "*")],
114 contract,
115 );
116 IndexedAuthorityMap::from_logical(&logical).unwrap()
117 }
118
119 #[test]
120 fn pca_cbor_roundtrip() {
121 let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec());
122 let mut buf = Vec::new();
123 ciborium::into_writer(&pca, &mut buf).unwrap();
124 let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
125 assert_eq!(pca, decoded);
126 assert!(decoded.check_profile().is_ok());
127 }
128
129 #[test]
130 fn lineage_id_roundtrips_when_present() {
131 let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec())
132 .with_lineage_id("picx-lineage-1");
133 let mut buf = Vec::new();
134 ciborium::into_writer(&pca, &mut buf).unwrap();
135 let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
136
137 assert_eq!(decoded.lineage_id.as_deref(), Some("picx-lineage-1"));
138 assert!(decoded.validate().is_ok());
139 }
140
141 #[test]
142 fn empty_lineage_id_is_rejected() {
143 let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_lineage_id("");
144
145 assert!(matches!(
146 pca.validate(),
147 Err(RejectReason::Malformed(message)) if message.contains("lineage_id")
148 ));
149 }
150}