Skip to main content

mockforge_platform_signing/
verifier.rs

1//! Pure-Rust verifier for [`crate::rotation::RotationEvent`] manifests.
2//!
3//! Lives outside `rotation.rs` so plugin-host code that only needs to
4//! **verify** rotation events doesn't have to pull in the
5//! [`crate::signer::PlatformSigner`] trait or anything that depends on
6//! the AWS SDK feature.
7
8use base64::Engine;
9use chrono::Utc;
10use thiserror::Error;
11
12use crate::rotation::RotationEvent;
13use crate::signer::SigningAlgorithm;
14
15/// Verify a rotation event end-to-end:
16///
17/// 1. Reconstruct the canonical signed bytes (domain prefix + JCS payload).
18/// 2. Decode `from_public_key_b64` as a `SubjectPublicKeyInfo` and pull
19///    out the algorithm OID + raw point.
20/// 3. Verify the DER signature against the from-key's public bytes.
21/// 4. Optional clock sanity: refuse if `issued_at > now + 5 min` (clock
22///    skew) or `transition_until < issued_at` (malformed).
23///
24/// Returns `Ok(())` on success; any failure path returns a descriptive
25/// `VerifyError`. The caller is responsible for the trust decision
26/// **after** verification (i.e. "do I currently trust `from_key_id` as
27/// my platform root?" — that's the cache lookup, separate from
28/// crypto verification).
29pub fn verify_rotation_event(event: &RotationEvent) -> Result<(), VerifyError> {
30    let payload = &event.payload;
31
32    if payload.version != 1 {
33        return Err(VerifyError::UnsupportedVersion(payload.version));
34    }
35    if payload.transition_until <= payload.issued_at {
36        return Err(VerifyError::MalformedTimestamps);
37    }
38    // 5-minute skew tolerance — enough for clock drift between regions
39    // without letting a backdated event sneak in.
40    let now = Utc::now();
41    if payload.issued_at > now + chrono::Duration::minutes(5) {
42        return Err(VerifyError::FutureIssuedAt);
43    }
44
45    let signed_bytes = RotationEvent::signed_bytes(payload)
46        .map_err(|e| VerifyError::Reencoding(format!("{e}")))?;
47
48    let sig_der = base64::engine::general_purpose::STANDARD
49        .decode(event.handover_signature_b64.as_bytes())
50        .map_err(|e| VerifyError::InvalidSignatureBase64(e.to_string()))?;
51
52    let from_spki = base64::engine::general_purpose::STANDARD
53        .decode(payload.from_public_key_b64.as_bytes())
54        .map_err(|e| VerifyError::InvalidPublicKeyBase64(e.to_string()))?;
55
56    let raw_point = extract_p256_or_p384_point(&from_spki)?;
57    let alg: &dyn ring::signature::VerificationAlgorithm = match payload.from_algorithm {
58        SigningAlgorithm::EcdsaSha256P256 => &ring::signature::ECDSA_P256_SHA256_ASN1,
59        SigningAlgorithm::EcdsaSha384P384 => &ring::signature::ECDSA_P384_SHA384_ASN1,
60    };
61    let pubkey = ring::signature::UnparsedPublicKey::new(alg, raw_point);
62    pubkey
63        .verify(&signed_bytes, &sig_der)
64        .map_err(|e| VerifyError::SignatureMismatch(format!("{e}")))?;
65
66    Ok(())
67}
68
69/// Strip a `SubjectPublicKeyInfo` to its raw uncompressed point.
70///
71/// Tolerates both the P-256 (65-byte point) and P-384 (97-byte point)
72/// shapes that AWS KMS and [`crate::signer::MockSigner`] both produce.
73/// Does NOT validate the OID — the algorithm choice comes from the
74/// signed `from_algorithm` field. (An attacker can't lie about the
75/// algorithm without invalidating the signature.)
76fn extract_p256_or_p384_point(spki: &[u8]) -> Result<Vec<u8>, VerifyError> {
77    // Pure structural scan — find the BIT STRING and return everything
78    // after its "unused bits" byte. We don't trust the absolute length
79    // because AWS KMS may emit either P-256 (91-byte SPKI) or P-384
80    // (120-byte SPKI), and there's no harm in accepting both.
81    //
82    // SubjectPublicKeyInfo ::= SEQUENCE {
83    //   algorithm AlgorithmIdentifier,
84    //   subjectPublicKey BIT STRING
85    // }
86    if spki.len() < 4 || spki[0] != 0x30 {
87        return Err(VerifyError::MalformedPublicKey("not a SEQUENCE"));
88    }
89    let (_seq_len, after_seq_hdr) = read_der_length(&spki[1..])?;
90    // First inner element: AlgorithmIdentifier (a SEQUENCE) — skip it.
91    if after_seq_hdr.is_empty() || after_seq_hdr[0] != 0x30 {
92        return Err(VerifyError::MalformedPublicKey("missing AlgorithmIdentifier"));
93    }
94    let (alg_len, after_alg_hdr) = read_der_length(&after_seq_hdr[1..])?;
95    if after_alg_hdr.len() < alg_len {
96        return Err(VerifyError::MalformedPublicKey("truncated AlgorithmIdentifier"));
97    }
98    let after_alg = &after_alg_hdr[alg_len..];
99    // Next: BIT STRING.
100    if after_alg.is_empty() || after_alg[0] != 0x03 {
101        return Err(VerifyError::MalformedPublicKey("missing BIT STRING"));
102    }
103    let (bs_len, after_bs_hdr) = read_der_length(&after_alg[1..])?;
104    if after_bs_hdr.len() < bs_len || bs_len == 0 {
105        return Err(VerifyError::MalformedPublicKey("truncated BIT STRING"));
106    }
107    // First byte of a BIT STRING is the number of unused bits — must
108    // be zero for a key.
109    if after_bs_hdr[0] != 0 {
110        return Err(VerifyError::MalformedPublicKey("BIT STRING has unused bits"));
111    }
112    Ok(after_bs_hdr[1..bs_len].to_vec())
113}
114
115/// Read a DER length octet stream. Returns `(length, rest_after_length_bytes)`.
116fn read_der_length(input: &[u8]) -> Result<(usize, &[u8]), VerifyError> {
117    if input.is_empty() {
118        return Err(VerifyError::MalformedPublicKey("missing length octet"));
119    }
120    let first = input[0];
121    if first & 0x80 == 0 {
122        return Ok((first as usize, &input[1..]));
123    }
124    let n_octets = (first & 0x7F) as usize;
125    if n_octets == 0 || n_octets > 4 || input.len() < 1 + n_octets {
126        return Err(VerifyError::MalformedPublicKey("bad long-form length"));
127    }
128    let mut len = 0usize;
129    for &byte in &input[1..=n_octets] {
130        len = (len << 8) | byte as usize;
131    }
132    Ok((len, &input[1 + n_octets..]))
133}
134
135/// Reasons rotation-event verification can fail.
136#[derive(Debug, Error)]
137pub enum VerifyError {
138    /// Event uses a `version` this build doesn't understand.
139    #[error("unsupported rotation-event version: {0}")]
140    UnsupportedVersion(u32),
141
142    /// `transition_until <= issued_at`.
143    #[error("malformed timestamps: transition_until must be after issued_at")]
144    MalformedTimestamps,
145
146    /// `issued_at` is more than 5 minutes in the future.
147    #[error("issued_at is in the future beyond clock-skew tolerance")]
148    FutureIssuedAt,
149
150    /// Could not re-serialize the payload to canonical bytes.
151    #[error("re-encoding failed: {0}")]
152    Reencoding(String),
153
154    /// `handover_signature_b64` was not valid base64.
155    #[error("handover signature is not valid base64: {0}")]
156    InvalidSignatureBase64(String),
157
158    /// `from_public_key_b64` was not valid base64.
159    #[error("from public key is not valid base64: {0}")]
160    InvalidPublicKeyBase64(String),
161
162    /// SPKI parsing failed.
163    #[error("from public key is not a valid SubjectPublicKeyInfo: {0}")]
164    MalformedPublicKey(&'static str),
165
166    /// Ring rejected the signature.
167    #[error("handover signature did not verify against from public key: {0}")]
168    SignatureMismatch(String),
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::rotation::{RotationEvent, RotationEventPayload, RotationStateMachine};
175    use crate::signer::MockSigner;
176    use chrono::Duration;
177
178    #[tokio::test]
179    async fn happy_path_verifies() {
180        let cur = MockSigner::generate("from").unwrap();
181        let next = MockSigner::generate("to").unwrap();
182        let sm = RotationStateMachine::new(cur);
183        let event = sm.begin_handover(&next, Duration::days(30)).await.unwrap();
184        verify_rotation_event(&event).expect("verifies");
185    }
186
187    #[tokio::test]
188    async fn tampered_to_key_id_fails() {
189        let cur = MockSigner::generate("from").unwrap();
190        let next = MockSigner::generate("to").unwrap();
191        let sm = RotationStateMachine::new(cur);
192        let mut event = sm.begin_handover(&next, Duration::days(30)).await.unwrap();
193        event.payload.to_key_id = "attacker-key".into();
194        let err = verify_rotation_event(&event).unwrap_err();
195        assert!(matches!(err, VerifyError::SignatureMismatch(_)));
196    }
197
198    #[tokio::test]
199    async fn tampered_transition_until_fails() {
200        let cur = MockSigner::generate("from").unwrap();
201        let next = MockSigner::generate("to").unwrap();
202        let sm = RotationStateMachine::new(cur);
203        let mut event = sm.begin_handover(&next, Duration::days(30)).await.unwrap();
204        event.payload.transition_until += Duration::days(365);
205        let err = verify_rotation_event(&event).unwrap_err();
206        assert!(matches!(err, VerifyError::SignatureMismatch(_)));
207    }
208
209    #[tokio::test]
210    async fn replayed_signature_against_different_payload_fails() {
211        // Take a valid signature, paste it onto a fresh payload — must
212        // not verify. Confirms the domain prefix + JCS payload binding.
213        let cur = MockSigner::generate("from").unwrap();
214        let next1 = MockSigner::generate("to1").unwrap();
215        let sm = RotationStateMachine::new(cur);
216        let event1 = sm.begin_handover(&next1, Duration::days(30)).await.unwrap();
217        // Build a parallel event using `next2` but reuse event1's
218        // signature. We can't call `begin_handover` again (state
219        // machine refuses) so we hand-craft a payload.
220        let mut event2 = event1.clone();
221        event2.payload.to_key_id = "to2".into();
222        // Crucially, leave the signature from event1 in place.
223        let err = verify_rotation_event(&event2).unwrap_err();
224        assert!(matches!(err, VerifyError::SignatureMismatch(_)));
225    }
226
227    #[test]
228    fn rejects_version_mismatch() {
229        let payload = RotationEventPayload {
230            version: 99,
231            from_algorithm: SigningAlgorithm::EcdsaSha256P256,
232            from_key_id: "a".into(),
233            from_public_key_b64: "AAAA".into(),
234            to_algorithm: SigningAlgorithm::EcdsaSha256P256,
235            to_key_id: "b".into(),
236            to_public_key_b64: "BBBB".into(),
237            issued_at: Utc::now(),
238            transition_until: Utc::now() + Duration::days(30),
239        };
240        let event = RotationEvent {
241            payload,
242            handover_signature_b64: "AAAA".into(),
243        };
244        let err = verify_rotation_event(&event).unwrap_err();
245        assert!(matches!(err, VerifyError::UnsupportedVersion(99)));
246    }
247
248    #[test]
249    fn rejects_inverted_timestamps() {
250        let now = Utc::now();
251        let payload = RotationEventPayload {
252            version: 1,
253            from_algorithm: SigningAlgorithm::EcdsaSha256P256,
254            from_key_id: "a".into(),
255            from_public_key_b64: "AAAA".into(),
256            to_algorithm: SigningAlgorithm::EcdsaSha256P256,
257            to_key_id: "b".into(),
258            to_public_key_b64: "BBBB".into(),
259            issued_at: now,
260            transition_until: now - Duration::days(1),
261        };
262        let event = RotationEvent {
263            payload,
264            handover_signature_b64: "AAAA".into(),
265        };
266        let err = verify_rotation_event(&event).unwrap_err();
267        assert!(matches!(err, VerifyError::MalformedTimestamps));
268    }
269
270    #[test]
271    fn rejects_future_issued_at() {
272        let now = Utc::now();
273        let payload = RotationEventPayload {
274            version: 1,
275            from_algorithm: SigningAlgorithm::EcdsaSha256P256,
276            from_key_id: "a".into(),
277            from_public_key_b64: "AAAA".into(),
278            to_algorithm: SigningAlgorithm::EcdsaSha256P256,
279            to_key_id: "b".into(),
280            to_public_key_b64: "BBBB".into(),
281            issued_at: now + Duration::hours(1),
282            transition_until: now + Duration::days(30),
283        };
284        let event = RotationEvent {
285            payload,
286            handover_signature_b64: "AAAA".into(),
287        };
288        let err = verify_rotation_event(&event).unwrap_err();
289        assert!(matches!(err, VerifyError::FutureIssuedAt));
290    }
291}