Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions misc/claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"storage-opaque": 2,
"sourced-data": 2
},
"ear.appraisal-policy-id": "policy://psa/60a0068d"
"ear.appraisal-policy-ids": [
"policy://psa/60a0068d"
]
}
}
}
}
128 changes: 116 additions & 12 deletions src/appraisal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Identifiers of the policies applied by the verifier (EAR `ear.appraisal-policy-ids` / 1003)
pub policy_ids: Vec<String>,
/// 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<String, RawValue>,
/// 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<String, RawValue>,
/// Claims about the public key that is being attested
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -179,8 +179,8 @@ impl<'de> Visitor<'de> for AppraisalVisitor {
Some("ear.trustworthiness-vector") => {
appraisal.trust_vector = map.next_value::<TrustVector>()?
}
Some("ear.appraisal-policy-id") => {
appraisal.policy_id = Some(map.next_value::<String>()?)
Some("ear.appraisal-policy-ids") => {
appraisal.policy_ids = map.next_value::<Vec<String>>()?;
}
Some("ear.veraison.annotated-evidence") => {
appraisal.annotated_evidence =
Expand All @@ -202,7 +202,9 @@ impl<'de> Visitor<'de> for AppraisalVisitor {
match map.next_key::<i32>()? {
Some(1000) => appraisal.status = map.next_value::<TrustTier>()?,
Some(1001) => appraisal.trust_vector = map.next_value::<TrustVector>()?,
Some(1003) => appraisal.policy_id = Some(map.next_value::<String>()?),
Some(1003) => {
appraisal.policy_ids = map.next_value::<Vec<String>>()?;
}
Some(-70000) => {
appraisal.annotated_evidence =
map.next_value::<BTreeMap<String, RawValue>>()?
Expand All @@ -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();
Expand Down
Loading