Skip to main content

enclavia_protocol/
kms_policy.rs

1//! In-enclave verification of a KMS key's policy.
2//!
3//! The backend mints one asymmetric KMS key per storage enclave whose
4//! policy is supposed to gate `kms:Decrypt` on the enclave's attestation
5//! PCR0/1/2 and to grant no principal a way to loosen that gate. The
6//! enclave must not *trust* the backend to have done this correctly: a
7//! buggy or hostile backend could mint a key whose policy lets the account
8//! decrypt directly, or whose policy can later be rewritten. So before the
9//! enclave seals its LUKS passphrase under the key (first boot) or relies
10//! on it to recover the passphrase (every subsequent boot), it fetches the
11//! policy with `kms:GetKeyPolicy` and runs it through
12//! [`verify_decrypt_policy`]. A failure refuses the boot.
13//!
14//! ## Trust model
15//!
16//! The enclave establishes its own TLS session to KMS from inside the
17//! enclave (rustls + webpki-roots validating the KMS endpoint); the parent
18//! only proxies ciphertext, so it can neither read nor forge the
19//! `GetKeyPolicy` response. This check is therefore load-bearing on two
20//! fronts: against a *buggy or misconfigured backend* (the realistic
21//! threat: a policy-construction bug like granting the account root
22//! `kms:*`), whose bad-but-authentic policy KMS would faithfully return
23//! over that TLS channel; and against a *hostile parent*, which the
24//! in-enclave TLS reduces to denial of service (it can drop the connection
25//! but not substitute a weaker policy). The attestation-gated `kms:Decrypt`
26//! the policy is checked for is what ultimately binds the key to this
27//! enclave's PCR0/1/2.
28//!
29//! ## What is enforced
30//!
31//! Over every `Effect: "Allow"` statement in the policy:
32//!
33//! 1. **No `NotAction`.** An `Allow` + `NotAction` grants everything
34//!    *except* the listed actions, which is too broad to reason about and
35//!    would include `kms:Decrypt`/`kms:PutKeyPolicy`. Rejected outright.
36//! 2. **No wildcard actions.** Any action containing `*` (e.g. `kms:*`,
37//!    `kms:Decrypt*`) is rejected: the legitimate policy uses explicit
38//!    actions only, and a wildcard is impossible to bound.
39//! 3. **No gate-loosening actions for anyone.** `kms:PutKeyPolicy` (rewrite
40//!    the policy), `kms:CreateGrant` (delegate decrypt to an un-attested
41//!    principal), `kms:ReplicateKey` (clone the key elsewhere), and any
42//!    `kms:ReEncrypt*` (recover plaintext outside the attestation
43//!    condition) are forbidden for every principal.
44//! 4. **Every `kms:Decrypt` grant is pinned to our PCRs.** A statement that
45//!    allows `kms:Decrypt` must carry a `StringEquals` /
46//!    `StringEqualsIgnoreCase` condition binding
47//!    `kms:RecipientAttestation:PCR0`, `:PCR1`, and `:PCR2` to *this*
48//!    enclave's own PCR values (and to exactly those, not a set).
49//!
50//! A policy with no `Allow` statement that grants `kms:Decrypt` passes
51//! vacuously: it grants nobody decrypt, which is not a confidentiality
52//! hole (in real KMS the enclave simply could not recover the passphrase,
53//! a liveness failure that surfaces at the `Decrypt` call, never silent
54//! exposure). This is what lets the dev/QEMU `mock-kms` auto-create path
55//! (policy-less keys) boot while production policies are enforced strictly.
56
57use serde_json::Value;
58
59use crate::attestation::Pcrs;
60
61/// A condition operator we accept as a hard string-equality binding.
62/// Anything else (`StringLike`, `StringNotEquals`, `*IfExists`, set
63/// operators) does not count as gating the action to our PCRs.
64const EQ_OPERATORS: [&str; 2] = ["StringEquals", "StringEqualsIgnoreCase"];
65
66/// Actions that could loosen or sidestep the attestation gate, forbidden
67/// for ANY principal (matched case-insensitively, exact).
68const FORBIDDEN_ACTIONS: [&str; 3] = [
69    "kms:putkeypolicy",
70    "kms:creategrant",
71    "kms:replicatekey",
72];
73
74/// Why a KMS key policy is unacceptable for an attestation-bound LUKS key.
75#[derive(Debug, thiserror::Error, PartialEq, Eq)]
76pub enum PolicyError {
77    /// The policy was not valid JSON.
78    #[error("key policy is not valid JSON: {0}")]
79    Json(String),
80    /// The policy had no `Statement` element at all.
81    #[error("key policy has no Statement element")]
82    NoStatements,
83    /// An `Allow` statement used `NotAction` (an allow-all-but grant).
84    #[error("an Allow statement uses NotAction, which is too broad to bound")]
85    NotAction,
86    /// An action used a `*` wildcard.
87    #[error("key policy grants wildcard action {0:?}; only explicit actions are allowed")]
88    WildcardAction(String),
89    /// A gate-loosening action was granted to some principal.
90    #[error("key policy grants gate-loosening action {0:?} (could change or bypass the decrypt gate)")]
91    ForbiddenAction(String),
92    /// A statement allows `kms:Decrypt` without a complete PCR binding to
93    /// this enclave.
94    #[error("a statement allows kms:Decrypt without binding it to this enclave's PCR0/1/2")]
95    UngatedDecrypt,
96    /// A `kms:Decrypt` statement binds a PCR to the wrong value.
97    #[error("kms:Decrypt is bound to PCR{index} {found:?}, not this enclave's {expected:?}")]
98    PcrMismatch {
99        /// PCR index (0, 1, or 2).
100        index: u8,
101        /// Value found in the policy condition.
102        found: String,
103        /// This enclave's own PCR value (hex).
104        expected: String,
105    },
106}
107
108/// Verify that `policy_json` (the stringified document returned by
109/// `kms:GetKeyPolicy`) is safe for a LUKS-wrapping key whose `kms:Decrypt`
110/// must be gated to `own` — this enclave's own PCR0/1/2. Fails closed; see
111/// the module docs for the exact invariants.
112pub fn verify_decrypt_policy(policy_json: &str, own: &Pcrs) -> Result<(), PolicyError> {
113    let doc: Value =
114        serde_json::from_str(policy_json).map_err(|e| PolicyError::Json(e.to_string()))?;
115
116    let statements = match doc.get("Statement") {
117        Some(Value::Array(arr)) => arr.clone(),
118        Some(other) => vec![other.clone()],
119        None => return Err(PolicyError::NoStatements),
120    };
121
122    let want = [
123        ("0", 0u8, hex::encode(&own.pcr0)),
124        ("1", 1u8, hex::encode(&own.pcr1)),
125        ("2", 2u8, hex::encode(&own.pcr2)),
126    ];
127
128    for stmt in &statements {
129        // Deny statements only restrict; they cannot grant anything, so
130        // they are irrelevant to "what is allowed". Skip anything that is
131        // not an explicit Allow.
132        if stmt.get("Effect").and_then(Value::as_str) != Some("Allow") {
133            continue;
134        }
135
136        // An Allow + NotAction grants everything except the listed actions
137        // (including Decrypt / PutKeyPolicy) — unbounded, reject.
138        if stmt.get("NotAction").is_some() {
139            return Err(PolicyError::NotAction);
140        }
141
142        let actions = normalize_actions(stmt.get("Action"));
143
144        for action in &actions {
145            if action.contains('*') {
146                return Err(PolicyError::WildcardAction(action.clone()));
147            }
148            if FORBIDDEN_ACTIONS.contains(&action.as_str())
149                || action.starts_with("kms:reencrypt")
150            {
151                return Err(PolicyError::ForbiddenAction(action.clone()));
152            }
153        }
154
155        // Any statement that allows Decrypt must pin all three PCRs to us.
156        if actions.iter().any(|a| a == "kms:decrypt") {
157            let bound = collect_pcr_bindings(stmt);
158            for (suffix, index, expected) in &want {
159                match bound.get(*suffix) {
160                    None => return Err(PolicyError::UngatedDecrypt),
161                    Some(found) => {
162                        if !found.eq_ignore_ascii_case(expected) {
163                            return Err(PolicyError::PcrMismatch {
164                                index: *index,
165                                found: found.clone(),
166                                expected: expected.clone(),
167                            });
168                        }
169                    }
170                }
171            }
172        }
173    }
174
175    Ok(())
176}
177
178/// Normalize a statement's `Action` (a string or an array of strings) to a
179/// lowercased `Vec<String>`. A missing/!string `Action` yields an empty
180/// list (such a statement grants no action we care about).
181fn normalize_actions(action: Option<&Value>) -> Vec<String> {
182    match action {
183        Some(Value::String(s)) => vec![s.to_ascii_lowercase()],
184        Some(Value::Array(arr)) => arr
185            .iter()
186            .filter_map(Value::as_str)
187            .map(|s| s.to_ascii_lowercase())
188            .collect(),
189        _ => Vec::new(),
190    }
191}
192
193/// Pull the `kms:RecipientAttestation:PCR{n}` bindings out of a statement's
194/// `Condition`, keyed by the `{n}` suffix ("0"/"1"/"2"). Only hard
195/// string-equality operators ([`EQ_OPERATORS`]) count, and only a single
196/// scalar value per PCR is accepted: a multi-value set would mean "match
197/// any of these images", a looser gate than we require, so it is dropped
198/// (and the missing binding then trips [`PolicyError::UngatedDecrypt`]).
199fn collect_pcr_bindings(stmt: &Value) -> std::collections::BTreeMap<String, String> {
200    let mut out = std::collections::BTreeMap::new();
201    let Some(cond) = stmt.get("Condition").and_then(Value::as_object) else {
202        return out;
203    };
204    for (op, kv) in cond {
205        if !EQ_OPERATORS.contains(&op.as_str()) {
206            continue;
207        }
208        let Some(kv_map) = kv.as_object() else { continue };
209        for (key, val) in kv_map {
210            let suffix = key
211                .strip_prefix("kms:RecipientAttestation:PCR")
212                .or_else(|| key.strip_prefix("kms:RecipientAttestation:pcr"));
213            let Some(suffix) = suffix else { continue };
214            // Only a single scalar string binds; an array (set) is a looser
215            // "any of" gate we deliberately do not honour.
216            if let Value::String(s) = val {
217                out.insert(suffix.to_string(), s.clone());
218            }
219        }
220    }
221    out
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn pcrs(a: &[u8], b: &[u8], c: &[u8]) -> Pcrs {
229        Pcrs {
230            pcr0: a.to_vec(),
231            pcr1: b.to_vec(),
232            pcr2: c.to_vec(),
233        }
234    }
235
236    /// Own PCRs used across the tests: bytes whose hex is stable/known.
237    fn own() -> Pcrs {
238        pcrs(&[0xaa; 48], &[0xbb; 48], &[0xcc; 48])
239    }
240
241    fn hex_of(b: &[u8]) -> String {
242        hex::encode(b)
243    }
244
245    /// A correct production-shaped policy: account lifecycle (no decrypt),
246    /// enclave GetPublicKey, enclave attested Decrypt gated to `own`.
247    fn good_policy(own: &Pcrs) -> String {
248        serde_json::json!({
249            "Version": "2012-10-17",
250            "Statement": [
251                {
252                    "Sid": "AccountKeyLifecycle",
253                    "Effect": "Allow",
254                    "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
255                    "Action": [
256                        "kms:DescribeKey",
257                        "kms:GetKeyPolicy",
258                        "kms:ScheduleKeyDeletion"
259                    ],
260                    "Resource": "*"
261                },
262                {
263                    "Sid": "EnclaveGetPublicKey",
264                    "Effect": "Allow",
265                    "Principal": { "AWS": "arn:aws:iam::111122223333:role/enc" },
266                    "Action": "kms:GetPublicKey",
267                    "Resource": "*"
268                },
269                {
270                    "Sid": "EnclaveAttestedDecrypt",
271                    "Effect": "Allow",
272                    "Principal": { "AWS": "arn:aws:iam::111122223333:role/enc" },
273                    "Action": "kms:Decrypt",
274                    "Resource": "*",
275                    "Condition": {
276                        "StringEqualsIgnoreCase": {
277                            "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
278                            "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
279                            "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
280                        }
281                    }
282                }
283            ]
284        })
285        .to_string()
286    }
287
288    #[test]
289    fn accepts_correct_policy() {
290        let own = own();
291        verify_decrypt_policy(&good_policy(&own), &own).expect("good policy accepted");
292    }
293
294    #[test]
295    fn accepts_correct_policy_case_insensitive_pcr_hex() {
296        // StringEqualsIgnoreCase semantics: upper-case policy hex must still
297        // match our lower-case own PCRs.
298        let own = own();
299        let doc = serde_json::json!({
300            "Statement": [{
301                "Effect": "Allow",
302                "Action": "kms:Decrypt",
303                "Condition": { "StringEqualsIgnoreCase": {
304                    "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0).to_uppercase(),
305                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1).to_uppercase(),
306                    "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2).to_uppercase(),
307                }}
308            }]
309        })
310        .to_string();
311        verify_decrypt_policy(&doc, &own).expect("case-insensitive hex accepted");
312    }
313
314    #[test]
315    fn empty_statements_pass_vacuously() {
316        let doc = r#"{"Version":"2012-10-17","Statement":[]}"#;
317        verify_decrypt_policy(doc, &own()).expect("empty policy is vacuously safe");
318    }
319
320    #[test]
321    fn rejects_account_kms_star() {
322        let own = own();
323        let doc = serde_json::json!({
324            "Statement": [{
325                "Sid": "AccountAdmin",
326                "Effect": "Allow",
327                "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
328                "Action": "kms:*",
329                "Resource": "*"
330            }]
331        })
332        .to_string();
333        assert_eq!(
334            verify_decrypt_policy(&doc, &own),
335            Err(PolicyError::WildcardAction("kms:*".into()))
336        );
337    }
338
339    #[test]
340    fn rejects_put_key_policy() {
341        let doc = serde_json::json!({
342            "Statement": [{
343                "Effect": "Allow",
344                "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
345                "Action": ["kms:DescribeKey", "kms:PutKeyPolicy"],
346                "Resource": "*"
347            }]
348        })
349        .to_string();
350        assert_eq!(
351            verify_decrypt_policy(&doc, &own()),
352            Err(PolicyError::ForbiddenAction("kms:putkeypolicy".into()))
353        );
354    }
355
356    #[test]
357    fn rejects_create_grant() {
358        let doc = serde_json::json!({
359            "Statement": [{
360                "Effect": "Allow",
361                "Action": "kms:CreateGrant",
362                "Resource": "*"
363            }]
364        })
365        .to_string();
366        assert_eq!(
367            verify_decrypt_policy(&doc, &own()),
368            Err(PolicyError::ForbiddenAction("kms:creategrant".into()))
369        );
370    }
371
372    #[test]
373    fn rejects_reencrypt() {
374        let doc = serde_json::json!({
375            "Statement": [{
376                "Effect": "Allow",
377                "Action": "kms:ReEncryptFrom",
378                "Resource": "*"
379            }]
380        })
381        .to_string();
382        assert_eq!(
383            verify_decrypt_policy(&doc, &own()),
384            Err(PolicyError::ForbiddenAction("kms:reencryptfrom".into()))
385        );
386    }
387
388    #[test]
389    fn rejects_ungated_decrypt() {
390        let doc = serde_json::json!({
391            "Statement": [{
392                "Effect": "Allow",
393                "Principal": "*",
394                "Action": "kms:Decrypt",
395                "Resource": "*"
396            }]
397        })
398        .to_string();
399        assert_eq!(
400            verify_decrypt_policy(&doc, &own()),
401            Err(PolicyError::UngatedDecrypt)
402        );
403    }
404
405    #[test]
406    fn rejects_decrypt_gated_to_wrong_pcr() {
407        let own = own();
408        let doc = serde_json::json!({
409            "Statement": [{
410                "Effect": "Allow",
411                "Action": "kms:Decrypt",
412                "Condition": { "StringEqualsIgnoreCase": {
413                    "kms:RecipientAttestation:PCR0": hex_of(&[0xde; 48]),
414                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
415                    "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
416                }}
417            }]
418        })
419        .to_string();
420        match verify_decrypt_policy(&doc, &own) {
421            Err(PolicyError::PcrMismatch { index: 0, .. }) => {}
422            other => panic!("expected PCR0 mismatch, got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn rejects_decrypt_missing_one_pcr() {
428        let own = own();
429        let doc = serde_json::json!({
430            "Statement": [{
431                "Effect": "Allow",
432                "Action": "kms:Decrypt",
433                "Condition": { "StringEqualsIgnoreCase": {
434                    "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
435                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
436                }}
437            }]
438        })
439        .to_string();
440        assert_eq!(
441            verify_decrypt_policy(&doc, &own),
442            Err(PolicyError::UngatedDecrypt)
443        );
444    }
445
446    #[test]
447    fn rejects_decrypt_gated_by_set_of_pcrs() {
448        // An array value means "match any of these images" — a looser gate
449        // we refuse to honour, so the binding is dropped and Decrypt reads
450        // as ungated.
451        let own = own();
452        let doc = serde_json::json!({
453            "Statement": [{
454                "Effect": "Allow",
455                "Action": "kms:Decrypt",
456                "Condition": { "StringEqualsIgnoreCase": {
457                    "kms:RecipientAttestation:PCR0": [hex_of(&own.pcr0), hex_of(&[0xff; 48])],
458                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
459                    "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
460                }}
461            }]
462        })
463        .to_string();
464        assert_eq!(
465            verify_decrypt_policy(&doc, &own),
466            Err(PolicyError::UngatedDecrypt)
467        );
468    }
469
470    #[test]
471    fn rejects_decrypt_gated_by_wrong_operator() {
472        // StringLike is not a hard equality, so it does not count as a
473        // binding; Decrypt reads as ungated.
474        let own = own();
475        let doc = serde_json::json!({
476            "Statement": [{
477                "Effect": "Allow",
478                "Action": "kms:Decrypt",
479                "Condition": { "StringLike": {
480                    "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
481                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
482                    "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
483                }}
484            }]
485        })
486        .to_string();
487        assert_eq!(
488            verify_decrypt_policy(&doc, &own),
489            Err(PolicyError::UngatedDecrypt)
490        );
491    }
492
493    #[test]
494    fn rejects_not_action_allow() {
495        let doc = serde_json::json!({
496            "Statement": [{
497                "Effect": "Allow",
498                "Principal": { "AWS": "arn:aws:iam::111122223333:root" },
499                "NotAction": "kms:CreateKey",
500                "Resource": "*"
501            }]
502        })
503        .to_string();
504        assert_eq!(verify_decrypt_policy(&doc, &own()), Err(PolicyError::NotAction));
505    }
506
507    #[test]
508    fn ignores_deny_statements() {
509        // A Deny with kms:* must not trip the wildcard check (it restricts,
510        // it does not grant), and the Allow Decrypt is correctly gated.
511        let own = own();
512        let doc = serde_json::json!({
513            "Statement": [
514                {
515                    "Effect": "Deny",
516                    "Principal": "*",
517                    "Action": "kms:*",
518                    "Resource": "*",
519                    "Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } }
520                },
521                {
522                    "Effect": "Allow",
523                    "Action": "kms:Decrypt",
524                    "Condition": { "StringEqualsIgnoreCase": {
525                        "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
526                        "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
527                        "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
528                    }}
529                }
530            ]
531        })
532        .to_string();
533        verify_decrypt_policy(&doc, &own).expect("deny statements are ignored");
534    }
535
536    #[test]
537    fn rejects_non_json() {
538        match verify_decrypt_policy("not json", &own()) {
539            Err(PolicyError::Json(_)) => {}
540            other => panic!("expected Json error, got {other:?}"),
541        }
542    }
543
544    #[test]
545    fn rejects_missing_statement() {
546        assert_eq!(
547            verify_decrypt_policy(r#"{"Version":"2012-10-17"}"#, &own()),
548            Err(PolicyError::NoStatements)
549        );
550    }
551
552    #[test]
553    fn accepts_single_statement_object() {
554        // AWS allows Statement to be a single object, not just an array.
555        let own = own();
556        let doc = serde_json::json!({
557            "Statement": {
558                "Effect": "Allow",
559                "Action": "kms:Decrypt",
560                "Condition": { "StringEquals": {
561                    "kms:RecipientAttestation:PCR0": hex_of(&own.pcr0),
562                    "kms:RecipientAttestation:PCR1": hex_of(&own.pcr1),
563                    "kms:RecipientAttestation:PCR2": hex_of(&own.pcr2),
564                }}
565            }
566        })
567        .to_string();
568        verify_decrypt_policy(&doc, &own).expect("single-object statement accepted");
569    }
570}