-
Notifications
You must be signed in to change notification settings - Fork 149
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
Add Secp256r1 #240
Add Secp256r1 #240
Conversation
fastcrypto/src/secp256r1.rs
Outdated
} | ||
|
||
impl Secp256r1Signature { | ||
/// Recover public key from 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.
Can you refer to the original impl this was based, just to ensure no malleability and consistency with the secpK1 lib. Ideally we should copy paste that logic (if not).
@joyqvq please have a look 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.
I will. It is mostly based on the specs (section 4.1.6 in https://www.secg.org/sec1-v2.pdf) and also on an implementation in the k256 crate. I know it's not optimal to do this on our own, but I couldn't find code I could copy-paste directly.
I have two somewhat related questions regarding this:
- I'm assuming we wan't to recover and check public keys because of the attack you found recently, but not both verify and ecrecover. Which should we do on default?
- Should we add a recovery id byte to signatures as it is done in secp256k1? Right now, we're recovering up to four potential keys, but since the end goal is interoperability with hardware wallets, the answer to this depends on how the hardware wallets are handling this. The documentation doesn't mention it, but the examples I have found looks like signatures are export as ASN.1 in DER formet without a recovery id byte.
@@ -37,6 +37,7 @@ typenum = "1.15.0" | |||
# TODO: switch to https://github.com/celo-org/celo-bls-snark-rs | |||
# when https://github.com/celo-org/celo-bls-snark-rs/issues/228 is solved | |||
celo-bls = { git = "https://github.com/huitseeker/celo-bls-snark-rs", branch = "updates-2-with-parallelism-toggle", package = "bls-crypto", default-features = false } | |||
p256 = { version = "0.11.1", features = ["ecdsa"] } |
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.
Indeed, this is unfortunately the lib to use (secp256r1 is not very popular in the chain space, and probably nobody worked on ecrecover and micro-optimizations :) ), unless @huitseeker has seen any fork of the one we use secpk1.
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.
There wasn't any ecrecover, and I'll check regarding benchmarks, but if we only consider the usage in Sui, where it's only going to be used for wallets, I'm assuming the performance is not that critical?
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.
Ecrecover helps on compressing txs, but we know it's more expensive than regular verification. Atm we try to be similar to Ethereum that expects ecrecover and we should use exactly the same logic, with the v byte flag that denotes which public key to pick when recovering.
We also want both ECDSAs to behave similarly.
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.
We will also need the regular verification in both ECDSAs as i/ requires for our fair benchmarks + to switch if in the future we decide that cost is more important than size.
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 could try the arithmetic generated by fiat-crypto, which is usually pretty darn fast:
https://github.com/mit-plv/fiat-crypto/tree/master/fiat-rust/src
I haven't run benches to compare to k256/p256, though.
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.
With the most recent commits, we are reimplementing ECDSA anyway, so using a different lib could make sense. We should run some benchmarks first to see if it's worth it though.
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.
Clarifications re ecrecover and v flag to be 100% compatible with the other ECDSA.
Note that wallets that sign with libraries that do not support outputs with ecrecover, we're going to provide converters.
We should also normalise signatures as we do for k1 curve, by reducing s value.
} | ||
|
||
impl Verifier<Secp256r1Signature> for Secp256r1PublicKey { | ||
fn verify(&self, msg: &[u8], signature: &Secp256r1Signature) -> Result<(), signature::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.
for secp256k1, we have the recovery id appended to the 64 bytes signature. perhaps we can do the same for the r1 signature as well?
// } | ||
|
||
#[test] | ||
fn wycheproof_test() { |
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.
nice!
// This implementation is based on recover_verifying_key_from_digest_bytes in the p256 crate, | ||
// but also handles the case the the x-coordinate is larger than the group order. | ||
|
||
let (r, s) = self.sig.split_scalars(); |
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.
for secp256k1, we reject signature with s in the lower half of the field. perhaps to maintain the same behavior 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.
To align the schemes, that certainly makes sense to do during signing. It's what Ethereum does, right, but I don't think it's part of the standard? The wycheproof tests for p256 fails if signatures with s > n/2 signatures are rejected, so we'll have to get around that if we wan't to enforce this on verification.
I'll add v flag and integrate your and Joys comments such that this implementation is 100% compatible with the k256 impl. And then I suggest that we for both ECDSAs implement both verify and ecrecover, perhaps with two different signature types. But I think we should do this in another PR. |
Yeah in another PR |
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 a bunch for the rapid turnaround!
As much as I love the awesome folks at RustCrypto, I'd urge to read their scary warning on this crate:
⚠️ Security WarningThe elliptic curve arithmetic contained in this crate has never been independently audited! (...)
I think we have several options here, which all improve security:
- we liaise with the RustCrypto folks and get this crate audited,
- we use the point and field arithmetic from fiat-crypto
- we proptest all the operations we use from p256, comparing the outputs to those of an audited Rust crypto lib such as ring
- we use symbolic testing with crux-mir
- more conventional fuzz-testing than proptest
- ...
I don't think we need to use all of those methods in this PR, of course, but I do think we need to think about employing at least a couple of these. Until then, I would make the secp256r1 module pub(crate)
@@ -37,6 +37,7 @@ typenum = "1.15.0" | |||
# TODO: switch to https://github.com/celo-org/celo-bls-snark-rs | |||
# when https://github.com/celo-org/celo-bls-snark-rs/issues/228 is solved | |||
celo-bls = { git = "https://github.com/huitseeker/celo-bls-snark-rs", branch = "updates-2-with-parallelism-toggle", package = "bls-crypto", default-features = false } | |||
p256 = { version = "0.11.1", features = ["ecdsa"] } |
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 could try the arithmetic generated by fiat-crypto, which is usually pretty darn fast:
https://github.com/mit-plv/fiat-crypto/tree/master/fiat-rust/src
I haven't run benches to compare to k256/p256, though.
#[test] | ||
fn serialize_deserialize() { | ||
let kpref = keys().pop().unwrap(); | ||
let public_key = kpref.public(); | ||
|
||
let bytes = bincode::serialize(&public_key).unwrap(); | ||
let pk2 = bincode::deserialize::<Secp256r1PublicKey>(&bytes).unwrap(); | ||
assert_eq!(public_key.as_ref(), pk2.as_ref()); | ||
|
||
let private_key = kpref.private(); | ||
let bytes = bincode::serialize(&private_key).unwrap(); | ||
let privkey = bincode::deserialize::<Secp256r1PrivateKey>(&bytes).unwrap(); | ||
let bytes2 = bincode::serialize(&privkey).unwrap(); | ||
assert_eq!(bytes, bytes2); | ||
|
||
let signature = Secp256r1Signature::default(); | ||
let bytes = bincode::serialize(&signature).unwrap(); | ||
let sig = bincode::deserialize::<Secp256r1Signature>(&bytes).unwrap(); | ||
let bytes2 = bincode::serialize(&sig).unwrap(); | ||
assert_eq!(bytes, bytes2); | ||
|
||
// test serde_json serialization | ||
let serialized = serde_json::to_string(&signature).unwrap(); | ||
println!("{:?}", serialized); | ||
let deserialized: Secp256r1Signature = serde_json::from_str(&serialized).unwrap(); | ||
assert_eq!(deserialized.as_ref(), signature.as_ref()); | ||
} | ||
|
||
#[test] | ||
fn import_export_public_key() { | ||
let kpref = keys().pop().unwrap(); | ||
let public_key = kpref.public(); | ||
let export = public_key.encode_base64(); | ||
let import = Secp256r1PublicKey::decode_base64(&export); | ||
assert!(import.is_ok()); | ||
assert_eq!(import.unwrap().as_ref(), public_key.as_ref()); | ||
} | ||
|
||
#[test] | ||
fn test_public_key_bytes_conversion() { | ||
let kp = keys().pop().unwrap(); | ||
let pk_bytes: Secp256r1PublicKeyBytes = kp.public().into(); | ||
let rebuilt_pk: Secp256r1PublicKey = pk_bytes.try_into().unwrap(); | ||
assert_eq!(kp.public().as_bytes(), rebuilt_pk.as_bytes()); | ||
} | ||
|
||
#[test] | ||
fn test_public_key_recovery() { | ||
let kp = keys().pop().unwrap(); | ||
let message: &[u8] = b"Hello, world!"; | ||
let signature: Secp256r1Signature = kp.sign(message); | ||
let recovered_keys = signature.recover(message).unwrap(); | ||
assert!(recovered_keys.contains(kp.public())); | ||
} |
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.
Nit: Those roundtrip tests are ideal candidates for a proptest conversion, specially if we implement the Arbitrary trait.
|
||
impl ToFromBytes for Secp256r1PrivateKey { | ||
fn from_bytes(bytes: &[u8]) -> Result<Self, FastCryptoError> { | ||
match ExternalSecretKey::try_from(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.
I'd love if we could make sure (either through unit testing here, or figuring out a wycheproof test that checks this) that non-canonical scalars are handled properly.
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.
There doesn't seem to be a wycheproof test for this. I'll add an issue.
Thanks for the review! The issue about lack of independent auditing is not specific for this PR since it's also the case for at least the k256 crate and perhaps others that do not specifically state, that they've been audited. So I suggest we discuss how we should handle this for all crates we depend on and use the ideas you mention as inputs to that discussion. We could make the module pub(crate), but should in principle do the same for k256 then :). |
This is implemented now. Code used to sign, generate recovery id and public key recovery is copied from the k256 crate. |
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're in a good state to merge, please resolve conflicts and will approve
It is done! |
|
||
impl Signer<Secp256r1Signature> for Secp256r1KeyPair { | ||
fn try_sign(&self, msg: &[u8]) -> Result<Secp256r1Signature, signature::Error> { | ||
// Code copied from Sign.rs in k256@0.11.6 |
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!
// Code copied from Sign.rs in k256@0.11.6 | ||
|
||
// Hash message | ||
let z = FieldBytes::from(Sha256::digest(msg).digest); |
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.
Interesting that internally the message is hashed with 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.
Yes, I noticed that too. However, now that we have reproduced all the code, we could easily make the hash function generic.
fastcrypto/src/secp256r1.rs
Outdated
let sig = ExternalSignature::from_scalars(r, s)?; | ||
|
||
// Note: This line is introduced here because big_r.y is a private field. | ||
let y: Scalar = Scalar::from_be_bytes_reduced(*big_r.to_encoded_point(false).y().unwrap()); |
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 this fail with a malformed R? maybe avoid unwrap just in case. ah it's signing, we don't really care, as it will never run on-chain + R is computed 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.
The y()
method only returns None
if the encoded point is compressed or compact, but we specifically ask for an uncompressed encoding, so the unwrap will never fail in this case. I will make the code bit more clear.
fastcrypto/src/secp256r1.rs
Outdated
/// An [FastCryptoError::GeneralError] is returned if no public keys can be recovered. | ||
pub fn recover(&self, msg: &[u8]) -> Result<Secp256r1PublicKey, FastCryptoError> { | ||
let (r, s) = self.sig.split_scalars(); | ||
let v = RecoveryId::from_byte(self.recovery_id).unwrap(); |
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.
As this will run on blockchain, let's ensure it returns an error vs panic on fail, unless we know it will never fail, even under malicious signers.
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.
That's true. The deserialization doesn't check the recovery id byte, so a malicious signer could just put anything there. It's fixed now.
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.
Awesome!
Fixes #217 and paves the way for MystenLabs/sui#6235