diff --git a/misc/claims.json b/misc/claims.json index 936aa7b..20d774c 100644 --- a/misc/claims.json +++ b/misc/claims.json @@ -19,7 +19,9 @@ "storage-opaque": 2, "sourced-data": 2 }, - "ear.appraisal-policy-id": "policy://psa/60a0068d" + "ear.appraisal-policy-ids": [ + "policy://psa/60a0068d" + ] } } -} +} \ No newline at end of file diff --git a/src/appraisal.rs b/src/appraisal.rs index 0e3dcbb..5463555 100644 --- a/src/appraisal.rs +++ b/src/appraisal.rs @@ -19,13 +19,13 @@ pub struct Appraisal { pub status: TrustTier, /// Contains the trustworthiness claims made in the appraisal pub trust_vector: TrustVector, - /// Identifier of the policy applied by the verifier - pub policy_id: Option, + /// Identifiers of the policies applied by the verifier (EAR `ear.appraisal-policy-ids` / 1003) + pub policy_ids: Vec, /// Evidence claims extracted and annotated by the verifier from the evidence supplied by the /// attester /// (note: this is a Veraison project extension to EAR) pub annotated_evidence: BTreeMap, - /// Addition claims made as part of the appraisal based on the policy indicated by `policy_id` + /// Additional claims made as part of the appraisal based on the policies in `policy_ids` /// (note: this is a Veraison project extension to EAR) pub policy_claims: BTreeMap, /// Claims about the public key that is being attested @@ -41,7 +41,7 @@ impl Appraisal { Appraisal { status: TrustTier::None, trust_vector: TrustVector::new(), - policy_id: None, + policy_ids: Vec::new(), annotated_evidence: BTreeMap::new(), policy_claims: BTreeMap::new(), key_attestation: None, @@ -54,7 +54,7 @@ impl Appraisal { let mut appraisal = Appraisal { status: TrustTier::None, trust_vector: TrustVector::new(), - policy_id: None, + policy_ids: Vec::new(), annotated_evidence: BTreeMap::new(), policy_claims: BTreeMap::new(), key_attestation: None, @@ -102,8 +102,8 @@ impl Serialize for Appraisal { map.serialize_entry("ear.trustworthiness-vector", &self.trust_vector)?; } - if let Some(pid) = &self.policy_id { - map.serialize_entry("ear.appraisal-policy-id", pid.as_str())? + if !self.policy_ids.is_empty() { + map.serialize_entry("ear.appraisal-policy-ids", &self.policy_ids)?; } if !self.annotated_evidence.is_empty() { @@ -123,8 +123,8 @@ impl Serialize for Appraisal { map.serialize_entry(&1001, &self.trust_vector)?; } - if let Some(pid) = &self.policy_id { - map.serialize_entry(&1003, pid.as_str())? + if !self.policy_ids.is_empty() { + map.serialize_entry(&1003, &self.policy_ids)?; } if !self.annotated_evidence.is_empty() { @@ -179,8 +179,8 @@ impl<'de> Visitor<'de> for AppraisalVisitor { Some("ear.trustworthiness-vector") => { appraisal.trust_vector = map.next_value::()? } - Some("ear.appraisal-policy-id") => { - appraisal.policy_id = Some(map.next_value::()?) + Some("ear.appraisal-policy-ids") => { + appraisal.policy_ids = map.next_value::>()?; } Some("ear.veraison.annotated-evidence") => { appraisal.annotated_evidence = @@ -202,7 +202,9 @@ impl<'de> Visitor<'de> for AppraisalVisitor { match map.next_key::()? { Some(1000) => appraisal.status = map.next_value::()?, Some(1001) => appraisal.trust_vector = map.next_value::()?, - Some(1003) => appraisal.policy_id = Some(map.next_value::()?), + Some(1003) => { + appraisal.policy_ids = map.next_value::>()?; + } Some(-70000) => { appraisal.annotated_evidence = map.next_value::>()? @@ -227,6 +229,108 @@ impl<'de> Visitor<'de> for AppraisalVisitor { mod test { use crate::{claim, Appraisal}; + /// `ear_appraisal_policy_ids` in draft-ietf-rats-ear (JSON examples use this URL). + const DRAFT_POLICY_ID_EXAMPLE: &str = "https://veraison.example/policy/1/60a0068d"; + + #[test] + fn policy_ids_json_roundtrip() { + let mut a = Appraisal::new(); + a.policy_ids = vec!["https://example/p/1".into(), "https://example/p/2".into()]; + let s = serde_json::to_string(&a).unwrap(); + assert!(s.contains("ear.appraisal-policy-ids")); + let b: Appraisal = serde_json::from_str(&s).unwrap(); + assert_eq!(a.policy_ids, b.policy_ids); + } + + /// draft-ietf-rats-ear: `appraisal-policy-ids-label => [ + text ]` with label 1003 (CBOR). + /// This crate uses dotted claim names in JSON (`ear.*`) parallel to other EAR JSON fields. + #[test] + fn policy_ids_json_shape_matches_ear_appraisal_policy_ids() { + let mut a = Appraisal::new(); + a.policy_ids = vec![ + DRAFT_POLICY_ID_EXAMPLE.into(), + "tag:example.com,2026:policy#2".into(), + ]; + let s = serde_json::to_string(&a).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + + let ids = v + .get("ear.appraisal-policy-ids") + .expect("ear.appraisal-policy-ids claim must be present"); + let arr = ids.as_array().expect("claim value must be a JSON array"); + assert_eq!(arr.len(), 2); + assert!(arr.iter().all(|x| x.as_str().is_some())); + assert_eq!(arr[0].as_str().unwrap(), DRAFT_POLICY_ID_EXAMPLE); + } + + /// Deserialize a minimal appraisal-shaped object using the same URL as the draft examples. + #[test] + fn policy_ids_json_deserialize_draft_example_document() { + let s = format!( + r#"{{"ear.status":"none","ear.appraisal-policy-ids":["{0}"]}}"#, + DRAFT_POLICY_ID_EXAMPLE + ); + let a: Appraisal = serde_json::from_str(&s).unwrap(); + assert_eq!(a.policy_ids, vec![DRAFT_POLICY_ID_EXAMPLE.to_string()]); + assert_eq!(a.status.to_string(), "none"); + } + + /// Optional claim: empty `policy_ids` must not emit the field (matches optional in CDDL). + #[test] + fn policy_ids_omitted_when_empty_json() { + let a = Appraisal::new(); + let s = serde_json::to_string(&a).unwrap(); + let v: serde_json::Value = serde_json::from_str(&s).unwrap(); + assert!( + v.get("ear.appraisal-policy-ids").is_none(), + "empty policy_ids must omit ear.appraisal-policy-ids: {v}" + ); + } + + #[test] + fn policy_ids_cbor_roundtrip() { + use ciborium::{de::from_reader, ser::into_writer}; + + let mut a = Appraisal::new(); + a.policy_ids = vec![DRAFT_POLICY_ID_EXAMPLE.into()]; + let mut buf = Vec::new(); + into_writer(&a, &mut buf).unwrap(); + let b: Appraisal = from_reader(buf.as_slice()).unwrap(); + assert_eq!(a.policy_ids, b.policy_ids); + } + + /// CBOR map must use claim key 1003 for appraisal policy identifiers (draft-ietf-rats-ear). + #[test] + fn policy_ids_cbor_uses_claim_key_1003_array_of_text() { + use ciborium::value::{Integer, Value}; + use ciborium::{de::from_reader, ser::into_writer}; + + let mut a = Appraisal::new(); + a.policy_ids = vec![DRAFT_POLICY_ID_EXAMPLE.into()]; + let mut buf = Vec::new(); + into_writer(&a, &mut buf).unwrap(); + + let root: Value = from_reader(buf.as_slice()).unwrap(); + let Value::Map(entries) = root else { + panic!("expected CBOR map, got {root:?}"); + }; + + let key_1003 = Value::Integer(Integer::from(1003)); + let (_, policy_val) = entries + .iter() + .find(|(k, _)| *k == key_1003) + .expect("map must contain claim key 1003"); + + let Value::Array(items) = policy_val else { + panic!("claim 1003 must be a CBOR array, got {policy_val:?}"); + }; + assert_eq!(items.len(), 1); + let Value::Text(t) = &items[0] else { + panic!("policy id elements must be CBOR text, got {:?}", items[0]); + }; + assert_eq!(t, DRAFT_POLICY_ID_EXAMPLE); + } + #[test] fn serde() { let mut appraisal = Appraisal::new();