use super::check_profile;
use crate::authority::indexed::IndexedAuthorityMap;
use crate::cose::CoseSigned;
use crate::error::RejectReason;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PcaChallenge {
#[serde(with = "serde_bytes")]
pub next_challenge: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PicPcaPayload {
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
pub position: u64,
pub context_of_authority: IndexedAuthorityMap,
pub challenge: PcaChallenge,
}
impl PicPcaPayload {
pub fn new(position: u64, context: IndexedAuthorityMap, next_challenge: Vec<u8>) -> Self {
Self {
profile: crate::PROFILE_0_2.to_string(),
lineage_id: None,
expires_at: None,
position,
context_of_authority: context,
challenge: PcaChallenge { next_challenge },
}
}
pub fn with_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
self.lineage_id = Some(lineage_id.into());
self
}
pub fn with_optional_lineage_id(mut self, lineage_id: Option<String>) -> Self {
self.lineage_id = lineage_id;
self
}
pub fn with_expires_at(mut self, expires_at: i64) -> Self {
self.expires_at = Some(expires_at);
self
}
pub fn with_optional_expires_at(mut self, expires_at: Option<i64>) -> Self {
self.expires_at = expires_at;
self
}
pub fn check_profile(&self) -> Result<(), RejectReason> {
check_profile("pic-pca+cose", &self.profile)
}
pub fn validate(&self) -> Result<(), RejectReason> {
self.check_profile()?;
if self.challenge.next_challenge.is_empty() {
return Err(RejectReason::NextChallengeInvalid);
}
if self.lineage_id.as_deref().is_some_and(str::is_empty) {
return Err(RejectReason::Malformed(
"pca.lineage_id must not be empty".to_owned(),
));
}
if self.expires_at.is_some_and(|expires_at| expires_at <= 0) {
return Err(RejectReason::Malformed(
"pca.expires_at must be a positive NumericDate".to_owned(),
));
}
self.context_of_authority.validate()
}
}
pub type PicPcaCose = CoseSigned<PicPcaPayload>;
#[cfg(test)]
mod tests {
use super::*;
use crate::authority::{AuthorityValue, Invariant, LogicalAuthority};
use std::collections::BTreeMap;
fn sample_map() -> IndexedAuthorityMap {
let mut contract = BTreeMap::new();
contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
let logical = LogicalAuthority::new(
None,
vec![Invariant::new("storage:save", "save", "storage", "*")],
contract,
);
IndexedAuthorityMap::from_logical(&logical).unwrap()
}
#[test]
fn pca_cbor_roundtrip() {
let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec());
let mut buf = Vec::new();
ciborium::into_writer(&pca, &mut buf).unwrap();
let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
assert_eq!(pca, decoded);
assert!(decoded.check_profile().is_ok());
}
#[test]
fn lineage_id_roundtrips_when_present() {
let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec())
.with_lineage_id("picx-lineage-1");
let mut buf = Vec::new();
ciborium::into_writer(&pca, &mut buf).unwrap();
let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
assert_eq!(decoded.lineage_id.as_deref(), Some("picx-lineage-1"));
assert!(decoded.validate().is_ok());
}
#[test]
fn expires_at_roundtrips_when_present() {
let pca =
PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_expires_at(1234);
let mut buf = Vec::new();
ciborium::into_writer(&pca, &mut buf).unwrap();
let decoded: PicPcaPayload = ciborium::from_reader(buf.as_slice()).unwrap();
assert_eq!(decoded.expires_at, Some(1234));
assert!(decoded.validate().is_ok());
}
#[test]
fn empty_lineage_id_is_rejected() {
let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_lineage_id("");
assert!(matches!(
pca.validate(),
Err(RejectReason::Malformed(message)) if message.contains("lineage_id")
));
}
#[test]
fn non_positive_expires_at_is_rejected() {
let pca = PicPcaPayload::new(0, sample_map(), b"challenge-0".to_vec()).with_expires_at(0);
assert!(matches!(
pca.validate(),
Err(RejectReason::Malformed(message)) if message.contains("expires_at")
));
}
}