Skip to content
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

Implement Secp256r1 / P256 verification in solidity #4881

Open
wants to merge 22 commits into
base: master
Choose a base branch
from

Conversation

Amxx
Copy link
Collaborator

@Amxx Amxx commented Feb 7, 2024

Why do we care about secp256r1?

Most security application uses secp256r1 (also known as p256). This lead hardware manufacturers to implement it, and leave other “exotic” curves on the side. Today, billions of people around the world own devices with spetial security hardware that supports secp256r1. If that was the curve used by ethereum, all these people would basically already own a hardware wallet … but unfortunatelly that is not the case.

If we cannot easily modify the curves supported by major smartphones manufacturer, we can provide tools to verify secp256r1 curve onchain. This would allow control of ERC-4337 smart wallets (among others) through a device designed to handle security keys (something users are notoriously bad at).

What @openzeppelin/contracts could provide

Existing wallets provide mechanisms to produce secp256k1 signature, both for transactions and messages. Solidity provides a precompile that, given a hash and a signature, will recover the address of the signer (using secp256k1). No such precompile exist for secp256r1.

There exist solidity implementations of the secp256r1 “verification” workflow. There is also a proposal to provide that verification through a precompile. Even if the precompile is implemented, it is likelly that many chains will not upgrade soon. A solidity implementation would remain usefull for users on these chains.

In some cases, users may want to follow the “recovery” flow that they are familiar with. There is also no proposal for a precompile that would do that operation. A solidity implementation would possibly be usefull to many users, and remain uncontested in the near future.

Notes

Stack too depth

Current proposed implementation works well when turning optimization on. However, compilation fails with "stack to deep" if optimizations are NOT turned on. This PR does enable optimizations for all tests to circumvent this issue. Also, users will have to enable optimizations if they want to use this library, which they should definitelly do given the gast costs.

  • This is still an issue when running coverage :/
    • This was fixed by adding details: { yul: true }, to the optimizer settings. This change in optimization setup may affect the accuracy of gas reporting in this PR (reference doesn't use the same settings)

Benchmarking

This repo provides benchmarking of this implementation against other existing ones.
Capture d’écran du 2024-02-07 11-33-29

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

@Amxx Amxx added this to the 5.1 milestone Feb 7, 2024
Copy link

changeset-bot bot commented Feb 7, 2024

🦋 Changeset detected

Latest commit: e0ef63b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Amxx Amxx marked this pull request as ready for review March 13, 2024 14:01
Copy link
Member

@ernestognw ernestognw left a comment

Choose a reason for hiding this comment

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

Left a couple of questions and notes while reviewing. I've been delaying this review for a while but at first sight it looks really well implemented, and I mostly need to familiarize and check the math is correct.

Comment on lines +22 to +33
/// @dev Generator (x component)
uint256 internal constant GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296;
/// @dev Generator (y component)
uint256 internal constant GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5;
/// @dev P (size of the field)
uint256 internal constant P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF;
/// @dev N (order of G)
uint256 internal constant N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551;
/// @dev A parameter of the weierstrass equation
uint256 internal constant A = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC;
/// @dev B parameter of the weierstrass equation
uint256 internal constant B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B;
Copy link
Member

Choose a reason for hiding this comment

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

These match with the parameters listed here

* @param qy - public key coordinate Y
*/
function verify(uint256 h, uint256 r, uint256 s, uint256 qx, uint256 qy) internal view returns (bool) {
if (r == 0 || r >= N || s == 0 || s >= N || !isOnCurve(qx, qy)) return false;
Copy link
Member

Choose a reason for hiding this comment

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

Most sources mention that if r == 0 or s == 0 during signature generation then the signature should be regenerated with a different nonce. However, I haven't found exactly why is it critical to check for both.

I get that operating with r == 0 or s == 0 breaks point addition, but I haven't found how that could be used maliciously. Would appreciate if you point me out to a source if you have one.

Copy link
Collaborator Author

@Amxx Amxx Apr 23, 2024

Choose a reason for hiding this comment

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

We are not generating a signature, we are verifying one. As you saif, it should not be generated that way, and if it is, we should reject it.

I'm not exactly sure how that could be used maliciously. I think the point is more like: we know this cannot possibly be a valid input, so we should reject it. We should count on the caller doing the sanity check.

Can we prove that is r (or s) is 0, then the function will return false without reverting? Maybe.

  • If s = 0, we get w = 0, which is not actually an inverse. That gives u1 = 0.
  • If r = 0, we get u2 = 0.

In _jMultShamir, it is unclear to me that any of there value being 0 can be treated in a specific way. If both are 0, then the end point is (0, 0) and we might get "true".

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I tested that. If you remove the zero check, then this test passes:

    /// forge-config: default.fuzz.runs = 512
    function testVerifyZero(uint256 seed, bytes32 digest) public {
        uint256 privateKey = bound(uint256(keccak256(abi.encode(seed))), 1, P256.N - 1);

        (uint256 x, uint256 y) = P256.getPublicKey(privateKey);
        assertTrue(P256.verify(uint256(digest), 0, 0, x, y));
    }

Said otherwize: without this check r=0, s=0 would be a valid signature that would be verified for any h and any qx, qy.

/// @solidity memory-safe-assembly
assembly {
let p := P
let yy := mulmod(y, y, p)
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 we return the point at infinity (0,1,0) if y == 0 at this point?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This part should be discussed over a call, but here are my first thought

A jacobian point (x,y,z) represents the carthesian point (x/z², y/z³). Therefore, any jacobian point that has z=0 is at infinity (because x/z² and y/z³ are x/0 and y/0, which is not defined).

For the case of _jDouble, if y=0 we have z' = 2*y*z = 0

So my understanding is that, if y=0 we know the result is a point at infinity, and we can potentially skip some computation is we already know one. However, if we don't skip, and we do the computation "normaly", we get a point at infinity, which is correct. We could have skipped the computation, but given that in our case addmod and mulmod are "cheap native operation" (which is not the case in an x86 machine), its probably ok.

We can try implementing the skip, and see if its actually saving gas, but AFAIK, this is an optimisation issue and not a correctness issue.

let u2 := mulmod(x2, zz1, p) // u2 = x2*z1²
let s1 := mulmod(y1, mulmod(zz2, z2, p), p) // s1 = y1*z2³
let s2 := mulmod(y2, mulmod(zz1, z1, p), p) // s2 = y2*z1³
let h := addmod(u2, sub(p, u1), p) // h = u2-u1
Copy link
Member

Choose a reason for hiding this comment

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

Similar to the _jDouble function, I see a couple of operations not followed from the reference here:

...
 if (U1 == U2)
   if (S1 != S2)
     return POINT_AT_INFINITY
   else 
     return POINT_DOUBLE(X1, Y1, Z1)
...

I want to make sure I understand why this is being ignored

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I believe that this is a similar case to #4881 (comment), but I'm not 100% sure, so lets explore that

Copy link
Collaborator Author

@Amxx Amxx Apr 25, 2024

Choose a reason for hiding this comment

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

if u1=u2, then h = u2-u1 = 0, and so h*z1*z2 = 0 ... so it appears we return a point at infinity

also, if u1=u2 then h = 0 and then:

  • x' = r²-h³-2*u1*h² = r²-0-0 = r²
  • y' = r*(u1*h²-x')-s1*h³ = r*(0-x')-0 = r*(-r²) = -r³

with r = s2-s1, which is 0 if s2=s1 (case where the ref says we should return double) and not 0 if s2 != s1 (case where the ref says we should return point at infinity, which we do)

Comment on lines 159 to 164
if (z1 == 0) {
return (x2, y2, z2);
}
if (z2 == 0) {
return (x1, y1, z1);
}
Copy link
Member

Choose a reason for hiding this comment

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

Is this because adding some coordinates to the point at infinity results in the same coordinates?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For the record:

Here the check is so that (0,0,0) is a "neutral element" for the jacobian addition. We could make the check as

        if (x1 == 0 && y1 == 0 && z1 == 0) {
            return (x2, y2, z2);
        }
        if (x2 == 0 && y2 == 0 && z2 == 0) {
            return (x1, y1, z1);
        }

but that would be more expensive (and that function is called 140 times when you verify)

contracts/utils/cryptography/P256.sol Outdated Show resolved Hide resolved
Co-authored-by: Ernesto García <ernestognw@gmail.com>
@Amxx
Copy link
Collaborator Author

Amxx commented Apr 25, 2024

Relevant source for discussion: https://www.hyperelliptic.org/EFD/g1p/auto-shortw-jacobian.html

Comment on lines 254 to 256
if (pos > 0) {
(x, y, z) = _jAdd(x, y, z, points[pos].x, points[pos].y, points[pos].z);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So here the if is optional.

If we remove the if, we are going to load points[0] which is (0,0,0) ... and the _jAdd will skip that as the "neutral element". The if here as a cost. 15/16 we pay it for no real reason (and we still pay the check in _jAdd). 1/16 the if avoids the overhead of a function call.

I'm going to benchmark which one is better and comment that so we don't go back and forward.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I checked, skipping the mloads in 1/16 cases is a bigger gain than the loss of the if in the other 15/16 cases. Keeping the if is the more effective solution here

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

Successfully merging this pull request may close these issues.

None yet

2 participants