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        rotation_period_in_days: None,
209        origin: input.origin.clone(),
210        multi_region: input.multi_region,
211        rotations: Vec::new(),
212        signing_algorithms: signing_algs,
213        encryption_algorithms: encryption_algs,
214        mac_algorithms: mac_algs,
215        custom_key_store_id: None,
216        imported_key_material: false,
217        imported_material_bytes: None,
218        private_key_seed: rand_bytes(32),
219        primary_region: None,
220        asymmetric_private_key_der: asym_priv,
221        asymmetric_public_key_der: asym_pub,
222    })
223}
224
225/// Build and insert a key into shared state. Returns
226/// `(key_id, arn)`.
227pub fn provision_key(
228    state: &SharedKmsState,
229    account_id: &str,
230    input: &KeyCreationInput,
231) -> Result<(String, String), String> {
232    let mut accounts = state.write();
233    let s = accounts.get_or_create(account_id);
234    let region = s.region.clone();
235    let key = build_kms_key(&region, account_id, input)?;
236    let key_id = key.key_id.clone();
237    let arn = key.arn.clone();
238    s.keys.insert(key_id.clone(), key);
239    Ok((key_id, arn))
240}
241
242/// Provision a multi-region replica of an existing primary key.
243/// Looks the primary up in the same account state, validates that
244/// it's `MultiRegion=true`, mints a `mrk-replica-` id (fakecloud is
245/// single-region so colliding IDs would overwrite the primary), and
246/// inserts the replica with `primary_region` set so `DescribeKey`
247/// reports `MultiRegionKeyType=REPLICA`.
248pub fn provision_replica_key(
249    state: &SharedKmsState,
250    account_id: &str,
251    primary_arn: &str,
252    description: Option<String>,
253    enabled: bool,
254    policy: Option<String>,
255    tags: BTreeMap<String, String>,
256) -> Result<(String, String), String> {
257    let parts: Vec<&str> = primary_arn.split(':').collect();
258    if parts.len() < 6 {
259        return Err(format!("Invalid PrimaryKeyArn: {primary_arn}"));
260    }
261    let primary_region = parts[3].to_string();
262    let key_id = parts[5]
263        .strip_prefix("key/")
264        .ok_or_else(|| format!("PrimaryKeyArn missing key/ segment: {primary_arn}"))?
265        .to_string();
266
267    let mut accounts = state.write();
268    let s = accounts.get_or_create(account_id);
269    let region = s.region.clone();
270    // Source must be a multi-region key in the primary account; look
271    // it up either by raw key_id (when the primary lives in this
272    // state's region) or via the region-keyed slot.
273    let source_storage_keys = [key_id.clone(), format!("{primary_region}:{key_id}")];
274    let source = source_storage_keys
275        .iter()
276        .find_map(|k| s.keys.get(k).cloned())
277        .ok_or_else(|| format!("Primary key {primary_arn} does not exist"))?;
278    if !source.multi_region {
279        return Err(format!(
280            "Primary key {primary_arn} is not a multi-region key"
281        ));
282    }
283
284    let replica_key_id = format!("mrk-replica-{}", Uuid::new_v4().as_simple());
285    let replica_arn =
286        Arn::new("kms", &region, account_id, &format!("key/{replica_key_id}")).to_string();
287    let mut replica = source;
288    replica.key_id = replica_key_id.clone();
289    replica.arn = replica_arn.clone();
290    if let Some(d) = description {
291        if !d.is_empty() {
292            replica.description = d;
293        }
294    }
295    replica.enabled = enabled;
296    replica.key_state = if enabled { "Enabled" } else { "Disabled" }.to_string();
297    if let Some(p) = policy {
298        if !p.is_empty() {
299            replica.policy = p;
300        }
301    }
302    if !tags.is_empty() {
303        replica.tags.extend(tags);
304    }
305    replica.deletion_date = None;
306    replica.key_rotation_enabled = false;
307    replica.multi_region = true;
308    replica.rotations = Vec::new();
309    replica.custom_key_store_id = None;
310    replica.imported_key_material = false;
311    replica.imported_material_bytes = None;
312    replica.primary_region = Some(primary_region);
313
314    s.keys.insert(replica_key_id.clone(), replica);
315    Ok((replica_key_id, replica_arn))
316}
317
318/// Insert (or replace) an alias pointing at `target_key_id`. Resolves
319/// `target_input` against either a raw key id or a key ARN. Returns
320/// the alias name on success.
321pub fn provision_alias(
322    state: &SharedKmsState,
323    account_id: &str,
324    alias_name: &str,
325    target_input: &str,
326) -> Result<String, String> {
327    if !alias_name.starts_with("alias/") {
328        return Err(format!(
329            "AliasName must start with 'alias/'; got '{alias_name}'"
330        ));
331    }
332    let mut accounts = state.write();
333    let s = accounts.get_or_create(account_id);
334    let target_key_id = if s.keys.contains_key(target_input) {
335        target_input.to_string()
336    } else if let Some(id) = target_input
337        .strip_prefix("arn:aws:kms:")
338        .and_then(|rest| rest.split(":key/").nth(1))
339    {
340        if s.keys.contains_key(id) {
341            id.to_string()
342        } else {
343            return Err(format!("KMS key '{target_input}' does not exist"));
344        }
345    } else {
346        return Err(format!("KMS key '{target_input}' does not exist"));
347    };
348    let alias_arn = Arn::new("kms", &s.region, &s.account_id, alias_name).to_string();
349    let alias = KmsAlias {
350        alias_name: alias_name.to_string(),
351        alias_arn,
352        target_key_id,
353        creation_date: Utc::now().timestamp() as f64,
354    };
355    s.aliases.insert(alias_name.to_string(), alias);
356    Ok(alias_name.to_string())
357}
358
359/// Mutable updates to apply to an existing key. Each `Option` field
360/// is "leave alone if `None`, replace with this value if `Some`",
361/// mirroring the AWS update semantics where unspecified fields are
362/// untouched. Properties that AWS treats as immutable (`KeySpec`,
363/// `KeyUsage`, `Origin`, `MultiRegion`) aren't representable here —
364/// the caller is expected to detect those changes and trigger
365/// resource replacement.
366#[derive(Debug, Default, Clone)]
367pub struct KeyUpdate {
368    pub description: Option<String>,
369    pub enabled: Option<bool>,
370    pub key_rotation_enabled: Option<bool>,
371    pub policy: Option<String>,
372    pub tags: Option<BTreeMap<String, String>>,
373}
374
375/// Apply a [`KeyUpdate`] to the key with `key_id`. Returns an error
376/// if the key isn't found in `state`.
377pub fn update_key_properties(
378    state: &SharedKmsState,
379    account_id: &str,
380    key_id: &str,
381    update: KeyUpdate,
382) -> Result<(), String> {
383    let mut accounts = state.write();
384    let s = accounts.get_or_create(account_id);
385    let key = s
386        .keys
387        .get_mut(key_id)
388        .ok_or_else(|| format!("Key '{key_id}' does not exist"))?;
389    if let Some(d) = update.description {
390        key.description = d;
391    }
392    if let Some(e) = update.enabled {
393        key.enabled = e;
394        key.key_state = if e { "Enabled" } else { "Disabled" }.to_string();
395    }
396    if let Some(r) = update.key_rotation_enabled {
397        key.key_rotation_enabled = r;
398    }
399    if let Some(p) = update.policy {
400        if !p.is_empty() {
401            key.policy = p;
402        }
403    }
404    if let Some(t) = update.tags {
405        key.tags = t;
406    }
407    Ok(())
408}
409
410/// Repoint an existing alias at a different target key. Used by
411/// `update_resource` for `AWS::KMS::Alias` when only `TargetKeyId`
412/// changes.
413pub fn update_alias_target(
414    state: &SharedKmsState,
415    account_id: &str,
416    alias_name: &str,
417    target_input: &str,
418) -> Result<(), String> {
419    let mut accounts = state.write();
420    let s = accounts.get_or_create(account_id);
421    let target_key_id = if s.keys.contains_key(target_input) {
422        target_input.to_string()
423    } else if let Some(id) = target_input
424        .strip_prefix("arn:aws:kms:")
425        .and_then(|rest| rest.split(":key/").nth(1))
426    {
427        if s.keys.contains_key(id) {
428            id.to_string()
429        } else {
430            return Err(format!("KMS key '{target_input}' does not exist"));
431        }
432    } else {
433        return Err(format!("KMS key '{target_input}' does not exist"));
434    };
435    let alias = s
436        .aliases
437        .get_mut(alias_name)
438        .ok_or_else(|| format!("Alias '{alias_name}' does not exist"))?;
439    alias.target_key_id = target_key_id;
440    Ok(())
441}