Skip to content

Conversation

obycode
Copy link
Contributor

@obycode obycode commented Oct 9, 2025

Implementing #6554.

@jcnelson
Copy link
Member

Why are we dropping libsecp256k1 in favor of the one from RustCrypto? The former has been extensively audited and is used by Bitcoin.

Add tests for `secp256k1-verify` and `secp256k1-recover?` while adding
tests for this new function.
@obycode obycode marked this pull request as ready for review October 10, 2025 21:41
@obycode obycode requested review from a team as code owners October 10, 2025 21:41
@obycode
Copy link
Contributor Author

obycode commented Oct 10, 2025

Why are we dropping libsecp256k1 in favor of the one from RustCrypto? The former has been extensively audited and is used by Bitcoin.

The thought was that since we're pulling in the RustCrypto crate for secp256r1, we may as well also replace the k256 crate as well with this Rust-native solution that seems to be well-vetted and audited as well. libsecp256k1 is a wrapped C library.

@obycode obycode linked an issue Oct 10, 2025 that may be closed by this pull request
@obycode obycode requested review from jcnelson and kantai October 10, 2025 21:55
This removes the need for trying the recovery bytes to find the right
one.
…eserialize`

The signature will be checked after deserialization. Checking it here
will cause a deserialization failure when what we actually want is a
signature validation failure.
Comment on lines 662 to 667
// signature must be well-formed
let _ = signature
.to_secp256k1_recoverable()
.ok_or(codec_error::DeserializeError(
"Failed to parse signature".to_string(),
))?;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to reviewers: this check here causes the deserialization of fake signatures used in tests to fail early. The signature itself will always be checked later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(it fails when one of the components is all 0s)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you leave this behavior here, and simply turn it off for testing? Wouldn't want to break anything by accident.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can leave it to be safe, but I don't think it is necessary. Added back in bec654c.

snippet: "secp256r1-verify ${1:message-hash} ${2:signature} ${3:public-key})",
output_type: "bool",
signature: "(secp256r1-verify message-hash signature public-key)",
description: "The `secp256r1-verify` function verifies that the provided signature of the message-hash
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to clarify specifically that this is not the Bitcoin signature scheme, despite having a nearly-identical spelling. This is NIST P-256.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signature: "(secp256r1-verify message-hash signature public-key)",
description: "The `secp256r1-verify` function verifies that the provided signature of the message-hash
was signed with the private key that generated the public key.
`message-hash` is the `sha256` of the message and `signature` is the raw 64-byte signature.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, it doesn't actually matter what hash function is used from the perspective of ECDSA. In this particular implementation, we only require that the message m is 32 bytes long (which in practice is a 256-bit cryptographic hash of a larger message).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. I copied that language from secp256k1-verify. We can remove it from both if that is more clear.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think it could be as simple as changing both to be "... is typically the sha256..."

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 1b5c605.

)
.into());
}
if data.len() != 64 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be a TypeValueError like it is above?

Copy link
Member

@jcnelson jcnelson Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, aren't recoverable P-256 signatures 65 bytes long? You need 32 bytes for r, 32 bytes for s, and 1 byte for v to indicate which of a set of recovered public keys is the real one.

EDIT: nevermind, recoverable signatures aren't called for in the SIP. But my question still stands -- shouldn't we be treating anything other than a (buff 64) as a type value error?

Copy link
Contributor Author

@obycode obycode Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one above is a TypeValueError that should never be reachable, because the type-checker should have ensured that this was a (buff 64). For this case, if the signature is < 64 bytes, the type-checker wouldn't have caught that, so it just returns false, so that we can make this function infallible (it only returns true or false in practice). secp256k1-verify behaves the same way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussion based on real-world usage of secp256r1, I decided to drop the recovery byte and the -recover? function, so these signatures are just the 64-bytes.

}

#[test]
fn test_secp256r1_verify_signature_too_short_returns_false() {
Copy link
Member

@jcnelson jcnelson Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still confused by this choice. How would a caller be led to believe that a message signature could be anything other than 65 bytes? A NIST P-256 signature has r and s values that fit into 32 bytes. We don't use DER encoding (or any sort of self-describing variable-length field encoding) for the signature values, so a signature buffer must allocate fixed-sized fields for r and s. This means that r and s must fit into a 64 byte buffer. Because we want these signatures to be recoverable, we include a 65th byte to be a parity byte v. Given that we already offer no other way to represent a signature in anything other than 65 64 bytes, I think that a signature with anything other than 65 64 bytes should just be treated as a runtime error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDIT: crossed out language regarding recoverable signatures, since the SIP does not call for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference is to leave it returning false in this case, rather than an un-catchable runtime error. The alternative is to make secp256r1-verify return a response and return an err in this case, but that would be different behavior than what secp256k1-verify has, and it is kind of nice to just get a bool here, with no need to deal with a response.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think my preference would also be to return false instead of a runtime error

}

#[test]
fn test_secp256k1_verify_signature_too_short_returns_false() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should at least plan to fix this for secp256k1 as well. If the signature isn't 65 bytes, it's a runtime error.

let recovery_id = K256RecoveryId::from_byte(self.0[0])?;
let mut sig_bytes = [0u8; 64];
sig_bytes[..64].copy_from_slice(&self.0[1..=64]);
let signature = K256Signature::from_slice(&sig_bytes).ok()?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this just be the following?

let signature = K256Signature::from_slice(&self.0[1..=64]).ok()?;

Then we can cut out the need for sig_bytes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! 58d4dd3


/// Recovers message and signature to public key (will be compressed).
pub fn recover_to_pubkey(
_msg: &[u8],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are these arguments prefixed with _?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, artifact of something I did at some point. Fixed in 31f25e5.

}

#[cfg(test)]
mod tests {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you include all of the tests from the previous (now deleted) version of secp256k1.rs so we can be certain that no behavioral changes crept in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-added in a2fb57f

This check is unnecessary and it can't run in tests, since we sometimes
provide fake signatures which will fail. Leaving it in place outside of
tests to make sure we don't accidentally change some behavior.
@obycode obycode requested review from hstove and removed request for kantai October 15, 2025 14:34
@brice-stacks brice-stacks requested a review from jcnelson October 16, 2025 18:42
Copy link
Contributor

@hstove hstove left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks great! I tried out some fixtures by using @noble/curves and it all worked as expected. My comments are pretty much all about code "style" / DX and risk bike-shedding.

It would be nice to not add any new clippy::indexing_slicing violations, so some of my suggestions are around that, even though I know you're just copy/pasting from existing code.

Comment on lines 38 to 59
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Secp256r1Error {
InvalidKey,
InvalidSignature,
InvalidMessage,
InvalidRecoveryId,
SigningFailed,
RecoveryFailed,
}

impl fmt::Display for Secp256r1Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Secp256r1Error::InvalidKey => write!(f, "Invalid key"),
Secp256r1Error::InvalidSignature => write!(f, "Invalid signature"),
Secp256r1Error::InvalidMessage => write!(f, "Invalid message"),
Secp256r1Error::InvalidRecoveryId => write!(f, "Invalid recovery ID"),
Secp256r1Error::SigningFailed => write!(f, "Signing failed"),
Secp256r1Error::RecoveryFailed => write!(f, "Recovery failed"),
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference would be for use to use thiserror here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's annoying that a lot of these RustCrypto functions return Result<Self>, otherwise using thiserror would also provide some nice DX improvements. Oh well!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I haven't gotten used to thiserror yet, but it is nice. Updated in 0ca9f79.

RecoveryFailed,
}

impl fmt::Display for Secp256k1Error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do go with thiserror, also here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Updated in 0ca9f79.

Comment on lines 160 to 164
if sig.len() < 65 {
buf[..sig.len()].copy_from_slice(sig);
} else {
buf.copy_from_slice(&sig[..65]);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if sig.len() < 65 {
buf[..sig.len()].copy_from_slice(sig);
} else {
buf.copy_from_slice(&sig[..65]);
}
for (dst, src) in buf.iter_mut().zip(sig.iter().copied()) {
*dst = src;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this instead?

    /// Generates place-holder data (for testing purposes only).
    #[cfg(any(test, feature = "testing"))]
    pub fn from_raw(sig: &[u8]) -> MessageSignature {
        const LEN: usize = 65;
        let mut buf = [0u8; LEN];
        let n = sig.len().min(LEN);
        buf[..n].copy_from_slice(&sig[..n]);
        MessageSignature(buf)
    }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh., never mind, I see you were trying to get rid of the array accesses. Will fix.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// Converts to a secp256k1::ecdsa::RecoverableSignature.
pub fn to_secp256k1_recoverable(&self) -> Option<RecoverableSignature> {
let recovery_id = K256RecoveryId::from_byte(self.0[0])?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let recovery_id = K256RecoveryId::from_byte(self.0[0])?;
let (recid_byte, sig_bytes) = self.0.split_first()?;
let recovery_id = K256RecoveryId::from_byte(*recid_byte)?;
let signature = K256Signature::from_slice(sig_bytes).ok()?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 172 to 174
let recovery_id_byte = recid.to_byte();
ret_bytes[0] = recovery_id_byte;
ret_bytes[1..=64].copy_from_slice(&bytes[..64]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let recovery_id_byte = recid.to_byte();
ret_bytes[0] = recovery_id_byte;
ret_bytes[1..=64].copy_from_slice(&bytes[..64]);
if let Some((first, rest)) = ret_bytes.split_first_mut() {
*first = recid.to_byte();
rest.copy_from_slice(&bytes);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

/// A Secp256r1 public key
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sorta nit, but what do you think about just serializing the whole struct with ::to_hex and ::from_hex? And also for Secp256r1PrivateKey? It would probably also help with logging - you'd just see the hex instead of { key: string, compressed: bool }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can actually just do:

#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Secp256r1PublicKey {
    key: P256VerifyingKey,
    compressed: bool,
}
impl_byte_array_serde!(Secp256r1PublicKey);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, nice. Looks like I can do the same for the private key as well.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[cfg(any(test, feature = "testing"))]
impl Default for Secp256r1PublicKey {
fn default() -> Self {
Self::new()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Secp256r1PublicKey::new() isn't used elsewhere, can we just remove it and add the implementation here? Or, maybe better, rename ::new to random()?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 242 to 243
// set this to true: LocalPeer will be doing this anyways,
// and that's currently the only way this method is used
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copypasta - you can just remove these lines

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +102 to +113
/// Converts from a p256::ecdsa::Signature to our MessageSignature
pub fn from_p256_signature(sig: &P256Signature) -> MessageSignature {
let sig_bytes = sig.to_bytes();
let mut ret_bytes = [0u8; 64];
ret_bytes.copy_from_slice(&sig_bytes);
MessageSignature(ret_bytes)
}

/// Converts to a p256::ecdsa::Signature
pub fn to_p256_signature(&self) -> Result<P256Signature, Secp256r1Error> {
P256Signature::from_slice(&self.0).map_err(|_| Secp256r1Error::InvalidSignature)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely nit, but what do you think about instead implementing MessageSignature::from<P256Signature> and P256Signature::try_from<MessageSignature>? I honestly see both sides of which is better (from_p256_signature is more explicit, but From gives into() etc). Feel free to resolve this if you prefer the current code!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that we can't implement P256Signature::try_from<MessageSignature> since that type is defined in the p256 crate. That's why I decided to just leave both like this for consistency.

Comment on lines 398 to 411
let encoded_point =
EncodedPoint::from_bytes(pubkey_arr).map_err(|_| Secp256r1Error::InvalidKey)?;

let public_key =
Option::<P256PublicKey>::from(P256PublicKey::from_encoded_point(&encoded_point))
.ok_or(Secp256r1Error::InvalidKey)?;
let verifying_key = P256VerifyingKey::from(public_key);

let signature =
P256Signature::from_slice(signature_arr).map_err(|_| Secp256r1Error::InvalidSignature)?;

verifying_key
.verify(message_arr, &signature)
.map_err(|_| Secp256r1Error::InvalidSignature)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it would be better to re-use our "library wrapper" code to prevent us from having differing applications

Suggested change
let encoded_point =
EncodedPoint::from_bytes(pubkey_arr).map_err(|_| Secp256r1Error::InvalidKey)?;
let public_key =
Option::<P256PublicKey>::from(P256PublicKey::from_encoded_point(&encoded_point))
.ok_or(Secp256r1Error::InvalidKey)?;
let verifying_key = P256VerifyingKey::from(public_key);
let signature =
P256Signature::from_slice(signature_arr).map_err(|_| Secp256r1Error::InvalidSignature)?;
verifying_key
.verify(message_arr, &signature)
.map_err(|_| Secp256r1Error::InvalidSignature)
let pk = Secp256r1PublicKey::from_slice(pubkey_arr).map_err(|_| Secp256r1Error::InvalidKey)?;
let sig =
MessageSignature::from_bytes(signature_arr).ok_or(Secp256r1Error::InvalidSignature)?;
pk.verify_digest(message_arr, &sig)
.map_err(|_| Secp256r1Error::InvalidSignature)?;
Ok(())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. 58ca134

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Clarity-4] secp256r1

4 participants