Oregami
Repositories/oxedyne/fe2o3

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
29use oxedyne_fe2o3_core::prelude::*;
30
31use 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.
48pub 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.
60pub 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.
74pub 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
80impl 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)]
141mod 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}