oxedyne/fe2o3/fe2o3_crypto/src/p256.rs
4.5 KiB, 1 run
created by r1870400018:50221, 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*, pure Rust and |
| 2 | //! wasm-clean. |
| 3 | //! |
| 4 | //! # Why this exists, and why it is verify only |
| 5 | //! |
| 6 | //! A browser or phone signs with a key it will not export: a WebCrypto |
| 7 | //! `CryptoKey` minted `extractable: false`, or a Secure Enclave / Android |
| 8 | //! Keystore handle. The key never crosses into wasm, so signing and key |
| 9 | //! generation stay on the host side of the boundary and are not offered here. |
| 10 | //! What a downloaded wasm client *does* need is to *check* a signature on-device, |
| 11 | //! rather than trust a server to have checked it, and that is the one operation |
| 12 | //! this module provides. |
| 13 | //! |
| 14 | //! The only other P-256 in Hematite is `fe2o3_net::ecdsa`, a thin wrapper over |
| 15 | //! `ring`, which is native only and cannot run in a browser. This module is its |
| 16 | //! wasm-reachable peer: it rests on the RustCrypto `p256` crate, pure Rust with |
| 17 | //! no `getrandom` on the verify path, and so compiles to |
| 18 | //! `wasm32-unknown-unknown`. It is gated behind the crate's `p256` feature and |
| 19 | //! is off by default, leaving a native build unchanged. |
| 20 | //! |
| 21 | //! # Accepted encodings |
| 22 | //! |
| 23 | //! These match exactly what WebCrypto emits, so the same bytes a browser puts on |
| 24 | //! the wire verify here without reshaping: |
| 25 | //! |
| 26 | //! - Public key: the 65-byte uncompressed SEC1 point `0x04 || X || Y`, as |
| 27 | //! `exportKey('raw')` yields for an ECDSA P-256 key. |
| 28 | //! - Signature: the 64-byte fixed-length `r || s` form (IEEE P1363), as |
| 29 | //! `crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' })` emits. |
| 30 | //! - Message: the raw bytes as signed, NOT a digest. SHA-256 is applied within, |
| 31 | //! via `fe2o3_hash`, so no second hasher is compiled onto this path. |
| 32 | //! |
| 33 | //! # What a false means |
| 34 | //! |
| 35 | //! A malformed input -- a key or signature of the wrong length, an off-curve or |
| 36 | //! identity point, an `r` or `s` outside `[1, n-1]` -- is a verification |
| 37 | //! *failure*, `Ok(false)`, not an error. That mirrors `fe2o3_net::ecdsa` and |
| 38 | //! lets a caller treat "this does not verify" uniformly, however the bytes went |
| 39 | //! wrong. Nothing here ever accepts what it cannot fully parse and check. |
| 40 | //! |
| 41 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 42 | //! Anthropic Claude |
| 43 | |
| 44 | use oxedyne_fe2o3_core::prelude::*; |
| 45 | use oxedyne_fe2o3_hash::sha256; |
| 46 | |
| 47 | // Leading `::` so the crate `p256` is meant, not this module of the same name. |
| 48 | use ::p256::ecdsa::{ |
| 49 | Signature, |
| 50 | VerifyingKey, |
| 51 | signature::hazmat::PrehashVerifier, |
| 52 | }; |
| 53 | |
| 54 | /// Length of an uncompressed SEC1 P-256 point, `0x04 || X || Y`. |
| 55 | pub const P256_POINT_LEN: usize = 65; |
| 56 | |
| 57 | /// Length of a raw `r || s` P-256 signature. |
| 58 | pub const P256_SIG_LEN: usize = 64; |
| 59 | |
| 60 | /// Verify a P-256 / SHA-256 signature in the encodings WebCrypto emits. |
| 61 | /// |
| 62 | /// `pubkey` is the 65-byte uncompressed SEC1 point, `sig` the 64-byte `r || s`, |
| 63 | /// and `msg` the raw message rather than a digest: SHA-256 is applied within, |
| 64 | /// matching WebCrypto's `hash: 'SHA-256'`. See the module header for how a |
| 65 | /// malformed input is reported. |
| 66 | pub fn verify_p256_sha256_fixed(pubkey: &[u8], msg: &[u8], sig: &[u8]) -> Outcome<bool> { |
| 67 | let digest = sha256::digest(msg); |
| 68 | verify_p256_prehashed(pubkey, &digest, sig) |
| 69 | } |
| 70 | |
| 71 | /// The prehash primitive underneath [`verify_p256_sha256_fixed`]: verify a |
| 72 | /// P-256 signature over a digest that has already been computed. |
| 73 | /// |
| 74 | /// `digest` is the message digest as a big-endian byte string. It need not be 32 |
| 75 | /// bytes: a digest shorter or longer than the curve's scalar field is reduced |
| 76 | /// per FIPS 186-4 / SEC1 (a short one is taken whole, a long one truncated to its |
| 77 | /// leftmost 256 bits), which is what lets a caller present, say, a SHA-1 or |
| 78 | /// SHA-512 digest and get the answer NIST's own vectors expect. The WebCrypto |
| 79 | /// path hands it a 32-byte SHA-256 digest. `pubkey` and `sig` are as for |
| 80 | /// [`verify_p256_sha256_fixed`]. |
| 81 | pub fn verify_p256_prehashed(pubkey: &[u8], digest: &[u8], sig: &[u8]) -> Outcome<bool> { |
| 82 | // A wrong width is a failure to verify, not an error; see the module header. |
| 83 | if pubkey.len() != P256_POINT_LEN { |
| 84 | return Ok(false); |
| 85 | } |
| 86 | if sig.len() != P256_SIG_LEN { |
| 87 | return Ok(false); |
| 88 | } |
| 89 | // An off-curve or identity point, or an out-of-range r or s, parses as a |
| 90 | // failure rather than propagating: the point never lands where a signature |
| 91 | // could hold. |
| 92 | let vk = match VerifyingKey::from_sec1_bytes(pubkey) { |
| 93 | Ok(vk) => vk, |
| 94 | Err(_) => return Ok(false), |
| 95 | }; |
| 96 | let signature = match Signature::from_slice(sig) { |
| 97 | Ok(sig) => sig, |
| 98 | Err(_) => return Ok(false), |
| 99 | }; |
| 100 | Ok(vk.verify_prehash(digest, &signature).is_ok()) |
| 101 | } |