-
Notifications
You must be signed in to change notification settings - Fork 710
feat: add support for secp256r1 and replace libsecp256k1 #6581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
Why are we dropping |
Add tests for `secp256k1-verify` and `secp256k1-recover?` while adding tests for this new function.
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. |
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.
// signature must be well-formed | ||
let _ = signature | ||
.to_secp256k1_recoverable() | ||
.ok_or(codec_error::DeserializeError( | ||
"Failed to parse signature".to_string(), | ||
))?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
clarity/src/vm/docs/mod.rs
Outdated
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.", |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
..."
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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.
stacks-common/src/util/secp256k1.rs
Outdated
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()?; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! 58d4dd3
stacks-common/src/util/secp256k1.rs
Outdated
|
||
/// Recovers message and signature to public key (will be compressed). | ||
pub fn recover_to_pubkey( | ||
_msg: &[u8], |
There was a problem hiding this comment.
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 _
?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Consensus in epoch 3.3 is broken because costs-4 changed.
There was a problem hiding this 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.
stacks-common/src/util/secp256r1.rs
Outdated
#[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"), | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
stacks-common/src/util/secp256k1.rs
Outdated
RecoveryFailed, | ||
} | ||
|
||
impl fmt::Display for Secp256k1Error { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Updated in 0ca9f79.
stacks-common/src/util/secp256k1.rs
Outdated
if sig.len() < 65 { | ||
buf[..sig.len()].copy_from_slice(sig); | ||
} else { | ||
buf.copy_from_slice(&sig[..65]); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; | |
} |
There was a problem hiding this comment.
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)
}
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stacks-common/src/util/secp256k1.rs
Outdated
|
||
/// Converts to a secp256k1::ecdsa::RecoverableSignature. | ||
pub fn to_secp256k1_recoverable(&self) -> Option<RecoverableSignature> { | ||
let recovery_id = K256RecoveryId::from_byte(self.0[0])?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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()?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stacks-common/src/util/secp256k1.rs
Outdated
let recovery_id_byte = recid.to_byte(); | ||
ret_bytes[0] = recovery_id_byte; | ||
ret_bytes[1..=64].copy_from_slice(&bytes[..64]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stacks-common/src/util/secp256r1.rs
Outdated
} | ||
|
||
/// A Secp256r1 public key | ||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] |
There was a problem hiding this comment.
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 }
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stacks-common/src/util/secp256r1.rs
Outdated
#[cfg(any(test, feature = "testing"))] | ||
impl Default for Secp256r1PublicKey { | ||
fn default() -> Self { | ||
Self::new() |
There was a problem hiding this comment.
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()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stacks-common/src/util/secp256r1.rs
Outdated
// set this to true: LocalPeer will be doing this anyways, | ||
// and that's currently the only way this method is used |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// 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) | ||
} |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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.
stacks-common/src/util/secp256r1.rs
Outdated
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) |
There was a problem hiding this comment.
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
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(()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call. 58ca134
Implementing #6554.