Skip to main content

fakecloud_kms/
provisioner.rs

1//! Public helpers for provisioning KMS keys from outside the
2//! `fakecloud-kms` crate (e.g. CloudFormation `AWS::KMS::Key` /
3//! `AWS::KMS::ReplicaKey`). This module exposes a single
4//! [`build_kms_key`] factory that mirrors `CreateKey`'s behaviour
5//! without going through the AWS request/response machinery, plus
6//! [`provision_key`] / [`provision_replica_key`] / [`provision_alias`]
7//! which take a `SharedKmsState` write lock and insert the resulting
8//! records.
9//!
10//! Keeping the asymmetric keypair generation, signing-algorithm
11//! tables, and policy defaulting in one place means CFN provisioning
12//! produces keys that are byte-compatible with what `CreateKey`
13//! itself would have produced — no asymmetric stubs, no missing
14//! `signing_algorithms`, no missing public key DER for `GetPublicKey`.
15
16use std::collections::BTreeMap;
17
18use chrono::Utc;
19use fakecloud_aws::arn::Arn;
20use uuid::Uuid;
21
22use super::asym;
23use super::asym_ecdsa;
24use super::helpers::{
25    default_key_policy, encryption_algorithms_for_key, mac_algorithms_for_key_spec, rand_bytes,
26    signing_algorithms_for_key_spec,
27};
28use crate::state::{KmsAlias, KmsKey, SharedKmsState};
29
30/// Inputs accepted by [`build_kms_key`]. Mirrors the subset of
31/// `AWS::KMS::Key` properties + `CreateKey` request fields that
32/// fakecloud honours. Defaults match AWS: `key_usage =
33/// ENCRYPT_DECRYPT`, `key_spec = SYMMETRIC_DEFAULT`, `origin =
34/// AWS_KMS`, `enabled = true`.
35#[derive(Debug, Clone)]
36pub struct KeyCreationInput {
37    pub description: String,
38    pub key_usage: String,
39    pub key_spec: String,
40    pub origin: String,
41    pub enabled: bool,
42    pub multi_region: bool,
43    pub key_rotation_enabled: bool,
44    pub policy: Option<String>,
45    pub tags: BTreeMap<String, String>,
46}
47
48impl Default for KeyCreationInput {
49    fn default() -> Self {
50        Self {
51            description: String::new(),
52            key_usage: "ENCRYPT_DECRYPT".to_string(),
53            key_spec: "SYMMETRIC_DEFAULT".to_string(),
54            origin: "AWS_KMS".to_string(),
55            enabled: true,
56            multi_region: false,
57            key_rotation_enabled: false,
58            policy: None,
59            tags: BTreeMap::new(),
60        }
61    }
62}
63
64/// AWS KMS key specs accepted across `CreateKey` / CFN.
65pub const VALID_KEY_SPECS: &[&str] = &[
66    "SYMMETRIC_DEFAULT",
67    "RSA_2048",
68    "RSA_3072",
69    "RSA_4096",
70    "ECC_NIST_P256",
71    "ECC_NIST_P384",
72    "ECC_NIST_P521",
73    "ECC_SECG_P256K1",
74    "HMAC_224",
75    "HMAC_256",
76    "HMAC_384",
77    "HMAC_512",
78    "SM2",
79];
80
81/// AWS KMS key usages accepted across `CreateKey` / CFN.
82pub const VALID_KEY_USAGES: &[&str] = &[
83    "ENCRYPT_DECRYPT",
84    "SIGN_VERIFY",
85    "GENERATE_VERIFY_MAC",
86    "KEY_AGREEMENT",
87];
88
89/// Validate that a `(key_usage, key_spec)` pair is supported by
90/// fakecloud's KMS implementation. Mirrors the constraints the real
91/// AWS service enforces: e.g. only HMAC specs allow
92/// `GENERATE_VERIFY_MAC`, and only ECC specs allow `KEY_AGREEMENT`.
93pub fn validate_key_usage_for_spec(key_usage: &str, key_spec: &str) -> Result<(), String> {
94    if !VALID_KEY_USAGES.contains(&key_usage) {
95        return Err(format!("Unsupported KeyUsage: {key_usage}"));
96    }
97    if !VALID_KEY_SPECS.contains(&key_spec) {
98        return Err(format!("Unsupported KeySpec: {key_spec}"));
99    }
100    let is_symmetric = key_spec == "SYMMETRIC_DEFAULT";
101    let is_hmac = key_spec.starts_with("HMAC_");
102    let is_rsa = key_spec.starts_with("RSA_");
103    let is_ecc = key_spec.starts_with("ECC_");
104    let is_sm2 = key_spec == "SM2";
105    match key_usage {
106        "ENCRYPT_DECRYPT" => {
107            if !(is_symmetric || is_rsa || is_sm2) {
108                return Err(format!(
109                    "KeySpec {key_spec} does not support KeyUsage ENCRYPT_DECRYPT"
110                ));
111            }
112        }
113        "SIGN_VERIFY" => {
114            if !(is_rsa || is_ecc || is_sm2) {
115                return Err(format!(
116                    "KeySpec {key_spec} does not support KeyUsage SIGN_VERIFY"
117                ));
118            }
119        }
120        "GENERATE_VERIFY_MAC" => {
121            if !is_hmac {
122                return Err(format!(
123                    "KeySpec {key_spec} does not support KeyUsage GENERATE_VERIFY_MAC"
124                ));
125            }
126        }
127        "KEY_AGREEMENT" => {
128            if !is_ecc {
129                return Err(format!(
130                    "KeySpec {key_spec} does not support KeyUsage KEY_AGREEMENT"
131                ));
132            }
133        }
134        _ => unreachable!(),
135    }
136    Ok(())
137}
138
139/// Build a fully-populated [`KmsKey`] for the given inputs. Generates
140/// asymmetric keypairs (RSA / ECDSA) when the spec calls for it,
141/// computes the matching `signing_algorithms` /
142/// `encryption_algorithms` / `mac_algorithms` tables, and falls back
143/// to [`default_key_policy`] when no policy is supplied.
144///
145/// `region` and `account_id` are used to mint the ARN. Caller is
146/// responsible for inserting the result into the appropriate
147/// `KmsState.keys` map under `key_id`.
148pub fn build_kms_key(
149    region: &str,
150    account_id: &str,
151    input: &KeyCreationInput,
152) -> Result<KmsKey, String> {
153    validate_key_usage_for_spec(&input.key_usage, &input.key_spec)?;
154
155    let key_id = if input.multi_region {
156        format!("mrk-{}", Uuid::new_v4().as_simple())
157    } else {
158        Uuid::new_v4().to_string()
159    };
160    let arn = Arn::new("kms", region, account_id, &format!("key/{key_id}")).to_string();
161    let now = Utc::now().timestamp() as f64;
162
163    let signing_algs = if input.key_usage == "SIGN_VERIFY" {
164        signing_algorithms_for_key_spec(&input.key_spec)
165    } else {
166        None
167    };
168    let encryption_algs = encryption_algorithms_for_key(&input.key_usage, &input.key_spec);
169    let mac_algs = if input.key_usage == "GENERATE_VERIFY_MAC" {
170        mac_algorithms_for_key_spec(&input.key_spec)
171    } else {
172        None
173    };
174
175    let mut asym_priv: Option<Vec<u8>> = None;
176    let mut asym_pub: Option<Vec<u8>> = None;
177    if let Some((p, k)) =
178        asym::generate_keypair(&input.key_spec).map_err(|e| format!("rsa keygen failed: {e}"))?
179    {
180        asym_priv = Some(p);
181        asym_pub = Some(k);
182    } else if let Some((p, k)) = asym_ecdsa::generate_keypair(&input.key_spec)
183        .map_err(|e| format!("ecdsa keygen failed: {e}"))?
184    {
185        asym_priv = Some(p);
186        asym_pub = Some(k);
187    }
188
189    let policy = input
190        .policy
191        .clone()
192        .unwrap_or_else(|| default_key_policy(account_id));
193
194    Ok(KmsKey {
195        key_id,
196        arn,
197        creation_date: now,
198        description: input.description.clone(),
199        enabled: input.enabled,
200        key_usage: input.key_usage.clone(),
201        key_spec: input.key_spec.clone(),
202        key_manager: "CUSTOMER".to_string(),
203        key_state: if input.enabled { "Enabled" } else { "Disabled" }.to_string(),
204        deletion_date: None,
205        tags: input.tags.clone(),
206        policy,
207        key_rotation_enabled: input.key_rotation_enabled,
208        origin: input.origin.clone(),
209        multi_region: input.multi_region,
210        rotations: Vec::new(),
211        signing_algorithms: signing_algs,
212        encryption_algorithms: encryption_algs,
213        mac_algorithms: mac_algs,
214        custom_key_store_id: None,
215        imported_key_material: false,
216        imported_material_bytes: None,
217        private_key_seed: rand_bytes(32),
218        primary_region: None,
219        asymmetric_private_key_der: asym_priv,
220        asymmetric_public_key_der: asym_pub,
221    })
222}
223
224/// Build and insert a key into shared state. Returns
225/// `(key_id, arn)`.
226pub fn provision_key(
227    state: &SharedKmsState,
228    account_id: &str,
229    input: &KeyCreationInput,
230) -> Result<(String, String), String> {
231    let mut accounts = state.write();
232    let s = accounts.get_or_create(account_id);
233    let region = s.region.clone();
234    let key = build_kms_key(&region, account_id, input)?;
235    let key_id = key.key_id.clone();
236    let arn = key.arn.clone();
237    s.keys.insert(key_id.clone(), key);
238    Ok((key_id, arn))
239}
240
241/// Provision a multi-region replica of an existing primary key.
242/// Looks the primary up in the same account state, validates that
243/// it's `MultiRegion=true`, mints a `mrk-replica-` id (fakecloud is
244/// single-region so colliding IDs would overwrite the primary), and
245/// inserts the replica with `primary_region` set so `DescribeKey`
246/// reports `MultiRegionKeyType=REPLICA`.
247pub fn provision_replica_key(
248    state: &SharedKmsState,
249    account_id: &str,
250    primary_arn: &str,
251    description: Option<String>,
252    enabled: bool,
253    policy: Option<String>,
254    tags: BTreeMap<String, String>,
255) -> Result<(String, String), String> {
256    let parts: Vec<&str> = primary_arn.split(':').collect();
257    if parts.len() < 6 {
258        return Err(format!("Invalid PrimaryKeyArn: {primary_arn}"));
259    }
260    let primary_region = parts[3].to_string();
261    let key_id = parts[5]
262        .strip_prefix("key/")
263        .ok_or_else(|| format!("PrimaryKeyArn missing key/ segment: {primary_arn}"))?
264        .to_string();
265
266    let mut accounts = state.write();
267    let s = accounts.get_or_create(account_id);
268    let region = s.region.clone();
269    // Source must be a multi-region key in the primary account; look
270    // it up either by raw key_id (when the primary lives in this
271    // state's region) or via the region-keyed slot.
272    let source_storage_keys = [key_id.clone(), format!("{primary_region}:{key_id}")];
273    let source = source_storage_keys
274        .iter()
275        .find_map(|k| s.keys.get(k).cloned())
276        .ok_or_else(|| format!("Primary key {primary_arn} does not exist"))?;
277    if !source.multi_region {
278        return Err(format!(
279            "Primary key {primary_arn} is not a multi-region key"
280        ));
281    }
282
283    let replica_key_id = format!("mrk-replica-{}", Uuid::new_v4().as_simple());
284    let replica_arn =
285        Arn::new("kms", &region, account_id, &format!("key/{replica_key_id}")).to_string();
286    let mut replica = source;
287    replica.key_id = replica_key_id.clone();
288    replica.arn = replica_arn.clone();
289    if let Some(d) = description {
290        if !d.is_empty() {
291            replica.description = d;
292        }
293    }
294    replica.enabled = enabled;
295    replica.key_state = if enabled { "Enabled" } else { "Disabled" }.to_string();
296    if let Some(p) = policy {
297        if !p.is_empty() {
298            replica.policy = p;
299        }
300    }
301    if !tags.is_empty() {
302        replica.tags.extend(tags);
303    }
304    replica.deletion_date = None;
305    replica.key_rotation_enabled = false;
306    replica.multi_region = true;
307    replica.rotations = Vec::new();
308    replica.custom_key_store_id = None;
309    replica.imported_key_material = false;
310    replica.imported_material_bytes = None;
311    replica.primary_region = Some(primary_region);
312
313    s.keys.insert(replica_key_id.clone(), replica);
314    Ok((replica_key_id, replica_arn))
315}
316
317/// Insert (or replace) an alias pointing at `target_key_id`. Resolves
318/// `target_input` against either a raw key id or a key ARN. Returns
319/// the alias name on success.
320pub fn provision_alias(
321    state: &SharedKmsState,
322    account_id: &str,
323    alias_name: &str,
324    target_input: &str,
325) -> Result<String, String> {
326    if !alias_name.starts_with("alias/") {
327        return Err(format!(
328            "AliasName must start with 'alias/'; got '{alias_name}'"
329        ));
330    }
331    let mut accounts = state.write();
332    let s = accounts.get_or_create(account_id);
333    let target_key_id = if s.keys.contains_key(target_input) {
334        target_input.to_string()
335    } else if let Some(id) = target_input
336        .strip_prefix("arn:aws:kms:")
337        .and_then(|rest| rest.split(":key/").nth(1))
338    {
339        if s.keys.contains_key(id) {
340            id.to_string()
341        } else {
342            return Err(format!("KMS key '{target_input}' does not exist"));
343        }
344    } else {
345        return Err(format!("KMS key '{target_input}' does not exist"));
346    };
347    let alias_arn = Arn::new("kms", &s.region, &s.account_id, alias_name).to_string();
348    let alias = KmsAlias {
349        alias_name: alias_name.to_string(),
350        alias_arn,
351        target_key_id,
352        creation_date: Utc::now().timestamp() as f64,
353    };
354    s.aliases.insert(alias_name.to_string(), alias);
355    Ok(alias_name.to_string())
356}
357
358/// Mutable updates to apply to an existing key. Each `Option` field
359/// is "leave alone if `None`, replace with this value if `Some`",
360/// mirroring the AWS update semantics where unspecified fields are
361/// untouched. Properties that AWS treats as immutable (`KeySpec`,
362/// `KeyUsage`, `Origin`, `MultiRegion`) aren't representable here —
363/// the caller is expected to detect those changes and trigger
364/// resource replacement.
365#[derive(Debug, Default, Clone)]
366pub struct KeyUpdate {
367    pub description: Option<String>,
368    pub enabled: Option<bool>,
369    pub key_rotation_enabled: Option<bool>,
370    pub policy: Option<String>,
371    pub tags: Option<BTreeMap<String, String>>,
372}
373
374/// Apply a [`KeyUpdate`] to the key with `key_id`. Returns an error
375/// if the key isn't found in `state`.
376pub fn update_key_properties(
377    state: &SharedKmsState,
378    account_id: &str,
379    key_id: &str,
380    update: KeyUpdate,
381) -> Result<(), String> {
382    let mut accounts = state.write();
383    let s = accounts.get_or_create(account_id);
384    let key = s
385        .keys
386        .get_mut(key_id)
387        .ok_or_else(|| format!("Key '{key_id}' does not exist"))?;
388    if let Some(d) = update.description {
389        key.description = d;
390    }
391    if let Some(e) = update.enabled {
392        key.enabled = e;
393        key.key_state = if e { "Enabled" } else { "Disabled" }.to_string();
394    }
395    if let Some(r) = update.key_rotation_enabled {
396        key.key_rotation_enabled = r;
397    }
398    if let Some(p) = update.policy {
399        if !p.is_empty() {
400            key.policy = p;
401        }
402    }
403    if let Some(t) = update.tags {
404        key.tags = t;
405    }
406    Ok(())
407}
408
409/// Repoint an existing alias at a different target key. Used by
410/// `update_resource` for `AWS::KMS::Alias` when only `TargetKeyId`
411/// changes.
412pub fn update_alias_target(
413    state: &SharedKmsState,
414    account_id: &str,
415    alias_name: &str,
416    target_input: &str,
417) -> Result<(), String> {
418    let mut accounts = state.write();
419    let s = accounts.get_or_create(account_id);
420    let target_key_id = if s.keys.contains_key(target_input) {
421        target_input.to_string()
422    } else if let Some(id) = target_input
423        .strip_prefix("arn:aws:kms:")
424        .and_then(|rest| rest.split(":key/").nth(1))
425    {
426        if s.keys.contains_key(id) {
427            id.to_string()
428        } else {
429            return Err(format!("KMS key '{target_input}' does not exist"));
430        }
431    } else {
432        return Err(format!("KMS key '{target_input}' does not exist"));
433    };
434    let alias = s
435        .aliases
436        .get_mut(alias_name)
437        .ok_or_else(|| format!("Alias '{alias_name}' does not exist"))?;
438    alias.target_key_id = target_key_id;
439    Ok(())
440}