Skip to main content

mockforge_platform_signing/
signer.rs

1//! [`PlatformSigner`] — backend-agnostic trait for the platform signing
2//! root.
3//!
4//! Backends keep the private key behind an HSM boundary (AWS KMS today,
5//! GCP KMS or a `YubiHSM` tomorrow) and only expose a `sign(...)`
6//! round-trip. All test fixtures use [`MockSigner`], which holds a
7//! software keypair in memory and is **not safe for production**.
8
9use async_trait::async_trait;
10use thiserror::Error;
11
12/// What signature algorithm a [`PlatformSigner`] produces.
13///
14/// AWS KMS does not support Ed25519, so the platform root uses ECDSA over
15/// NIST P-256 or P-384. P-256 is the default (smaller signatures, faster
16/// verify on the host fleet) — P-384 is available for higher-assurance
17/// deployments.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum SigningAlgorithm {
21    /// ECDSA over NIST P-256 with SHA-256. Default.
22    EcdsaSha256P256,
23    /// ECDSA over NIST P-384 with SHA-384. Higher-assurance opt-in.
24    EcdsaSha384P384,
25}
26
27impl SigningAlgorithm {
28    /// Stable wire-form for use inside [`crate::rotation::RotationEventPayload`].
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::EcdsaSha256P256 => "ecdsa-sha256-p256",
32            Self::EcdsaSha384P384 => "ecdsa-sha384-p384",
33        }
34    }
35}
36
37/// The platform signer abstraction. One instance corresponds to one HSM-
38/// hosted key.
39///
40/// Implementations MUST guarantee that the private key bytes never leave
41/// the HSM. Only [`PlatformSigner::sign`] crosses the boundary, and only
42/// the resulting signature comes back.
43#[async_trait]
44pub trait PlatformSigner: Send + Sync {
45    /// Opaque identifier the operator uses to refer to this key (e.g. a
46    /// KMS key ARN). Stable across signer instances.
47    fn key_id(&self) -> &str;
48
49    /// Signature algorithm this key produces.
50    fn algorithm(&self) -> SigningAlgorithm;
51
52    /// `SubjectPublicKeyInfo` (DER) for the key. Plugin-hosts use this
53    /// to verify signatures the signer produces.
54    async fn public_key_der(&self) -> Result<Vec<u8>, SignerError>;
55
56    /// Sign the given message. The returned bytes are the DER-encoded
57    /// ECDSA signature (matches what AWS KMS `Sign` returns when called
58    /// with `MessageType=RAW`).
59    async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SignerError>;
60}
61
62/// Errors a signer backend can produce.
63#[derive(Debug, Error)]
64pub enum SignerError {
65    /// The backend rejected the call (e.g. `AccessDenied`, `KeyDisabled`).
66    /// The string is the backend's own error message, surfaced as-is.
67    #[error("signer backend error: {0}")]
68    Backend(String),
69
70    /// The configured key id was empty or malformed.
71    #[error("invalid key id: {0}")]
72    InvalidKeyId(String),
73
74    /// Required environment variable missing.
75    #[error("missing environment variable: {0}")]
76    MissingEnv(&'static str),
77
78    /// The backend returned a public key in an unexpected encoding.
79    #[error("unexpected public-key encoding from backend: {0}")]
80    UnexpectedPublicKey(String),
81}
82
83/// Software-keypair signer for tests. **Never use in production** — the
84/// private bytes live in process memory, which defeats the entire point
85/// of this crate.
86///
87/// Uses ring's ECDSA implementation over NIST P-256 with SHA-256.
88pub struct MockSigner {
89    key_id: String,
90    keypair: ring::signature::EcdsaKeyPair,
91    public_key_der: Vec<u8>,
92    algorithm: SigningAlgorithm,
93}
94
95impl std::fmt::Debug for MockSigner {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("MockSigner")
98            .field("key_id", &self.key_id)
99            .field("algorithm", &self.algorithm)
100            .finish_non_exhaustive()
101    }
102}
103
104impl MockSigner {
105    /// Generate a fresh P-256 keypair labelled with `key_id`.
106    pub fn generate(key_id: impl Into<String>) -> Result<Self, SignerError> {
107        let rng = ring::rand::SystemRandom::new();
108        let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
109            &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING,
110            &rng,
111        )
112        .map_err(|e| SignerError::Backend(format!("ring pkcs8 generate failed: {e}")))?;
113        let keypair = ring::signature::EcdsaKeyPair::from_pkcs8(
114            &ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING,
115            pkcs8.as_ref(),
116            &rng,
117        )
118        .map_err(|e| SignerError::Backend(format!("ring keypair load failed: {e}")))?;
119        // ring returns the raw subject-public-key (uncompressed point);
120        // wrap it in a SubjectPublicKeyInfo so the verifier can use the
121        // same DER shape as the AWS KMS path.
122        let raw_pub = ring::signature::KeyPair::public_key(&keypair).as_ref().to_vec();
123        let public_key_der = wrap_p256_spki(&raw_pub);
124        Ok(Self {
125            key_id: key_id.into(),
126            keypair,
127            public_key_der,
128            algorithm: SigningAlgorithm::EcdsaSha256P256,
129        })
130    }
131}
132
133#[async_trait]
134impl PlatformSigner for MockSigner {
135    fn key_id(&self) -> &str {
136        &self.key_id
137    }
138
139    fn algorithm(&self) -> SigningAlgorithm {
140        self.algorithm
141    }
142
143    async fn public_key_der(&self) -> Result<Vec<u8>, SignerError> {
144        Ok(self.public_key_der.clone())
145    }
146
147    async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SignerError> {
148        let rng = ring::rand::SystemRandom::new();
149        let sig = self
150            .keypair
151            .sign(&rng, message)
152            .map_err(|e| SignerError::Backend(format!("ring sign failed: {e}")))?;
153        Ok(sig.as_ref().to_vec())
154    }
155}
156
157/// Wrap a raw P-256 uncompressed public-key point (65 bytes, leading 0x04)
158/// in a minimal `SubjectPublicKeyInfo` so the verifier sees the same DER
159/// shape the AWS KMS backend returns.
160fn wrap_p256_spki(raw_uncompressed_point: &[u8]) -> Vec<u8> {
161    // Hand-rolled DER builder — this is fixed-shape ASN.1 (RFC 5480
162    // §2.1.1) so a bespoke encoding is simpler than pulling in a full
163    // DER library just for one record.
164    //
165    // SEQUENCE {
166    //   SEQUENCE {            // AlgorithmIdentifier
167    //     OID 1.2.840.10045.2.1     // id-ecPublicKey
168    //     OID 1.2.840.10045.3.1.7   // secp256r1
169    //   }
170    //   BIT STRING { <unused = 0> || raw_uncompressed_point }
171    // }
172    const ALG_PREFIX: &[u8] = &[
173        0x30, 0x13, // SEQUENCE (AlgorithmIdentifier), len 19
174        0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID id-ecPublicKey
175        0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, // OID secp256r1
176    ];
177    let bitstring_len = 1 + raw_uncompressed_point.len(); // 1 = "unused bits" byte
178    let mut bitstring = Vec::with_capacity(2 + bitstring_len);
179    bitstring.push(0x03); // BIT STRING tag
180    bitstring.push(bitstring_len as u8);
181    bitstring.push(0x00); // 0 unused bits
182    bitstring.extend_from_slice(raw_uncompressed_point);
183    let body_len = ALG_PREFIX.len() + bitstring.len();
184    let mut out = Vec::with_capacity(2 + body_len);
185    out.push(0x30); // outer SEQUENCE
186    out.push(body_len as u8);
187    out.extend_from_slice(ALG_PREFIX);
188    out.extend_from_slice(&bitstring);
189    out
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[tokio::test]
197    async fn mock_signer_round_trips() {
198        let signer = MockSigner::generate("test-key-1").unwrap();
199        assert_eq!(signer.key_id(), "test-key-1");
200        assert_eq!(signer.algorithm(), SigningAlgorithm::EcdsaSha256P256);
201
202        let msg = b"hello mockforge";
203        let sig = signer.sign(msg).await.unwrap();
204        let pub_der = signer.public_key_der().await.unwrap();
205
206        // Verify with ring directly against the wrapped SPKI — the
207        // verifier module does the same thing for the rotation event.
208        // Strip the SPKI wrapping to get back the raw point ring expects.
209        let raw_point = extract_p256_point_from_spki(&pub_der).expect("valid spki");
210        let pubkey = ring::signature::UnparsedPublicKey::new(
211            &ring::signature::ECDSA_P256_SHA256_ASN1,
212            &raw_point,
213        );
214        pubkey.verify(msg, &sig).expect("signature should verify");
215    }
216
217    #[tokio::test]
218    async fn mock_signer_rejects_tampered_message() {
219        let signer = MockSigner::generate("test-key-2").unwrap();
220        let sig = signer.sign(b"original").await.unwrap();
221        let pub_der = signer.public_key_der().await.unwrap();
222        let raw_point = extract_p256_point_from_spki(&pub_der).unwrap();
223        let pubkey = ring::signature::UnparsedPublicKey::new(
224            &ring::signature::ECDSA_P256_SHA256_ASN1,
225            &raw_point,
226        );
227        assert!(pubkey.verify(b"tampered", &sig).is_err());
228    }
229
230    #[test]
231    fn signing_algorithm_wire_form_is_stable() {
232        // These strings ship over the wire inside RotationEventPayload;
233        // any change is a backwards-incompatible break of every
234        // plugin-host that's persisted a rotation event.
235        assert_eq!(SigningAlgorithm::EcdsaSha256P256.as_str(), "ecdsa-sha256-p256");
236        assert_eq!(SigningAlgorithm::EcdsaSha384P384.as_str(), "ecdsa-sha384-p384");
237    }
238
239    /// Pull the 65-byte uncompressed point back out of a minimal P-256
240    /// `SubjectPublicKeyInfo` so ring can verify against it.
241    fn extract_p256_point_from_spki(spki: &[u8]) -> Option<Vec<u8>> {
242        // The wrap_p256_spki layout is fixed; the raw point starts at
243        // a known offset. This is test-only — production verifier in
244        // `verifier.rs` does the same thing more carefully.
245        const HEADER_LEN: usize = 26; // outer SEQ(2) + AlgorithmIdentifier(21) + BIT STRING tag+len(2) + unused-bits(1)
246        if spki.len() <= HEADER_LEN {
247            return None;
248        }
249        Some(spki[HEADER_LEN..].to_vec())
250    }
251}