Skip to main content

secretx_aws_kms/
lib.rs

1//! AWS KMS signing backend for secretx.
2//!
3//! Implements [`SigningBackend`] for AWS KMS asymmetric keys. The private key
4//! never leaves AWS — all signing operations are performed inside KMS.
5//!
6//! # URI format
7//!
8//! ```text
9//! secretx:aws-kms:<key-id>[?algorithm=<algo>]
10//! ```
11//!
12//! Where `<key-id>` is a KMS key UUID, alias ARN (`alias/my-key`), or key ARN,
13//! and `<algo>` is one of `ecdsa-p256` (default) or `rsa-pss-2048`.
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! # async fn example() -> Result<(), secretx_core::SecretError> {
19//! use secretx_aws_kms::AwsKmsBackend;
20//! use secretx_core::SigningBackend;
21//!
22//! let backend = AwsKmsBackend::from_uri(
23//!     "secretx:aws-kms:alias/my-signing-key?algorithm=ecdsa-p256",
24//! )?;
25//! let sig = backend.sign(b"hello world").await?;
26//! # Ok(())
27//! # }
28//! ```
29
30use std::sync::Arc;
31
32use aws_sdk_kms::operation::get_public_key::GetPublicKeyError;
33use aws_sdk_kms::operation::sign::SignError;
34use aws_sdk_kms::primitives::Blob;
35use aws_sdk_kms::types::{MessageType, SigningAlgorithmSpec};
36use secretx_core::{
37    SecretError, SecretUri, SigningAlgorithm, SigningBackend,
38};
39use sha2::{Digest as _, Sha256};
40
41const BACKEND: &str = "aws-kms";
42
43// ── Backend ───────────────────────────────────────────────────────────────────
44
45/// AWS KMS signing backend.
46///
47/// Construct with [`AwsKmsBackend::from_uri`]. The AWS client is built once
48/// at construction time using the ambient environment credentials
49/// (`AWS_ACCESS_KEY_ID`, `AWS_PROFILE`, instance metadata, etc.).
50#[derive(Debug)]
51pub struct AwsKmsBackend {
52    client: Arc<aws_sdk_kms::Client>,
53    key_id: String,
54    algorithm: SigningAlgorithm,
55}
56
57impl AwsKmsBackend {
58    /// Construct from a `secretx:aws-kms:<key-id>[?algorithm=<algo>]` URI.
59    ///
60    /// Builds the AWS client synchronously using a scoped thread with its own
61    /// tokio runtime so that this constructor can be called from any context.
62    /// No network call is made during construction.
63    ///
64    /// **Note**: the `?algorithm=` parameter is validated syntactically (must be
65    /// a recognized algorithm name) but not against the actual KMS key type.
66    /// A mismatch (e.g. `?algorithm=rsa-pss-2048` with an EC key) will succeed
67    /// at construction and fail at the first `sign()` call with an opaque KMS
68    /// `InvalidKeyUsageException`.
69    pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
70        Self::from_parsed_uri(&SecretUri::parse(uri)?)
71    }
72
73    /// Construct from a pre-parsed [`SecretUri`].
74    pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
75        if parsed.backend() != BACKEND {
76            return Err(SecretError::InvalidUri(format!(
77                "expected backend `aws-kms`, got `{}`",
78                parsed.backend()
79            )));
80        }
81        if parsed.path().is_empty() {
82            return Err(SecretError::InvalidUri(
83                "aws-kms URI requires a key ID: secretx:aws-kms:<key-id>".into(),
84            ));
85        }
86
87        // Algorithm-key type compatibility (e.g. ecdsa-p256 on an RSA key) is
88        // NOT checked at construction time.  A mismatch is caught at sign() time
89        // by KMS server-side enforcement (InvalidKeyUsageException).  An eager
90        // GetPublicKey call here would add network latency to every construction
91        // for a check that almost never fires.
92        let algorithm = match parsed.param("algorithm") {
93            None | Some("ecdsa-p256") => SigningAlgorithm::EcdsaP256Sha256,
94            Some("rsa-pss-2048") => SigningAlgorithm::RsaPss2048Sha256,
95            Some(other) => {
96                return Err(SecretError::InvalidUri(format!(
97                    "unknown algorithm `{other}`; supported: ecdsa-p256, rsa-pss-2048"
98                )));
99            }
100        };
101
102        let key_id = parsed.path().to_owned();
103
104        let client = secretx_core::run_on_new_thread(
105            || async {
106                let config = aws_config::defaults(aws_config::BehaviorVersion::latest()).load().await;
107                Ok(aws_sdk_kms::Client::new(&config))
108            },
109            "aws-kms",
110        )?;
111
112        Ok(Self {
113            client: Arc::new(client),
114            key_id,
115            algorithm,
116        })
117    }
118}
119
120// ── ECDSA DER → raw conversion helpers ───────────────────────────────────────
121
122/// Returns true for KMS error codes that are transient (retry may succeed).
123///
124/// Covers throttling (rate limits), internal server errors, dependency
125/// timeouts, and temporarily unavailable keys.
126fn is_transient_kms_code(code: &str) -> bool {
127    matches!(
128        code,
129        "ThrottlingException"
130            | "RequestThrottledException"
131            | "KMSInternalException"
132            | "DependencyTimeoutException"
133            | "KeyUnavailableException"
134    )
135}
136
137/// Classify a KMS SDK error into the appropriate [`SecretError`].
138///
139/// `is_not_found` should return `true` when the service error is a
140/// `NotFoundException` (the concrete type varies per operation).
141fn classify_kms_sdk_error<E>(
142    sdk_err: aws_sdk_kms::error::SdkError<E>,
143    is_not_found: impl FnOnce(&E) -> bool,
144) -> SecretError
145where
146    E: std::error::Error + std::fmt::Display + aws_sdk_kms::error::ProvideErrorMetadata + Send + Sync + 'static,
147{
148    if let Some(svc) = sdk_err.as_service_error() {
149        if is_not_found(svc) {
150            return SecretError::NotFound;
151        }
152        let code = svc.meta().code().unwrap_or("");
153        if is_transient_kms_code(code) {
154            return SecretError::Unavailable {
155                backend: BACKEND,
156                source: svc.to_string().into(),
157            };
158        }
159        return SecretError::Backend {
160            backend: BACKEND,
161            source: svc.to_string().into(),
162        };
163    }
164    SecretError::Unavailable {
165        backend: BACKEND,
166        source: sdk_err.to_string().into(),
167    }
168}
169
170/// Convert a DER-encoded ECDSA-P256 signature to raw 64-byte (r||s) format.
171///
172/// AWS KMS Sign() returns DER for ECDSA algorithms.  The `SigningBackend::sign()`
173/// contract is raw (r||s) so callers can use consistent verification code across
174/// backends (local-signing and pkcs11 also return raw format).
175///
176/// DER structure: SEQUENCE { INTEGER r, INTEGER s } — each component is
177/// zero-padded to 32 bytes for P-256.
178fn ecdsa_der_to_raw_p256(der: &[u8]) -> Result<Vec<u8>, SecretError> {
179    let parse_err = |msg: &'static str| SecretError::Backend {
180        backend: BACKEND,
181        source: format!("ECDSA DER signature parse failed: {msg}").into(),
182    };
183
184    // SEQUENCE tag 0x30
185    let rest = der
186        .strip_prefix(&[0x30])
187        .ok_or_else(|| parse_err("expected SEQUENCE tag 0x30"))?;
188    let (seq_len, rest) = der_length(rest).ok_or_else(|| parse_err("invalid SEQUENCE length"))?;
189    if rest.len() < seq_len {
190        return Err(parse_err("SEQUENCE truncated"));
191    }
192    if rest.len() > seq_len {
193        return Err(parse_err("trailing bytes after SEQUENCE"));
194    }
195    let rest = &rest[..seq_len];
196
197    let (r, rest) = der_integer(rest).ok_or_else(|| parse_err("invalid INTEGER r"))?;
198    let (s, rest) = der_integer(rest).ok_or_else(|| parse_err("invalid INTEGER s"))?;
199    if !rest.is_empty() {
200        return Err(parse_err("trailing bytes inside SEQUENCE after INTEGER s"));
201    }
202
203    fn fixed32(n: &[u8]) -> Result<[u8; 32], &'static str> {
204        if n.len() > 32 {
205            return Err("integer component exceeds 32 bytes for P-256");
206        }
207        let mut out = [0u8; 32];
208        out[32 - n.len()..].copy_from_slice(n);
209        Ok(out)
210    }
211
212    let r32 = fixed32(r).map_err(parse_err)?;
213    let s32 = fixed32(s).map_err(parse_err)?;
214
215    Ok([r32, s32].concat())
216}
217
218/// Parse a DER length field. Returns `Some((length, remaining_bytes))`.
219///
220/// Enforces DER minimal-length encoding (X.690 §10.1): long form is only
221/// accepted when the value cannot be encoded in short form (>= 0x80).
222fn der_length(bytes: &[u8]) -> Option<(usize, &[u8])> {
223    let (&first, rest) = bytes.split_first()?;
224    if first < 0x80 {
225        Some((first as usize, rest))
226    } else {
227        let n = (first & 0x7f) as usize;
228        if n == 0 || n > 2 || rest.len() < n {
229            return None;
230        }
231        let mut len = 0usize;
232        for &b in &rest[..n] {
233            len = (len << 8) | (b as usize);
234        }
235        // DER requires minimal encoding: reject long-form for values
236        // that fit in short-form (< 0x80), and reject leading zero bytes.
237        if len < 0x80 {
238            return None; // should have used short form
239        }
240        if n == 2 && len < 0x100 {
241            return None; // should have used 1-byte long form
242        }
243        Some((len, &rest[n..]))
244    }
245}
246
247/// Parse a DER INTEGER tag-length-value. Returns `Some((integer_bytes, remaining))`.
248/// Strips the leading 0x00 sign-extension byte if present (DER positive integers).
249fn der_integer(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
250    let (&tag, rest) = bytes.split_first()?;
251    if tag != 0x02 {
252        return None;
253    }
254    let (len, rest) = der_length(rest)?;
255    if rest.len() < len {
256        return None;
257    }
258    let (value, rest) = rest.split_at(len);
259    // Strip leading 0x00 sign-extension (DER encodes positive integers with MSB set
260    // by prepending 0x00 to distinguish from negative values).
261    let value = value.strip_prefix(&[0x00]).unwrap_or(value);
262    Some((value, rest))
263}
264
265// ── SigningBackend impl ───────────────────────────────────────────────────────
266
267#[async_trait::async_trait]
268impl SigningBackend for AwsKmsBackend {
269    async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SecretError> {
270        let algo_spec = match self.algorithm {
271            SigningAlgorithm::EcdsaP256Sha256 => SigningAlgorithmSpec::EcdsaSha256,
272            SigningAlgorithm::RsaPss2048Sha256 => SigningAlgorithmSpec::RsassaPssSha256,
273            SigningAlgorithm::Ed25519 => {
274                // Ed25519 is not a KMS-supported signing algorithm; the URI was
275                // syntactically valid, so this is a capability error not a URI error.
276                // Backend (not Unavailable): retrying will not add Ed25519 support.
277                return Err(SecretError::Backend {
278                    backend: BACKEND,
279                    source: "Ed25519 is not supported by AWS KMS; use ecdsa-p256 or rsa-pss-2048"
280                        .into(),
281                });
282            }
283            // Non-exhaustive: new variants added in future minor versions will
284            // reach this arm.  Backend (not Unavailable): retrying will not help
285            // if this backend does not recognize the algorithm.
286            _ => {
287                return Err(SecretError::Backend {
288                    backend: BACKEND,
289                    source: format!("algorithm {:?} is not supported by AWS KMS", self.algorithm)
290                        .into(),
291                });
292            }
293        };
294
295        // Pre-hash the message with SHA-256 and send the digest with
296        // MessageType::Digest.  MessageType::Raw would hash internally but
297        // imposes a 4096-byte message size limit; MessageType::Digest carries
298        // no size limit and is semantically identical (both algorithms sign
299        // the SHA-256 hash of the message).
300        let digest: [u8; 32] = Sha256::digest(message).into();
301
302        let response = self
303            .client
304            .sign()
305            .key_id(&self.key_id)
306            .message(Blob::new(digest))
307            .message_type(MessageType::Digest)
308            .signing_algorithm(algo_spec)
309            .send()
310            .await
311            .map_err(|sdk_err| {
312                classify_kms_sdk_error(sdk_err, SignError::is_not_found_exception)
313            })?;
314
315        let sig_bytes = response
316            .signature
317            .ok_or_else(|| SecretError::Backend {
318                backend: BACKEND,
319                source: "KMS sign response contained no signature".into(),
320            })?
321            .into_inner();
322
323        // AWS KMS returns DER-encoded ECDSA signatures.  Convert to raw (r||s) so
324        // callers receive consistent 64-byte output regardless of which SigningBackend
325        // implementation they use (local-signing and pkcs11 return raw format).
326        // RSA-PSS signatures are already raw bytes — no conversion needed.
327        match self.algorithm {
328            SigningAlgorithm::EcdsaP256Sha256 => ecdsa_der_to_raw_p256(&sig_bytes),
329            _ => Ok(sig_bytes),
330        }
331    }
332
333    async fn public_key_der(&self) -> Result<Vec<u8>, SecretError> {
334        let response = self
335            .client
336            .get_public_key()
337            .key_id(&self.key_id)
338            .send()
339            .await
340            .map_err(|sdk_err| {
341                classify_kms_sdk_error(sdk_err, GetPublicKeyError::is_not_found_exception)
342            })?;
343
344        Ok(response
345            .public_key
346            .ok_or_else(|| SecretError::Backend {
347                backend: BACKEND,
348                source: "KMS get_public_key response contained no public key".into(),
349            })?
350            .into_inner())
351    }
352
353    fn algorithm(&self) -> Result<SigningAlgorithm, SecretError> {
354        Ok(self.algorithm)
355    }
356}
357
358// AwsKmsBackend intentionally does NOT implement SecretStore — it is a
359// signing-only backend.  The umbrella crate rejects aws-kms URIs via
360// from_uri with InvalidUri before any SecretStore method could be reached.
361
362inventory::submit!(secretx_core::SigningBackendRegistration::new(
363    "aws-kms",
364    |uri: &secretx_core::SecretUri| {
365        let b = AwsKmsBackend::from_parsed_uri(uri)?;
366        Ok(Arc::new(b) as Arc<dyn secretx_core::SigningBackend>)
367    },
368));
369
370// ── Tests ─────────────────────────────────────────────────────────────────────
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    // ── URI parsing (no AWS credentials required) ─────────────────────────────
377
378    #[test]
379    fn from_uri_wrong_backend() {
380        assert!(matches!(
381            AwsKmsBackend::from_uri("secretx:aws-sm:some-key"),
382            Err(SecretError::InvalidUri(_))
383        ));
384    }
385
386    #[test]
387    fn from_uri_missing_key_id() {
388        assert!(matches!(
389            AwsKmsBackend::from_uri("secretx:aws-kms:"),
390            Err(SecretError::InvalidUri(_))
391        ));
392    }
393
394    #[test]
395    fn from_uri_invalid_algorithm() {
396        assert!(matches!(
397            AwsKmsBackend::from_uri("secretx:aws-kms:alias/my-key?algorithm=elgamal"),
398            Err(SecretError::InvalidUri(_))
399        ));
400    }
401
402    // ── Integration tests ─────────────────────────────────────────────────────
403    // SECRETX_AWS_KMS_TEST_KEY_ID     — ECC_NIST_P256 key for ECDSA tests
404    // SECRETX_AWS_KMS_TEST_RSA_KEY_ID — RSA_2048 key for RSA-PSS tests
405
406    fn integration_key_id() -> Option<String> {
407        std::env::var("SECRETX_AWS_KMS_TEST_KEY_ID").ok()
408    }
409
410    fn integration_rsa_key_id() -> Option<String> {
411        std::env::var("SECRETX_AWS_KMS_TEST_RSA_KEY_ID").ok()
412    }
413
414    #[tokio::test]
415    async fn integration_sign_and_verify_ecdsa() {
416        let Some(key_id) = integration_key_id() else {
417            eprintln!("SECRETX_AWS_KMS_TEST_KEY_ID not set; skipping integration test");
418            return;
419        };
420
421        let uri = format!("secretx:aws-kms:{key_id}?algorithm=ecdsa-p256");
422        let backend = AwsKmsBackend::from_uri(&uri).expect("from_uri failed");
423        assert_eq!(
424            backend.algorithm().expect("algorithm"),
425            SigningAlgorithm::EcdsaP256Sha256
426        );
427
428        let message = b"hello from secretx-aws-kms integration test";
429        let sig_bytes = backend.sign(message).await.expect("sign failed");
430        assert_eq!(
431            sig_bytes.len(),
432            64,
433            "ECDSA P-256 raw signature must be 64 bytes"
434        );
435
436        let pub_der = backend
437            .public_key_der()
438            .await
439            .expect("public_key_der failed");
440        assert!(!pub_der.is_empty(), "public key DER must not be empty");
441
442        // Verify the signature against the public key to confirm ecdsa_der_to_raw_p256
443        // is producing a valid (r||s) encoding, not just non-empty bytes.
444        use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
445        use p256::pkcs8::DecodePublicKey;
446        let vk = VerifyingKey::from_public_key_der(&pub_der)
447            .expect("P-256 VerifyingKey from DER failed");
448        let sig = Signature::from_bytes(sig_bytes.as_slice().into())
449            .expect("P-256 Signature decode failed");
450        vk.verify(message, &sig)
451            .expect("P-256 signature verification failed");
452    }
453
454    #[tokio::test]
455    async fn integration_sign_and_verify_rsa_pss() {
456        let Some(key_id) = integration_rsa_key_id() else {
457            eprintln!("SECRETX_AWS_KMS_TEST_RSA_KEY_ID not set; skipping integration test");
458            return;
459        };
460
461        let uri = format!("secretx:aws-kms:{key_id}?algorithm=rsa-pss-2048");
462        let backend = AwsKmsBackend::from_uri(&uri).expect("from_uri failed");
463        assert_eq!(
464            backend.algorithm().expect("algorithm"),
465            SigningAlgorithm::RsaPss2048Sha256
466        );
467
468        let message = b"hello from secretx-aws-kms rsa-pss integration test";
469        let sig_bytes = backend.sign(message).await.expect("sign failed");
470        assert_eq!(
471            sig_bytes.len(),
472            256,
473            "RSA-2048 PSS signature must be 256 bytes"
474        );
475
476        let pub_der = backend
477            .public_key_der()
478            .await
479            .expect("public_key_der failed");
480        assert!(!pub_der.is_empty(), "public key DER must not be empty");
481
482        // Verify the signature against the public key to confirm the raw RSA-PSS
483        // bytes from KMS are a valid signature over the message.
484        use rsa::pkcs8::DecodePublicKey;
485        use rsa::pss::VerifyingKey;
486        use rsa::signature::Verifier;
487        let pub_key = rsa::RsaPublicKey::from_public_key_der(&pub_der)
488            .expect("RSA public key from DER failed");
489        let vk = VerifyingKey::<sha2::Sha256>::new(pub_key);
490        let sig = rsa::pss::Signature::try_from(sig_bytes.as_slice())
491            .expect("RSA-PSS Signature decode failed");
492        vk.verify(message, &sig)
493            .expect("RSA-PSS signature verification failed");
494    }
495
496    #[tokio::test]
497    async fn integration_not_found() {
498        let Some(_) = integration_key_id() else {
499            eprintln!("SECRETX_AWS_KMS_TEST_KEY_ID not set; skipping integration test");
500            return;
501        };
502
503        let uri = "secretx:aws-kms:alias/nonexistent-key-that-does-not-exist-secretx-test";
504        let backend = AwsKmsBackend::from_uri(uri).expect("from_uri failed");
505        let result = backend.sign(b"test").await;
506        assert!(
507            matches!(result, Err(SecretError::NotFound)),
508            "expected NotFound for nonexistent key, got: {result:?}"
509        );
510    }
511
512    #[tokio::test]
513    async fn integration_default_algorithm_is_ecdsa() {
514        let Some(key_id) = integration_key_id() else {
515            eprintln!("SECRETX_AWS_KMS_TEST_KEY_ID not set; skipping integration test");
516            return;
517        };
518
519        let uri = format!("secretx:aws-kms:{key_id}");
520        let backend = AwsKmsBackend::from_uri(&uri).expect("from_uri failed");
521        assert_eq!(
522            backend.algorithm().expect("algorithm"),
523            SigningAlgorithm::EcdsaP256Sha256,
524            "default algorithm must be EcdsaP256Sha256"
525        );
526    }
527
528    // ── DER → raw ECDSA conversion ────────────────────────────────────────────
529    // Oracle: a known P-256 DER signature decoded by hand.
530    // This DER byte sequence encodes r=0x01...(32 bytes) s=0x02...(32 bytes)
531    // with no leading zero (both r and s have MSB clear).
532    //
533    // DER: 30 44          # SEQUENCE, 68 bytes
534    //       02 20         # INTEGER r, 32 bytes
535    //         01 00 ... 00  (0x01 followed by 31 zero bytes)
536    //       02 20         # INTEGER s, 32 bytes
537    //         02 00 ... 00  (0x02 followed by 31 zero bytes)
538
539    #[test]
540    fn ecdsa_der_to_raw_p256_no_padding() {
541        // Construct a DER signature where r and s fit in exactly 32 bytes each.
542        let mut der = Vec::new();
543        let r: Vec<u8> = {
544            let mut v = vec![0u8; 32];
545            v[0] = 0x01;
546            v
547        };
548        let s: Vec<u8> = {
549            let mut v = vec![0u8; 32];
550            v[0] = 0x02;
551            v
552        };
553        // INTEGER r
554        let mut int_r = vec![0x02u8, 32];
555        int_r.extend_from_slice(&r);
556        // INTEGER s
557        let mut int_s = vec![0x02u8, 32];
558        int_s.extend_from_slice(&s);
559        // SEQUENCE
560        der.push(0x30);
561        der.push((int_r.len() + int_s.len()) as u8);
562        der.extend_from_slice(&int_r);
563        der.extend_from_slice(&int_s);
564
565        let raw = ecdsa_der_to_raw_p256(&der).expect("should parse");
566        assert_eq!(raw.len(), 64);
567        assert_eq!(&raw[..32], r.as_slice());
568        assert_eq!(&raw[32..], s.as_slice());
569    }
570
571    #[test]
572    fn ecdsa_der_to_raw_p256_with_sign_extension() {
573        // r has MSB set, so DER prefixes it with 0x00 (33-byte INTEGER).
574        // After stripping 0x00 and left-padding to 32 bytes, result is the same.
575        let mut der = Vec::new();
576        let r: Vec<u8> = {
577            let mut v = vec![0u8; 32];
578            v[0] = 0xFF;
579            v
580        }; // MSB set
581        let s: Vec<u8> = {
582            let mut v = vec![0u8; 32];
583            v[0] = 0x01;
584            v
585        };
586        // INTEGER r with sign extension
587        let mut int_r = vec![0x02u8, 33];
588        int_r.push(0x00); // sign extension
589        int_r.extend_from_slice(&r);
590        // INTEGER s (no extension needed)
591        let mut int_s = vec![0x02u8, 32];
592        int_s.extend_from_slice(&s);
593        // SEQUENCE
594        der.push(0x30);
595        der.push((int_r.len() + int_s.len()) as u8);
596        der.extend_from_slice(&int_r);
597        der.extend_from_slice(&int_s);
598
599        let raw = ecdsa_der_to_raw_p256(&der).expect("should parse");
600        assert_eq!(raw.len(), 64);
601        assert_eq!(&raw[..32], r.as_slice()); // 0xFF... recovered
602        assert_eq!(&raw[32..], s.as_slice());
603    }
604
605    #[test]
606    fn ecdsa_der_to_raw_p256_bad_tag_rejected() {
607        // Not a SEQUENCE (0x30), must fail.
608        let garbage = [0x31u8, 0x04, 0x02, 0x01, 0x01, 0x02, 0x01, 0x01];
609        assert!(matches!(
610            ecdsa_der_to_raw_p256(&garbage),
611            Err(SecretError::Backend { .. })
612        ));
613    }
614}