oxedyne/fe2o3/fe2o3_net/src/ecdsa.rs
15.5 KiB, 46 runs
created by r1870400018:13303, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! ECDSA P-256 (NIST secp256r1) signature verification. |
| 2 | //! |
| 3 | //! A thin wrapper over `ring::signature` (already in Hematite's dependency |
| 4 | //! tree via this crate, and the same backend the ACME JWS signer uses) |
| 5 | //! exposing the one operation a downstream verifier needs: check a P-256 |
| 6 | //! signature over a message given a public key. |
| 7 | //! |
| 8 | //! The motivating caller is a payment gateway verifying signatures from |
| 9 | //! browser device keypairs. WebCrypto exposes Ed25519 on some engines but |
| 10 | //! not others; where it is missing the browser falls back to ECDSA over |
| 11 | //! P-256 with SHA-256. This function accepts exactly the encodings that |
| 12 | //! WebCrypto emits, so the gateway can verify both an Ed25519 signature |
| 13 | //! (via [`crate`]'s Ed25519 path) and a P-256 signature uniformly. |
| 14 | //! |
| 15 | //! Accepted encodings: |
| 16 | //! |
| 17 | //! - Public key: the 65-byte uncompressed SEC1 point `0x04 || X || Y`, as |
| 18 | //! produced by WebCrypto `exportKey('raw')` for an ECDSA P-256 key. |
| 19 | //! - Signature: the 64-byte fixed-length `r || s` form (IEEE P1363), which |
| 20 | //! is what WebCrypto `crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' })` |
| 21 | //! emits. This is `ring`'s `ECDSA_P256_SHA256_FIXED`. |
| 22 | //! - Message: the raw bytes as signed. It must NOT be pre-hashed -- |
| 23 | //! `ECDSA_P256_SHA256_FIXED` hashes the message with SHA-256 internally, |
| 24 | //! matching WebCrypto's `hash: 'SHA-256'`. |
| 25 | //! |
| 26 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 27 | //! Anthropic Claude |
| 28 | |
| 29 | use oxedyne_fe2o3_core::prelude::*; |
| 30 | |
| 31 | use ring::{ |
| 32 | rand::SystemRandom, |
| 33 | signature::{ |
| 34 | EcdsaKeyPair, |
| 35 | KeyPair, |
| 36 | UnparsedPublicKey, |
| 37 | ECDSA_P256_SHA256_ASN1, |
| 38 | ECDSA_P256_SHA256_FIXED, |
| 39 | ECDSA_P256_SHA256_FIXED_SIGNING, |
| 40 | }, |
| 41 | }; |
| 42 | |
| 43 | |
| 44 | /// `pubkey` is the 65-byte uncompressed SEC1 point, `sig` the 64-byte `r || s`, |
| 45 | /// and `msg` the raw message rather than a digest, SHA-256 being applied within. |
| 46 | /// A wrong length or an ill-formed point fails to verify rather than panicking, |
| 47 | /// `ring` reporting every malformed input as an ordinary verification failure. |
| 48 | pub fn verify_p256_sha256_fixed(pubkey: &[u8], msg: &[u8], sig: &[u8]) -> bool { |
| 49 | let key = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, pubkey); |
| 50 | key.verify(msg, sig).is_ok() |
| 51 | } |
| 52 | |
| 53 | /// The sibling of [`verify_p256_sha256_fixed`] for the ASN.1 DER signature form. |
| 54 | /// The key and message encodings are identical -- a 65-byte uncompressed SEC1 |
| 55 | /// point and a raw (un-hashed) message -- but `sig` is the DER `SEQUENCE { r, s }` |
| 56 | /// rather than the 64-byte `r || s`. This is the shape a WebAuthn/CTAP |
| 57 | /// authenticator emits for an ES256 assertion (COSE algorithm `-7`), where the |
| 58 | /// fixed form of a browser's own WebCrypto key does not apply. As above, any |
| 59 | /// malformed input fails to verify rather than panicking. |
| 60 | pub fn verify_p256_sha256_asn1(pubkey: &[u8], msg: &[u8], sig: &[u8]) -> bool { |
| 61 | let key = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey); |
| 62 | key.verify(msg, sig).is_ok() |
| 63 | } |
| 64 | |
| 65 | /// A P-256 key pair that signs in the encodings [`verify_p256_sha256_fixed`] accepts. |
| 66 | /// |
| 67 | /// The counterpart to the verifier above: where that checks what a browser produced, this produces |
| 68 | /// the same shapes from Rust -- a 65-byte uncompressed SEC1 public key and 64-byte `r || s` |
| 69 | /// signatures over SHA-256. A Rust client of a gateway that authenticates device keys needs it, and |
| 70 | /// so does any test that must present a real signature rather than a fixture. |
| 71 | /// |
| 72 | /// The PKCS#8 bytes are retained so the key can be written to disk and reloaded: `ring` consumes |
| 73 | /// them at load time and does not hand them back. |
| 74 | pub struct P256KeyPair { |
| 75 | pkcs8: Vec<u8>, // kept for persistence |
| 76 | key_pair: EcdsaKeyPair, // ring's live key pair |
| 77 | rng: SystemRandom, // ring wants one per signature |
| 78 | } |
| 79 | |
| 80 | impl P256KeyPair { |
| 81 | pub fn generate() -> Outcome<Self> { |
| 82 | let rng = SystemRandom::new(); |
| 83 | let pkcs8 = match EcdsaKeyPair::generate_pkcs8( |
| 84 | &ECDSA_P256_SHA256_FIXED_SIGNING, |
| 85 | &rng, |
| 86 | ) { |
| 87 | Ok(doc) => doc.as_ref().to_vec(), |
| 88 | Err(e) => return Err(err!( |
| 89 | "ring could not generate a P-256 PKCS#8 document: {}.", e; |
| 90 | Init, Unknown)), |
| 91 | }; |
| 92 | let key_pair = res!(Self::load_pair(&pkcs8, &rng)); |
| 93 | Ok(Self { pkcs8, key_pair, rng }) |
| 94 | } |
| 95 | |
| 96 | pub fn from_pkcs8(pkcs8: &[u8]) -> Outcome<Self> { |
| 97 | let rng = SystemRandom::new(); |
| 98 | let key_pair = res!(Self::load_pair(pkcs8, &rng)); |
| 99 | Ok(Self { |
| 100 | pkcs8: pkcs8.to_vec(), |
| 101 | key_pair, |
| 102 | rng, |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | /// The bytes round-trip through [`P256KeyPair::from_pkcs8`]. |
| 107 | pub fn pkcs8_bytes(&self) -> &[u8] { |
| 108 | &self.pkcs8 |
| 109 | } |
| 110 | |
| 111 | /// The public key as the 65-byte uncompressed SEC1 point `0x04 || X || Y`, the same encoding |
| 112 | /// WebCrypto `exportKey('raw')` yields and [`verify_p256_sha256_fixed`] expects. |
| 113 | pub fn public_key(&self) -> Vec<u8> { |
| 114 | self.key_pair.public_key().as_ref().to_vec() |
| 115 | } |
| 116 | |
| 117 | /// The 64-byte fixed-length `r || s` form. `msg` is the raw message, not a |
| 118 | /// digest: SHA-256 is applied within, matching WebCrypto's |
| 119 | /// `sign({ name: 'ECDSA', hash: 'SHA-256' })`. |
| 120 | pub fn sign(&self, msg: &[u8]) -> Outcome<Vec<u8>> { |
| 121 | match self.key_pair.sign(&self.rng, msg) { |
| 122 | Ok(sig) => Ok(sig.as_ref().to_vec()), |
| 123 | Err(e) => Err(err!( |
| 124 | "ring could not produce a P-256 signature: {}.", e; |
| 125 | Unknown)), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | fn load_pair(pkcs8: &[u8], rng: &SystemRandom) -> Outcome<EcdsaKeyPair> { |
| 130 | match EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8, rng) { |
| 131 | Ok(kp) => Ok(kp), |
| 132 | Err(e) => Err(err!( |
| 133 | "ring rejected the supplied P-256 PKCS#8 bytes: {}.", e; |
| 134 | Init, Invalid, Input)), |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | |
| 140 | #[cfg(test)] |
| 141 | mod tests { |
| 142 | use super::*; |
| 143 | |
| 144 | use crate::acme::jose::base64url_encode; |
| 145 | |
| 146 | use ring::{ |
| 147 | rand::SystemRandom, |
| 148 | signature::{ |
| 149 | EcdsaKeyPair, |
| 150 | KeyPair, |
| 151 | ECDSA_P256_SHA256_FIXED_SIGNING, |
| 152 | }, |
| 153 | }; |
| 154 | |
| 155 | /// Round-trip a self-consistent vector generated with `ring`: create a |
| 156 | /// P-256 key pair, sign a message, export the raw (65-byte uncompressed) |
| 157 | /// public key and the 64-byte fixed signature, then verify. A tampered |
| 158 | /// signature, message and key must all be rejected, and wrong-length |
| 159 | /// inputs must fail gracefully rather than panic. |
| 160 | #[test] |
| 161 | fn test_p256_verify_round_trip() -> Outcome<()> { |
| 162 | let rng = SystemRandom::new(); |
| 163 | |
| 164 | // Fresh P-256 key pair. |
| 165 | let pkcs8 = match EcdsaKeyPair::generate_pkcs8( |
| 166 | &ECDSA_P256_SHA256_FIXED_SIGNING, |
| 167 | &rng, |
| 168 | ) { |
| 169 | Ok(doc) => doc, |
| 170 | Err(e) => return Err(err!( |
| 171 | "ring failed to generate a P-256 PKCS#8 document: {}.", e; |
| 172 | Test, Init)), |
| 173 | }; |
| 174 | let key_pair = match EcdsaKeyPair::from_pkcs8( |
| 175 | &ECDSA_P256_SHA256_FIXED_SIGNING, |
| 176 | pkcs8.as_ref(), |
| 177 | &rng, |
| 178 | ) { |
| 179 | Ok(kp) => kp, |
| 180 | Err(e) => return Err(err!( |
| 181 | "ring rejected its own freshly-generated P-256 PKCS#8: {}.", e; |
| 182 | Test, Init)), |
| 183 | }; |
| 184 | |
| 185 | // The raw public key is the 65-byte uncompressed SEC1 point, exactly |
| 186 | // what WebCrypto exportKey('raw') yields. |
| 187 | let pubkey = key_pair.public_key().as_ref().to_vec(); |
| 188 | assert_eq!(pubkey.len(), 65, "P-256 raw public key must be 65 bytes"); |
| 189 | assert_eq!(pubkey[0], 0x04, "uncompressed SEC1 point must start with 0x04"); |
| 190 | |
| 191 | // Sign a message. ring's FIXED variant hashes with SHA-256 internally |
| 192 | // and emits the 64-byte r || s form. |
| 193 | let msg = b"payment gateway device-key challenge"; |
| 194 | let sig = match key_pair.sign(&rng, msg) { |
| 195 | Ok(s) => s.as_ref().to_vec(), |
| 196 | Err(e) => return Err(err!( |
| 197 | "ring failed to sign the P-256 test message: {}.", e; |
| 198 | Test, Data)), |
| 199 | }; |
| 200 | assert_eq!(sig.len(), 64, "P-256 fixed signature must be 64 bytes"); |
| 201 | |
| 202 | // A valid signature verifies. |
| 203 | assert!(verify_p256_sha256_fixed(&pubkey, msg, &sig), |
| 204 | "verify should accept a valid P-256 signature"); |
| 205 | |
| 206 | // A tampered signature is rejected. |
| 207 | let mut bad_sig = sig.clone(); |
| 208 | bad_sig[0] ^= 0x01; |
| 209 | assert!(!verify_p256_sha256_fixed(&pubkey, msg, &bad_sig), |
| 210 | "verify should reject a tampered signature"); |
| 211 | |
| 212 | // A tampered message is rejected. |
| 213 | let mut bad_msg = msg.to_vec(); |
| 214 | bad_msg[0] ^= 0x01; |
| 215 | assert!(!verify_p256_sha256_fixed(&pubkey, &bad_msg, &sig), |
| 216 | "verify should reject a tampered message"); |
| 217 | |
| 218 | // A tampered public key is rejected. |
| 219 | let mut bad_key = pubkey.clone(); |
| 220 | bad_key[1] ^= 0x01; // Perturb the X coordinate, keep the 0x04 tag. |
| 221 | assert!(!verify_p256_sha256_fixed(&bad_key, msg, &sig), |
| 222 | "verify should reject a wrong public key"); |
| 223 | |
| 224 | // Wrong-length inputs must fail gracefully, not panic. |
| 225 | assert!(!verify_p256_sha256_fixed(&pubkey[..64], msg, &sig), |
| 226 | "verify should reject a short public key"); |
| 227 | assert!(!verify_p256_sha256_fixed(&pubkey, msg, &sig[..63]), |
| 228 | "verify should reject a short signature"); |
| 229 | assert!(!verify_p256_sha256_fixed(&[], msg, &sig), |
| 230 | "verify should reject an empty public key"); |
| 231 | |
| 232 | Ok(()) |
| 233 | } |
| 234 | |
| 235 | /// The ASN.1 sibling verifies what `ring`'s DER signer produces, in the shape a WebAuthn |
| 236 | /// authenticator emits: a 65-byte SEC1 key, a raw message, and a DER `SEQUENCE { r, s }` |
| 237 | /// signature. A tampered signature, message and key must all be rejected, the fixed-form |
| 238 | /// verifier must NOT accept a DER signature (the two encodings are distinct), and |
| 239 | /// wrong-length inputs must fail gracefully rather than panic. |
| 240 | #[test] |
| 241 | fn test_p256_verify_asn1_round_trip() -> Outcome<()> { |
| 242 | use ring::signature::ECDSA_P256_SHA256_ASN1_SIGNING; |
| 243 | |
| 244 | let rng = SystemRandom::new(); |
| 245 | |
| 246 | let pkcs8 = match EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng) { |
| 247 | Ok(doc) => doc, |
| 248 | Err(e) => return Err(err!( |
| 249 | "ring failed to generate a P-256 ASN.1 PKCS#8 document: {}.", e; Test, Init)), |
| 250 | }; |
| 251 | let key_pair = match EcdsaKeyPair::from_pkcs8( |
| 252 | &ECDSA_P256_SHA256_ASN1_SIGNING, |
| 253 | pkcs8.as_ref(), |
| 254 | &rng, |
| 255 | ) { |
| 256 | Ok(kp) => kp, |
| 257 | Err(e) => return Err(err!( |
| 258 | "ring rejected its own freshly-generated ASN.1 PKCS#8: {}.", e; Test, Init)), |
| 259 | }; |
| 260 | |
| 261 | let pubkey = key_pair.public_key().as_ref().to_vec(); |
| 262 | assert_eq!(pubkey.len(), 65, "P-256 raw public key must be 65 bytes"); |
| 263 | assert_eq!(pubkey[0], 0x04, "uncompressed SEC1 point must start with 0x04"); |
| 264 | |
| 265 | // The ASN.1 signer emits a DER SEQUENCE, variable length (~70-72 bytes), |
| 266 | // never the fixed 64. |
| 267 | let msg = b"webauthn.get assertion over authenticatorData || SHA-256(clientDataJSON)"; |
| 268 | let sig = match key_pair.sign(&rng, msg) { |
| 269 | Ok(s) => s.as_ref().to_vec(), |
| 270 | Err(e) => return Err(err!( |
| 271 | "ring failed to sign the P-256 ASN.1 test message: {}.", e; Test, Data)), |
| 272 | }; |
| 273 | assert_ne!(sig.len(), 64, "the DER form is not the 64-byte fixed form"); |
| 274 | assert_eq!(sig[0], 0x30, "a DER SEQUENCE begins with the 0x30 tag"); |
| 275 | |
| 276 | // A valid DER signature verifies under the ASN.1 verifier. |
| 277 | assert!(verify_p256_sha256_asn1(&pubkey, msg, &sig), |
| 278 | "asn1 verify should accept a valid DER signature"); |
| 279 | |
| 280 | // The fixed-form verifier must not accept a DER signature: the encodings |
| 281 | // are distinct and must not be interchangeable. |
| 282 | assert!(!verify_p256_sha256_fixed(&pubkey, msg, &sig), |
| 283 | "the fixed verifier must reject a DER-encoded signature"); |
| 284 | |
| 285 | // A tampered signature is rejected (perturb r, past the DER header). |
| 286 | let mut bad_sig = sig.clone(); |
| 287 | let last = bad_sig.len() - 1; |
| 288 | bad_sig[last] ^= 0x01; |
| 289 | assert!(!verify_p256_sha256_asn1(&pubkey, msg, &bad_sig), |
| 290 | "asn1 verify should reject a tampered signature"); |
| 291 | |
| 292 | // A tampered message is rejected. |
| 293 | let mut bad_msg = msg.to_vec(); |
| 294 | bad_msg[0] ^= 0x01; |
| 295 | assert!(!verify_p256_sha256_asn1(&pubkey, &bad_msg, &sig), |
| 296 | "asn1 verify should reject a tampered message"); |
| 297 | |
| 298 | // A tampered public key is rejected. |
| 299 | let mut bad_key = pubkey.clone(); |
| 300 | bad_key[1] ^= 0x01; |
| 301 | assert!(!verify_p256_sha256_asn1(&bad_key, msg, &sig), |
| 302 | "asn1 verify should reject a wrong public key"); |
| 303 | |
| 304 | // Wrong-length and empty inputs must fail gracefully, not panic. |
| 305 | assert!(!verify_p256_sha256_asn1(&pubkey[..64], msg, &sig), |
| 306 | "asn1 verify should reject a short public key"); |
| 307 | assert!(!verify_p256_sha256_asn1(&pubkey, msg, &[]), |
| 308 | "asn1 verify should reject an empty signature"); |
| 309 | assert!(!verify_p256_sha256_asn1(&pubkey, msg, &sig[..2]), |
| 310 | "asn1 verify should reject a truncated DER signature"); |
| 311 | |
| 312 | Ok(()) |
| 313 | } |
| 314 | |
| 315 | /// A loaded key's public point must be the one that is actually in the DER. The expected `x` |
| 316 | /// and `y` below were derived from the same DER by `openssl` (see the documentation on |
| 317 | /// `TEST_P256_PKCS8`), so this checks the loader against a tool that is not us. |
| 318 | #[test] |
| 319 | fn test_p256_keypair_public_key_matches_openssl_00() -> Outcome<()> { |
| 320 | let kp = res!(P256KeyPair::from_pkcs8(&crate::acme::jose::TEST_P256_PKCS8)); |
| 321 | let pk = kp.public_key(); |
| 322 | assert_eq!(pk.len(), 65); |
| 323 | assert_eq!(pk[0], 0x04); |
| 324 | assert_eq!( |
| 325 | base64url_encode(&pk[1..33]), |
| 326 | "cMAYIYJu7A2aNTTrurSWBFMwr8uyVRYGvrrgsUz8I6Q", |
| 327 | ); |
| 328 | assert_eq!( |
| 329 | base64url_encode(&pk[33..65]), |
| 330 | "Ktqy2hcvjIy_FofO47MfWeHLgjN7Vdxw0Bp2MRQyG8Y", |
| 331 | ); |
| 332 | Ok(()) |
| 333 | } |
| 334 | |
| 335 | /// What this crate signs, this crate verifies -- in the encodings a browser uses. The signature |
| 336 | /// must be the 64-byte fixed form, and a tampered message must fail. |
| 337 | #[test] |
| 338 | fn test_p256_keypair_sign_verifies_00() -> Outcome<()> { |
| 339 | let kp = res!(P256KeyPair::generate()); |
| 340 | let msg = b"verify:sid-of-the-oxedation"; |
| 341 | let sig = res!(kp.sign(msg)); |
| 342 | assert_eq!(sig.len(), 64, "the fixed form is 64 bytes of r || s"); |
| 343 | assert!(verify_p256_sha256_fixed(&kp.public_key(), msg, &sig), |
| 344 | "a freshly-signed message must verify"); |
| 345 | assert!(!verify_p256_sha256_fixed(&kp.public_key(), b"verify:another-sid", &sig), |
| 346 | "the signature must not verify over a different message"); |
| 347 | Ok(()) |
| 348 | } |
| 349 | |
| 350 | /// A key written to disk and read back is the same key: same public point, and signatures made |
| 351 | /// after the reload still verify. |
| 352 | #[test] |
| 353 | fn test_p256_keypair_pkcs8_round_trip_00() -> Outcome<()> { |
| 354 | let kp = res!(P256KeyPair::generate()); |
| 355 | let reloaded = res!(P256KeyPair::from_pkcs8(kp.pkcs8_bytes())); |
| 356 | assert_eq!(kp.public_key(), reloaded.public_key()); |
| 357 | let msg = b"a message signed after the reload"; |
| 358 | let sig = res!(reloaded.sign(msg)); |
| 359 | assert!(verify_p256_sha256_fixed(&kp.public_key(), msg, &sig), |
| 360 | "the reloaded key must be the same key"); |
| 361 | Ok(()) |
| 362 | } |
| 363 | |
| 364 | /// Bytes that are not a P-256 PKCS#8 document must be refused, with an error rather than a |
| 365 | /// panic. |
| 366 | #[test] |
| 367 | fn test_p256_keypair_rejects_junk_pkcs8_00() -> Outcome<()> { |
| 368 | assert!(P256KeyPair::from_pkcs8(b"not a key at all").is_err(), |
| 369 | "junk PKCS#8 must be refused"); |
| 370 | Ok(()) |
| 371 | } |
| 372 | } |