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