use serde_json::Value;
use crate::attestation::Pcrs;
const EQ_OPERATORS: [&str; 2] = ["StringEquals", "StringEqualsIgnoreCase"];
const FORBIDDEN_ACTIONS: [&str; 3] = [
"kms:putkeypolicy",
"kms:creategrant",
"kms:replicatekey",
];
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PolicyError {
#[error("key policy is not valid JSON: {0}")]
Json(String),
#[error("key policy has no Statement element")]
NoStatements,
#[error("an Allow statement uses NotAction, which is too broad to bound")]
NotAction,
#[error("key policy grants wildcard action {0:?}; only explicit actions are allowed")]
WildcardAction(String),
#[error("key policy grants gate-loosening action {0:?} (could change or bypass the decrypt gate)")]
ForbiddenAction(String),
#[error("a statement allows kms:Decrypt without binding it to this enclave's PCR0/1/2")]
UngatedDecrypt,
#[error("kms:Decrypt is bound to PCR{index} {found:?}, not this enclave's {expected:?}")]
PcrMismatch {
index: u8,
found: String,
expected: String,
},
}
pub fn verify_decrypt_policy(policy_json: &str, own: &Pcrs) -> Result<(), PolicyError> {
let doc: Value =
serde_json::from_str(policy_json).map_err(|e| PolicyError::Json(e.to_string()))?;
let statements = match doc.get("Statement") {
Some(Value::Array(arr)) => arr.clone(),
Some(other) => vec![other.clone()],
None => return Err(PolicyError::NoStatements),
};
let want = [
("0", 0u8, hex::encode(&own.pcr0)),
("1", 1u8, hex::encode(&own.pcr1)),
("2", 2u8, hex::encode(&own.pcr2)),
];
for stmt in &statements {
if stmt.get("Effect").and_then(Value::as_str) != Some("Allow") {
continue;
}
if stmt.get("NotAction").is_some() {
return Err(PolicyError::NotAction);
}
let actions = normalize_actions(stmt.get("Action"));
for action in &actions {
if action.contains('*') {
return Err(PolicyError::WildcardAction(action.clone()));
}
if FORBIDDEN_ACTIONS.contains(&action.as_str())
|| action.starts_with("kms:reencrypt")
{
return Err(PolicyError::ForbiddenAction(action.clone()));
}
}
if actions.iter().any(|a| a == "kms:decrypt") {
let bound = collect_pcr_bindings(stmt);
for (suffix, index, expected) in &want {
match bound.get(*suffix) {
None => return Err(PolicyError::UngatedDecrypt),
Some(found) => {
if !found.eq_ignore_ascii_case(expected) {
return Err(PolicyError::PcrMismatch {
index: *index,
found: found.clone(),
expected: expected.clone(),
});
}
}
}
}
}
}
Ok(())
}
fn normalize_actions(action: Option<&Value>) -> Vec<String> {
match action {
Some(Value::String(s)) => vec![s.to_ascii_lowercase()],
Some(Value::Array(arr)) => arr
.iter()
.filter_map(Value::as_str)
.map(|s| s.to_ascii_lowercase())
.collect(),
_ => Vec::new(),
}
}
fn collect_pcr_bindings(stmt: &Value) -> std::collections::BTreeMap<String, String> {
let mut out = std::collections::BTreeMap::new();
let Some(cond) = stmt.get("Condition").and_then(Value::as_object) else {
return out;
};
for (op, kv) in cond {
if !EQ_OPERATORS.contains(&op.as_str()) {
continue;
}
let Some(kv_map) = kv.as_object() else { continue };
for (key, val) in kv_map {
let suffix = key
.strip_prefix("kms:RecipientAttestation:PCR")
.or_else(|| key.strip_prefix("kms:RecipientAttestation:pcr"));
let Some(suffix) = suffix else { continue };
if let Value::String(s) = val {
out.insert(suffix.to_string(), s.clone());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn pcrs(a: &[u8], b: &[u8], c: &[u8]) -> Pcrs {
Pcrs {
pcr0: a.to_vec(),
pcr1: b.to_vec(),
pcr2: c.to_vec(),
}
}
fn own() -> Pcrs {
pcrs(&[0xaa; 48], &[0xbb; 48], &[0xcc; 48])
}
fn hex_of(b: &[u8]) -> String {
hex::encode(b)
}
fn good_policy(own: &Pcrs) -> String {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AccountKeyLifecycle",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"Action": [
"kms:DescribeKey",
"kms:GetKeyPolicy",
"kms:ScheduleKeyDeletion"
],
"Resource": "*"
},
{
"Sid": "EnclaveGetPublicKey",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/enc" },
"Action": "kms:GetPublicKey",
"Resource": "*"
},
{
"Sid": "EnclaveAttestedDecrypt",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/enc" },
"Action": "kms:Decrypt",
"Resource": "*",
"Condition": {
"StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}
}
}
]
})
.to_string()
}
#[test]
fn accepts_correct_policy() {
let own = own();
verify_decrypt_policy(&good_policy(&own), &own).expect("good policy accepted");
}
#[test]
fn accepts_correct_policy_case_insensitive_pcr_hex() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0).to_uppercase(),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1).to_uppercase(),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2).to_uppercase(),
}}
}]
})
.to_string();
verify_decrypt_policy(&doc, &own).expect("case-insensitive hex accepted");
}
#[test]
fn empty_statements_pass_vacuously() {
let doc = r#"{"Version":"2012-10-17","Statement":[]}"#;
verify_decrypt_policy(doc, &own()).expect("empty policy is vacuously safe");
}
#[test]
fn rejects_account_kms_star() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Sid": "AccountAdmin",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"Action": "kms:*",
"Resource": "*"
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own),
Err(PolicyError::WildcardAction("kms:*".into()))
);
}
#[test]
fn rejects_put_key_policy() {
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"Action": ["kms:DescribeKey", "kms:PutKeyPolicy"],
"Resource": "*"
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own()),
Err(PolicyError::ForbiddenAction("kms:putkeypolicy".into()))
);
}
#[test]
fn rejects_create_grant() {
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:CreateGrant",
"Resource": "*"
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own()),
Err(PolicyError::ForbiddenAction("kms:creategrant".into()))
);
}
#[test]
fn rejects_reencrypt() {
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:ReEncryptFrom",
"Resource": "*"
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own()),
Err(PolicyError::ForbiddenAction("kms:reencryptfrom".into()))
);
}
#[test]
fn rejects_ungated_decrypt() {
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "kms:Decrypt",
"Resource": "*"
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own()),
Err(PolicyError::UngatedDecrypt)
);
}
#[test]
fn rejects_decrypt_gated_to_wrong_pcr() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": hex_of(&[0xde; 48]),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}}
}]
})
.to_string();
match verify_decrypt_policy(&doc, &own) {
Err(PolicyError::PcrMismatch { index: 0, .. }) => {}
other => panic!("expected PCR0 mismatch, got {other:?}"),
}
}
#[test]
fn rejects_decrypt_missing_one_pcr() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
}}
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own),
Err(PolicyError::UngatedDecrypt)
);
}
#[test]
fn rejects_decrypt_gated_by_set_of_pcrs() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": [hex_of(&own.pcr0), hex_of(&[0xff; 48])],
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}}
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own),
Err(PolicyError::UngatedDecrypt)
);
}
#[test]
fn rejects_decrypt_gated_by_wrong_operator() {
let own = own();
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringLike": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}}
}]
})
.to_string();
assert_eq!(
verify_decrypt_policy(&doc, &own),
Err(PolicyError::UngatedDecrypt)
);
}
#[test]
fn rejects_not_action_allow() {
let doc = serde_json::json!({
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"NotAction": "kms:CreateKey",
"Resource": "*"
}]
})
.to_string();
assert_eq!(verify_decrypt_policy(&doc, &own()), Err(PolicyError::NotAction));
}
#[test]
fn ignores_deny_statements() {
let own = own();
let doc = serde_json::json!({
"Statement": [
{
"Effect": "Deny",
"Principal": "*",
"Action": "kms:*",
"Resource": "*",
"Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } }
},
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEqualsIgnoreCase": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}}
}
]
})
.to_string();
verify_decrypt_policy(&doc, &own).expect("deny statements are ignored");
}
#[test]
fn rejects_non_json() {
match verify_decrypt_policy("not json", &own()) {
Err(PolicyError::Json(_)) => {}
other => panic!("expected Json error, got {other:?}"),
}
}
#[test]
fn rejects_missing_statement() {
assert_eq!(
verify_decrypt_policy(r#"{"Version":"2012-10-17"}"#, &own()),
Err(PolicyError::NoStatements)
);
}
#[test]
fn accepts_single_statement_object() {
let own = own();
let doc = serde_json::json!({
"Statement": {
"Effect": "Allow",
"Action": "kms:Decrypt",
"Condition": { "StringEquals": {
"kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
"kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
"kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
}}
}
})
.to_string();
verify_decrypt_policy(&doc, &own).expect("single-object statement accepted");
}
}