diff --git a/crates/crypto/src/blst_impl.rs b/crates/crypto/src/blst_impl.rs index a37f4f9c..62832f65 100644 --- a/crates/crypto/src/blst_impl.rs +++ b/crates/crypto/src/blst_impl.rs @@ -16,12 +16,23 @@ use zeroize::Zeroizing; use crate::{ tbls::Tbls, - types::{BlsError, Error, Index, PrivateKey, PublicKey, Signature}, + types::{BlsError, Error, Index, PrivateKey, PublicKey, SIGNATURE_LENGTH, Signature}, }; /// Domain Separation Tag for Ethereum 2.0 BLS signatures const ETH2_DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; +/// Serialized BLS12-381 G2 compressed point at infinity (the identity +/// signature). This is the value Charon's Herumi `Aggregate` returns for an +/// empty input slice: `sig.Serialize()` of a zero `bls.Sign`. The high byte +/// sets the compression bit (`0x80`) and the infinity bit (`0x40`); all other +/// bytes are zero. +const IDENTITY_SIGNATURE: Signature = { + let mut s = [0u8; SIGNATURE_LENGTH]; + s[0] = 0xc0; + s +}; + /// BLST implementation of threshold BLS signatures. /// /// This implementation is compatible with the Herumi BLS library used in @@ -60,7 +71,8 @@ impl Tbls for BlstImpl { } fn secret_to_public_key(&self, secret_key: &PrivateKey) -> Result { - let sk = BlstSecretKey::from_bytes(secret_key)?; + let sk = + BlstSecretKey::from_bytes(secret_key).map_err(|e| Error::InvalidSecretKey(e.into()))?; let pk = sk.sk_to_pk(); Ok(pk.to_bytes()) } @@ -72,18 +84,29 @@ impl Tbls for BlstImpl { threshold: Index, mut rng: impl RngCore + CryptoRng, ) -> Result, Error> { + // Charon's Herumi backend only rejects `threshold <= 1` + // (see charon/tbls/herumi.go ThresholdSplit @ v1.7.1). We additionally + // reject `threshold > total`: such a (t, n) scheme is unrecoverable and + // is always a programming error. No Charon call site passes t > n, so + // this hardening never rejects an otherwise-valid split. if threshold <= 1 || threshold > total { return Err(Error::InvalidThreshold { threshold, total }); } - let threashlod = - usize::try_from(threshold).map_err(|_| Error::InvalidThreshold { threshold, total })?; - let sk = BlstSecretKey::from_bytes(secret_key)?; + // `threshold` is bounded above by `total` here; the conversion is + // infallible on 64-bit targets and only fails on 32-bit targets for an + // implausibly large `total`. Map that to a dedicated overflow error + // rather than re-using InvalidThreshold (the value is in range). + let threshold_usize = + usize::try_from(threshold).map_err(|_| Error::ThresholdOverflow { threshold })?; + + let sk = + BlstSecretKey::from_bytes(secret_key).map_err(|e| Error::InvalidSecretKey(e.into()))?; // Create polynomial coefficients: a_0 = secret, a_1..a_{t-1} = random. // `poly` holds `BlstSecretKey`s, each `#[zeroize(drop)]`, so the secret // coefficients are wiped when `poly` is dropped. - let mut poly = Vec::with_capacity(threashlod); + let mut poly = Vec::with_capacity(threshold_usize); poly.push(sk); for _ in 1..threshold { @@ -138,15 +161,17 @@ impl Tbls for BlstImpl { } fn aggregate(&self, signatures: &[Signature]) -> Result { + // Parity with Charon Herumi `Aggregate` (tbls/herumi.go:227): an empty + // input is NOT an error. Herumi aggregates into a zero `bls.Sign` and + // returns its serialized form, i.e. the G2 compressed point at infinity. if signatures.is_empty() { - return Err(Error::EmptySignatureArray); + return Ok(IDENTITY_SIGNATURE); } - if signatures.len() == 1 { - return Ok(signatures[0]); - } - - // Use blst's aggregation + // Deserialize every input signature (matches the Herumi loop, which + // errors if any element fails to deserialize). Note: aggregation + // canonicalizes the output even for a single input (Herumi returns + // `sig.Serialize()`, never the input bytes verbatim). let parsed_sigs: Vec = signatures .iter() .map(|sig_bytes| { @@ -207,7 +232,8 @@ impl Tbls for BlstImpl { } fn sign(&self, private_key: &PrivateKey, data: &[u8]) -> Result { - let sk = BlstSecretKey::from_bytes(private_key)?; + let sk = BlstSecretKey::from_bytes(private_key) + .map_err(|e| Error::InvalidSecretKey(e.into()))?; let sig = sk.sign(data, ETH2_DST, &[]); Ok(sig.to_bytes()) } @@ -802,7 +828,7 @@ mod tests { } #[test] - fn aggregate_single_signature() { + fn aggregate_single_signature_is_canonical() { use rand::rngs::OsRng; let blst = setup(); @@ -811,11 +837,24 @@ mod tests { let sk = blst.generate_secret_key(OsRng).unwrap(); let sig = blst.sign(&sk, data).unwrap(); + // Charon Herumi Aggregate deserializes+re-serializes even for one element, + // so the output is the canonical encoding of the parsed point (not the + // input bytes verbatim). For a signature produced by `sign` these coincide. let aggregated = blst.aggregate(&[sig]).unwrap(); - assert_eq!( - sig, aggregated, - "Aggregating single signature should return the same signature" - ); + assert_eq!(sig, aggregated); + + // Canonical round-trip: re-serializing the parsed aggregate is idempotent. + let reparsed = BlstSignature::from_bytes(&aggregated).unwrap(); + assert_eq!(aggregated, reparsed.to_bytes()); + } + + #[test] + fn aggregate_single_malformed_signature_errors() { + let blst = setup(); + // All-zero 96 bytes is not a valid compressed signature; Herumi's + // Deserialize fails, so Aggregate returns an error. + let bad = [0u8; 96]; + assert!(blst.aggregate(&[bad]).is_err()); } #[test] @@ -868,12 +907,73 @@ mod tests { let sk = blst.generate_secret_key(OsRng).unwrap(); // Threshold of 1 is invalid - let result = blst.threshold_split(&sk, 5, 1); - assert!(result.is_err(), "Threshold of 1 should be rejected"); + let err = blst.threshold_split(&sk, 5, 1).unwrap_err(); + assert!(matches!( + err, + Error::InvalidThreshold { + threshold: 1, + total: 5 + } + )); // Threshold greater than total is invalid - let result = blst.threshold_split(&sk, 3, 5); - assert!(result.is_err(), "Threshold > total should be rejected"); + let err = blst.threshold_split(&sk, 3, 5).unwrap_err(); + assert!(matches!( + err, + Error::InvalidThreshold { + threshold: 5, + total: 3 + } + )); + + // threshold == 0 is also rejected (<= 1) + let err = blst.threshold_split(&sk, 5, 0).unwrap_err(); + assert!(matches!( + err, + Error::InvalidThreshold { + threshold: 0, + total: 5 + } + )); + } + + #[test] + fn threshold_equal_total_is_valid() { + let blst = setup(); + let sk = blst.generate_secret_key(rand::rngs::OsRng).unwrap(); + // threshold == total is a valid (n, n) scheme. + let shares = blst.threshold_split(&sk, 4, 4).unwrap(); + assert_eq!(shares.len(), 4); + let recovered = blst.recover_secret(&shares).unwrap(); + assert_eq!(sk, recovered); + } + + #[test] + fn invalid_secret_key_error_is_consistent() { + let blst = setup(); + // All-0xff bytes are >= the scalar field order => from_bytes fails. + let bad: PrivateKey = [0xff; 32]; + + assert!(matches!( + blst.secret_to_public_key(&bad), + Err(Error::InvalidSecretKey(_)) + )); + assert!(matches!( + blst.sign(&bad, b"data"), + Err(Error::InvalidSecretKey(_)) + )); + assert!(matches!( + blst.threshold_split(&bad, 5, 3), + Err(Error::InvalidSecretKey(_)) + )); + + let mut shares = HashMap::new(); + shares.insert(1u64, bad); + shares.insert(2u64, bad); + assert!(matches!( + blst.recover_secret(&shares), + Err(Error::InvalidSecretKey(_)) + )); } #[test] @@ -914,16 +1014,35 @@ mod tests { } #[test] - fn empty_aggregate_fails() { + fn aggregate_empty_returns_identity_signature() { let blst = setup(); - let result = blst.aggregate(&[]); - assert!( - result.is_err(), - "Aggregating empty signature list should fail" + // Parity with Charon Herumi Aggregate: empty input is NOT an error; it + // returns the serialized G2 point at infinity. Fixture: `0xc0` followed by + // 95 zero bytes (Herumi `sig.Serialize()` of a zero `bls.Sign`). + let agg = blst + .aggregate(&[]) + .expect("empty aggregate must not error (Herumi parity)"); + + let mut expected = [0u8; 96]; + expected[0] = 0xc0; + assert_eq!( + agg, expected, + "empty aggregate must equal the serialized identity signature" ); } + #[test] + fn identity_signature_matches_go_fixture() { + // Hex of Herumi `bls.Sign{}.Serialize()` for the BLS12-381 G2 compressed + // point at infinity (eth2/ZCash compressed encoding): `c0` followed by + // 190 hex zeros (96 bytes total). + let go_fixture_hex = format!("c0{}", "0".repeat(190)); + let go_fixture = hex::decode(go_fixture_hex).unwrap(); + assert_eq!(go_fixture.len(), 96); + assert_eq!(&IDENTITY_SIGNATURE[..], &go_fixture[..]); + } + #[test] fn public_key_is_deterministic() { use rand::rngs::OsRng; diff --git a/crates/crypto/src/tbls.rs b/crates/crypto/src/tbls.rs index 1ef68993..7800e4ac 100644 --- a/crates/crypto/src/tbls.rs +++ b/crates/crypto/src/tbls.rs @@ -35,6 +35,15 @@ pub trait Tbls { /// /// Maximum of 255 shares (total <= 255) due to underlying BLS library /// constraints. + /// + /// # Errors + /// + /// Returns [`Error::InvalidThreshold`] if `threshold < 2` or + /// `threshold > total`. (Charon only enforces `threshold > 1`; Pluto + /// additionally rejects `threshold > total` as an unrecoverable, always-bug + /// configuration.) Returns [`Error::InvalidSecretKey`] if `secret_key` is + /// not a valid BLS scalar, and [`Error::ThresholdOverflow`] if `threshold` + /// does not fit in `usize` on this platform. fn threshold_split_insecure( &self, secret_key: &PrivateKey, @@ -54,6 +63,15 @@ pub trait Tbls { /// /// Maximum of 255 shares (total <= 255) due to underlying BLS library /// constraints. + /// + /// # Errors + /// + /// Returns [`Error::InvalidThreshold`] if `threshold < 2` or + /// `threshold > total`. (Charon only enforces `threshold > 1`; Pluto + /// additionally rejects `threshold > total` as an unrecoverable, always-bug + /// configuration.) Returns [`Error::InvalidSecretKey`] if `secret_key` is + /// not a valid BLS scalar, and [`Error::ThresholdOverflow`] if `threshold` + /// does not fit in `usize` on this platform. fn threshold_split( &self, secret_key: &PrivateKey, diff --git a/crates/crypto/src/types.rs b/crates/crypto/src/types.rs index cc4f5dcd..ebe5297c 100644 --- a/crates/crypto/src/types.rs +++ b/crates/crypto/src/types.rs @@ -62,6 +62,18 @@ pub enum Error { total: Index, }, + /// The threshold value does not fit into the platform `usize`. + /// + /// `Index` is a `u64`; on 32-bit targets a sufficiently large threshold + /// cannot be used as a `Vec` capacity. This is a platform/overflow + /// condition, distinct from a caller-supplied out-of-range threshold + /// (`InvalidThreshold`). + #[error("Threshold {threshold} does not fit into usize on this platform")] + ThresholdOverflow { + /// The threshold value that failed to convert. + threshold: Index, + }, + /// Failed to verify a BLS signature. /// /// This error occurs when signature verification fails, indicating either diff --git a/crates/frost/src/kryptology.rs b/crates/frost/src/kryptology.rs index dd7ffb1f..5c08f4c0 100644 --- a/crates/frost/src/kryptology.rs +++ b/crates/frost/src/kryptology.rs @@ -413,12 +413,18 @@ pub fn round2( received_bcasts: &BTreeMap, received_shares: &BTreeMap, ) -> Result<(Round2Bcast, KeyPackage, PublicKeyPackage), KryptologyError> { + // Bounds mirror ObolNetwork/kryptology@v0.1.0 dkg_round2.go. + // feldman.Limit == max_signers. + // bcast: threshold-1 <= len <= max_signers (may include this node's + // own bcast) p2psend: threshold-1 <= len <= max_signers - 1 (never + // includes self) let min_received = (secret.threshold - 1) as usize; - let max_received = (secret.max_signers - 1) as usize; + let bcast_max = secret.max_signers as usize; + let shares_max = (secret.max_signers - 1) as usize; if received_bcasts.len() < min_received - || received_bcasts.len() > max_received + || received_bcasts.len() > bcast_max || received_shares.len() < min_received - || received_shares.len() > max_received + || received_shares.len() > shares_max { return Err(KryptologyError::IncorrectPackageCount); } @@ -432,8 +438,13 @@ pub fn round2( let mut share_sum = Scalar::ZERO; for (&sender_id, bcast) in received_bcasts { + // Charon's getRound2Inputs may include this node's own Round1Bcast in the + // broadcast map. Go's Round2 skips it (`if id == dp.Id { continue }`) rather + // than erroring. Self's commitment is added to peer_commitments separately + // below, and self's share contribution is the own_share_scalar term — so the + // self entry must be skipped here for both verification and share summation. if sender_id == secret.id { - return Err(KryptologyError::InvalidParticipantId(sender_id)); + continue; } let sender_commitment = @@ -456,14 +467,30 @@ pub fn round2( return Err(KryptologyError::InvalidProof { culprit: sender_id }); } - // Verify Feldman share + // Verify Feldman share. + // + // Mirrors kryptology's `FeldmanVerifier.Verify`, which calls + // `ShamirShare.Validate` (pkg/sharing/shamir.go:27-39) *before* the VSS + // equation. `Validate` rejects (1) id == 0, (2) a non-canonical scalar + // encoding, then (3) a zero scalar, each as a dedicated error. let share = received_shares .get(&sender_id) .ok_or(KryptologyError::InvalidShare { culprit: sender_id })?; - if share.id != secret.id { + // Step (1): identifier must be non-zero and addressed to us. kryptology + // only checks `id == 0`; we additionally require the share is addressed + // to this participant. Both map to InvalidShare with the sender culprit. + if share.id == 0 || share.id != secret.id { return Err(KryptologyError::InvalidShare { culprit: sender_id }); } + // Step (2): canonical scalar decode (scalar_from_be -> InvalidScalar). let mut share_scalar = scalar_from_be(&share.value)?; + // Step (3): reject a zero share value, matching ShamirShare.Validate's + // `sc.IsZero()` -> "invalid share". scalar_from_be accepts the zero + // scalar, so this guard is required for parity and to ensure rejection + // even against an all-identity (degenerate) commitment vector. + if share_scalar == Scalar::ZERO { + return Err(KryptologyError::InvalidShare { culprit: sender_id }); + } let signing_share = SigningShare::new(share_scalar); let secret_share = @@ -1126,24 +1153,130 @@ mod tests { )); } + /// Charon-shaped input: the round2 broadcast map includes this node's own + /// Round1Bcast (as produced by Charon's getRound2Inputs), which Go's Round2 + /// tolerates by skipping (`if id == dp.Id { continue }`). Round2 must + /// succeed. #[test] - fn round2_rejects_self_broadcast() { + fn round2_skips_self_broadcast() { let mut rng = StdRng::seed_from_u64(323); let threshold = 2u16; let max_signers = 3u16; let ctx = 0u8; - let (_bcast1, shares1, _secret1) = - round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast1, shares1, _secret1) = round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); let (bcast2, _shares2, secret2) = round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast3, shares3, _secret3) = round1(3, threshold, max_signers, ctx, &mut rng).unwrap(); + + // Broadcast map includes self (id 2), exactly like Charon's getRound2Inputs. + let received_bcasts: BTreeMap = + [(1, bcast1), (2, bcast2), (3, bcast3)].into(); + // Shares map never includes self. + let received_shares: BTreeMap = + [(1, shares1[&2].clone()), (3, shares3[&2].clone())].into(); - let received_bcasts: BTreeMap = [(2, bcast2)].into(); - let received_shares: BTreeMap = [(2, shares1[&2].clone())].into(); + let (_r2_bcast, _key_package, _public_key_package) = + round2(secret2, &received_bcasts, &received_shares) + .expect("round2 must skip the self broadcast and succeed"); + } + + /// A broadcast map larger than `max_signers` is rejected on the length + /// check before any cryptographic work (kryptology `feldman.Limit == + /// max_signers`). + #[test] + fn round2_rejects_bcast_over_max_signers() { + let mut rng = StdRng::seed_from_u64(325); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + let (bcast1, shares1, _s1) = round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast2, _s2, secret2) = round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast3, shares3, _s3) = round1(3, threshold, max_signers, ctx, &mut rng).unwrap(); + + // 4 broadcasts > max_signers (3): must be rejected on the length check. + let received_bcasts: BTreeMap = + [(1, bcast1.clone()), (2, bcast2), (3, bcast3), (4, bcast1)].into(); + let received_shares: BTreeMap = + [(1, shares1[&2].clone()), (3, shares3[&2].clone())].into(); + + assert!(matches!( + round2(secret2, &received_bcasts, &received_shares), + Err(KryptologyError::IncorrectPackageCount) + )); + } + + /// The p2psend (shares) upper bound stays `max_signers - 1`; one too many + /// shares is rejected as `IncorrectPackageCount`. + #[test] + fn round2_rejects_shares_over_max_signers_minus_one() { + let mut rng = StdRng::seed_from_u64(326); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + let (bcast1, shares1, _s1) = round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (_b2, _s2, secret2) = round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast3, shares3, _s3) = round1(3, threshold, max_signers, ctx, &mut rng).unwrap(); + + let received_bcasts: BTreeMap = [(1, bcast1), (3, bcast3)].into(); + // 3 shares == max_signers > max_signers-1 (2): rejected. + let received_shares: BTreeMap = [ + (1, shares1[&2].clone()), + (3, shares3[&2].clone()), + (4, shares1[&2].clone()), + ] + .into(); + + assert!(matches!( + round2(secret2, &received_bcasts, &received_shares), + Err(KryptologyError::IncorrectPackageCount) + )); + } + + /// A received Shamir share whose value is the zero scalar must be rejected + /// with a dedicated reason, matching kryptology's `ShamirShare.Validate` + /// (`sc.IsZero()` -> "invalid share") which runs *before* the Feldman VSS + /// equation. See pkg/sharing/shamir.go:27-39 @ Charon v1.7.1. + #[test] + fn round2_rejects_zero_share_value() { + let mut rng = StdRng::seed_from_u64(2026); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + let (bcast1, shares1, _secret1) = round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (_bcast2, _shares2, secret2) = + round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + + // Tamper: zero the share value addressed to participant 2. + let mut zero_share = shares1[&2].clone(); + zero_share.value = [0u8; 32]; + assert_eq!(zero_share.id, 2, "share is addressed to participant 2"); + + let result = round2(secret2, &[(1, bcast1)].into(), &[(1, zero_share)].into()); - let result = round2(secret2, &received_bcasts, &received_shares); assert!(matches!( result, - Err(KryptologyError::InvalidParticipantId(2)) + Err(KryptologyError::InvalidShare { culprit: 1 }) + )); + } + + /// A received share with id == 0 is rejected (kryptology + /// ShamirShare.Validate: `Id == 0` -> "invalid identifier"). + #[test] + fn round2_rejects_zero_share_id() { + let mut rng = StdRng::seed_from_u64(2027); + let (bcast1, shares1, _secret1) = round1(1, 2, 3, 0, &mut rng).unwrap(); + let (_bcast2, _shares2, secret2) = round1(2, 2, 3, 0, &mut rng).unwrap(); + + let mut bad = shares1[&2].clone(); + bad.id = 0; + + let result = round2(secret2, &[(1, bcast1)].into(), &[(1, bad)].into()); + assert!(matches!( + result, + Err(KryptologyError::InvalidShare { culprit: 1 }) )); } diff --git a/crates/k1util/src/k1util.rs b/crates/k1util/src/k1util.rs index d7e9d353..e06df010 100644 --- a/crates/k1util/src/k1util.rs +++ b/crates/k1util/src/k1util.rs @@ -167,18 +167,32 @@ pub fn recover(hash: &[u8], sig: &[u8]) -> Result { }); } - let mut recovery_byte = sig[K1_REC_IDX]; - - if recovery_byte == 27 || recovery_byte == 28 { - recovery_byte = recovery_byte.wrapping_sub(27); - } + let original_recovery_byte = sig[K1_REC_IDX]; + + // Charon accepts only the Ethereum-format recovery ids {0, 1} and their + // Bitcoin-compact-format equivalents {27, 28}. Reject everything else + // (notably the x-reduced ids 2 and 3 that `RecoveryId::from_byte` would + // otherwise accept). See charon app/k1util/k1util.go Recover @ v1.7.1. + let recovery_byte = match original_recovery_byte { + 0 | 1 => original_recovery_byte, + // 27/28 are the Bitcoin-compact-format equivalents of 0/1. + 27 => 0, + 28 => 1, + _ => { + return Err(K1UtilError::InvalidSignatureRecoveryId { + invalid_recovery_byte: original_recovery_byte, + }); + } + }; let signature = Signature::from_slice(&sig[..SIGNATURE_LEN - 1]).map_err(K1UtilError::InvalidSignature)?; + // `recovery_byte` is guaranteed to be 0 or 1 here, so `from_byte` cannot + // fail; keep the fallible call to avoid an unchecked construction. let recovery_id = RecoveryId::from_byte(recovery_byte).ok_or(K1UtilError::InvalidSignatureRecoveryId { - invalid_recovery_byte: recovery_byte, + invalid_recovery_byte: original_recovery_byte, })?; let pubkey = ecdsa::VerifyingKey::recover_from_prehash(hash, &signature, recovery_id) @@ -315,6 +329,96 @@ mod tests { ); } + #[test] + fn recover_recovery_id_domain() { + // Produce a known-valid 65-byte signature whose natural recovery byte is + // 0 or 1, then override the recovery byte to exercise the domain. + let key_bytes = hex::decode(PRIV_KEY_1).unwrap(); + let key = SecretKey::from_slice(&key_bytes).unwrap(); + let digest = hex::decode(DIGEST_1).unwrap(); + let base_sig = sign(&key, &digest).unwrap(); + let natural_v = base_sig[K1_REC_IDX]; // 0 or 1 + + // (recovery_byte, expect_ok). 27/28 are the Bitcoin-compact equivalents + // of 0/1. For domain validation we only assert accept/reject. + struct Case { + v: u8, + accepted: bool, + } + let cases = [ + Case { + v: 0, + accepted: true, + }, + Case { + v: 1, + accepted: true, + }, + Case { + v: 27, + accepted: true, + }, + Case { + v: 28, + accepted: true, + }, + Case { + v: 2, + accepted: false, + }, // x-reduced id, accepted by k256 but rejected by Charon + Case { + v: 3, + accepted: false, + }, // x-reduced id + Case { + v: 4, + accepted: false, + }, + Case { + v: 26, + accepted: false, + }, + Case { + v: 29, + accepted: false, + }, + Case { + v: 255, + accepted: false, + }, + ]; + + for case in cases { + let mut sig = base_sig; + sig[K1_REC_IDX] = case.v; + let result = recover(&digest, &sig); + if case.accepted { + assert!( + result.is_ok(), + "recovery byte {} should be accepted", + case.v + ); + } else { + assert!( + matches!( + result, + Err(K1UtilError::InvalidSignatureRecoveryId { invalid_recovery_byte }) + if invalid_recovery_byte == case.v + ), + "recovery byte {} should be rejected with InvalidSignatureRecoveryId carrying the original byte, got {:?}", + case.v, + result.map(|_| "Ok"), + ); + } + } + + // The recovery byte that equals the signature's true recovery id must + // recover the original public key (verify_65 inherits the fix). + let mut sig = base_sig; + sig[K1_REC_IDX] = natural_v; + assert!(verify_65(&key.public_key(), &digest, &sig).unwrap()); + } + #[cfg(unix)] #[test] fn save_writes_private_key_with_owner_only_permissions() {