Skip to content

Commit

Permalink
Merge pull request #9 from borislav-itskov/schnorr-signer
Browse files Browse the repository at this point in the history
Schnorr signer & Multisig Provider
  • Loading branch information
borislav-itskov authored Oct 24, 2024
2 parents 5b64c78 + 733e00b commit 392681c
Show file tree
Hide file tree
Showing 23 changed files with 726 additions and 507 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
run: npx hardhat node & npm run test:coverage

- name: Archive code coverage results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: reports/coverage/lcov.info
229 changes: 146 additions & 83 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
# Schnorr Signatures

A javaScript library for signing and verifying Schnorr Signatures.
It can be used for single and multi signatures.
Blockchain validation via ecrecover is also supported.
Blockchain validation via ecrecover is also supported.

# Typescript support

Since version 2.0.0, we're moving entirely to Typescript.

## Version 2.0 Breaking changes
* `sign()` and `multiSigSign()` return an instance of `SignatureOutput`. Each element in it has a buffer property
* instead of `e` we return `challenge` for the Schnorr Challenge. To accces its value, use `challenge.buffer`
* instead of `s` we return `signature` for the Schnorr Signature. To accces its value, use `signature.buffer`
* instead of `R` we return `publicNonce` for the nonce. To accces its value, use `publicNonce.buffer`
* `getCombinedPublicKey()` returns a `Key` class. To get the actual key, use `key.buffer`
* a lot of method become static as they don't keep any state:
* `verify`
* `sign`
* `sumSigs`
* `getCombinedPublicKey`
* `getCombinedAddress`

- `sign()` and `multiSigSign()` return an instance of `SignatureOutput`. Each element in it has a buffer property
- instead of `e` we return `challenge` for the Schnorr Challenge. To accces its value, use `challenge.buffer`
- instead of `s` we return `signature` for the Schnorr Signature. To accces its value, use `signature.buffer`
- instead of `R` we return `publicNonce` for the nonce. To accces its value, use `publicNonce.buffer`
- `getCombinedPublicKey()` returns a `Key` class. To get the actual key, use `key.buffer`
- a lot of method become static as they don't keep any state:
- `verify`
- `sign`
- `sumSigs`
- `getCombinedPublicKey`
- `getCombinedAddress`

## Version 3.0 Breaking changes
* `finalPublicNonce`, `FinalPublicNonce` is replaced everywhere with `publicNonce`, `PublicNonce`. The old name just didn't make sense.
* `sign()` is the former `signHash()`. A sign function that accepts a plain-text message as an argument no longer exists.
* `multiSigSign()` is the former `multiSigSignHash()`. A sign function that accepts a plain-text message as an argument no longer exists.
* `verify()` is the former `verifyHash()`. A verification function that accepts a plain-text message as an argument no longer exists.

- `finalPublicNonce`, `FinalPublicNonce` is replaced everywhere with `publicNonce`, `PublicNonce`. The old name just didn't make sense.
- `sign()` is the former `signHash()`. A sign function that accepts a plain-text message as an argument no longer exists.
- `multiSigSign()` is the former `multiSigSignHash()`. A sign function that accepts a plain-text message as an argument no longer exists.
- `verify()` is the former `verifyHash()`. A verification function that accepts a plain-text message as an argument no longer exists.

In version 2, we had plenty of ways to sign a message. This broad a lot of confusion as to what function was the correct one to use in various situations. This lead us to believe that making things simpler and forcing a hash to be passed to the methods is the way forward.

## Requirements:

* Node: >=16.0.0, <20.0.0
* npm (Node.js package manager) v9.x.x
- Node: >=16.0.0, <20.0.0
- npm (Node.js package manager) v9.x.x

## Installation

Expand All @@ -41,37 +45,42 @@ npm i
```

## Testing

```
npm run test
```

## Usage

### Single Signatures

We refer to Single Signatures as ones that have a single signer.

Sign:
```js
import Schnorrkel from '@borislav.itskov/schnorrkel.js'

const privateKey = new Key(Buffer.from(ethers.utils.randomBytes(32)))
const msg = 'test message'
const hash = ethers.utils.hashMessage(msg)
const {signature, publicNonce, challenge} = Schnorrkel.sign(privateKey, hash)
```js
import { SchnorrSigner } from "@borislav.itskov/schnorrkel.js";
import { hexlify, randomBytes, hashMessage } from "ethers/lib/utils";

const privateKey = hexlify(randomBytes(32));
const signer = new SchnorrSigner(pk1);
const msg = "test message";
const commitment = hashMessage(msg);
const signature = signer.sign(commitment);
```

Offchain verification:
We take the `signature`, `hash` and `publicNonce` from the example above and do:
We take the `signature` and `hash` from the example above and do:

```js
const publicKey = Buffer.from(secp256k1.publicKeyCreate(privateKey.buffer))
// signature and publicNonce come from Schnorrkel.sign
const result = Schnorrkel.verify(signature, hash, publicNonce, publicKey)
const result = signer.verify(hash, signature);
```

Onchain verification:

First, you will need a contract that verifies schnorr. We have it in the repository and it is called `SchnorrAccountAbstraction`.
But all in all, you need this onchain:

```js
function verifySchnorr(bytes32 hash, bytes memory sig) internal pure returns (bool) {
// px := public key x-coord
Expand All @@ -97,35 +106,27 @@ We explain how ecrecover works and why it is needed later [in this document](#ec
Let's send a request to the local hardhat node. First run in the terminal:
npx hardhat node
Afterwards, here is part of the code:

```js
import { ethers } from 'ethers'
import secp256k1 from 'secp256k1'

const privateKey = new Key(Buffer.from(ethers.utils.randomBytes(32)))
const publicKey = secp256k1.publicKeyCreate(ethers.utils.arrayify(privateKey))
const px = publicKey.slice(1, 33)
const pxGeneratedAddress = ethers.utils.hexlify(px)
const schnorrAddr = '0x' + pxGeneratedAddress.slice(pxGeneratedAddress.length - 40, pxGeneratedAddress.length)
const factory = new ethers.ContractFactory(SchnorrAccountAbstraction.abi, SchnorrAccountAbstraction.bytecode, wallet)
const contract: any = await factory.deploy([schnorrAddr])

const pkBuffer = new Key(Buffer.from(ethers.utils.arrayify(privateKey)))
const msg = 'just a test message';
const msgHash = ethers.utils.hashMessage(msg)
const sig = Schnorrkel.sign(pkBuffer, msgHash)

// wrap the result
const publicKey = secp256k1.publicKeyCreate(ethers.utils.arrayify(privateKey))
const px = publicKey.slice(1, 33);
const parity = publicKey[0] - 2 + 27;
const abiCoder = new ethers.utils.AbiCoder();
const sigData = abiCoder.encode([ "bytes32", "bytes32", "bytes32", "uint8" ], [
px,
sig.challenge.buffer,
sig.signature.buffer,
parity
]);
const result = await contract.isValidSignature(msgHash, sigData);
import { SchnorrSigner } from "@borislav.itskov/schnorrkel.js";
import { hexlify, randomBytes, hashMessage } from "ethers/lib/utils";
import { ContractFactory } from "ethers";

const privateKey = hexlify(randomBytes(32));
const signer = new SchnorrSigner(privateKey);
const factory = new ContractFactory(
SchnorrAccountAbstraction.abi,
SchnorrAccountAbstraction.bytecode,
wallet
);
const contract: any = await factory.deploy([signer.getSchnorrAddress()]);
const msg = "just a test message";
const commitment = hashMessage(msg);
const sig = signer.sign(commitment);
const result = await contract.isValidSignature(
commitment,
signer.getEcrecoverSignature(sig)
);
```

You can see the full implementation in `tests/schnorrkel/onchainSingleSign.test.ts` in this repository.
Expand All @@ -135,58 +136,119 @@ You can see the full implementation in `tests/schnorrkel/onchainSingleSign.test.
Schnorr multisignatures work on the basis n/n - all of the signers need to sign in order for the signature to be valid.
Below are all the steps needed to craft a successful multisig.

### MultisigProvider

To make multisig easier, a `SchnorrMultisigProvider` class was created. It expects all `SchnorrProvider` objects that participate in the multisig. The `SchnorrProvider` can be passed by itself or one could use the `SchnorrSigner`. The meaninful point is that you don't need possession of the private keys to use the `SchnorrMultisigProvider`. It's goal is to provider helper functions for fetching the correct on-chain schnorr address, the combined public key of all the signers and provide a easy way to fetch the expected on-chain structure for validation

```js
import {
SchnorrSigner,
SchnorrMultisigProvider,
} from "@borislav.itskov/schnorrkel.js";

const signerOne = new SchnorrSigner(pk1);
const signerTwo = new SchnorrSigner(pk2);
const multisigProvider = new SchnorrMultisigProvider([signerOne, signerTwo]);
```

#### Public nonces

Public nonces need to be exchanged between signers before they sign. Normally, the Signer should implement this library as define a `getPublicNonces` method that will call the library and return the nonces. For our test example, we're going to call the schnorrkel library directly:
Public nonces need to be exchanged between signers before they sign. You can do this in two ways.
Using the multisigProvider:

```js
const privateKey1: Buffer = '...'
const privateKey2: Buffer = '...'
const publicNonces1 = schnorrkel.generatePublicNonces(privateKey1);
const publicNonces2 = schnorrkel.generatePublicNonces(privateKey2);
const publicNonces = multisigProvider.getPublicNonces();
```

Again, this isn't how the flow is supposed to work. A signer needs to implement the library and when `getPublicNonces` is called, the user should be ask whether he is okay to generate and give his public nonces.
Or manually by calling each signer individially:

```js
const publicNonces = [signerOne.getPublicNonces(), signerOne.getPublicNonces()];
```

Nonces need to be exchanged before signing can begin. Also, `getPublicNonces` should not be called again before all signers complete their signing process. Or at least one should be careful not to mixes the public nonces with newly generated ones. In the case of mixed nonces, signing will not work.

#### sign

After we have them, here is how to sign:
Here is an example of a signing process. Public keys and public nonce can be retriever either manually by calling the signer or directly by calling the multisigProvider.

```js
const publicKey1: Buffer = '...'
const publicKey2: Buffer = '...'
const publicKeys = [publicKey1, publicKey2];
const combinedPublicKey = schnorrkel.getCombinedPublicKey(publicKeys)
const {signature: sigOne, challenge: e, publicNonce} = signerOne.multiSignMessage(msg, publicKeys, publicNonces)
const {signature: sigTwo} = signerTwo.multiSignMessage(msg, publicKeys, publicNonces)
const sSummed = Schnorrkel.sumSigs([sigOne, sigTwo])
import { solidityKeccak256 } from "ethers/lib/utils";

const msg = "just a test message";
const msgHash = solidityKeccak256(["string"], [msg]);
const publicKeys = multisigProvider.getPublicKeys();
const publicNonces = multisigProvider.getPublicNonces();
const signature = signerOne.sign(msgHash, publicKeys, publicNonces);
const signatureTwo = signerTwo.sign(msgHash, publicKeys, publicNonces);
```

#### verify onchain

Generation of the encoded data for the on-chain verification is somewhat complex and therefore, it's hidden away in the `multisigProvider`.
Here's an example of how to perform an on-chain verification using the provider:

```js
const px = combinedPublicKey.buffer.slice(1,33);
const parity = combinedPublicKey.buffer[0] - 2 + 27;
const abiCoder = new ethers.utils.AbiCoder();
const sigData = abiCoder.encode([ "bytes32", "bytes32", "bytes32", "uint8" ], [
px,
challenge.buffer,
sSummed.buffer,
parity
const ecRecoverSchnorr = multisigProvider.getEcrecoverSignature([
signature,
signatureTwo,
]);
const msgHash = ethers.utils.solidityKeccak256(['string'], [msg]);
const result = await contract.isValidSignature(msgHash, sigData);
const result = await contract.isValidSignature(msgHash, ecRecoverSchnorr);
```

Here's also an example of how you can do it without the multisigProvider:

```js
import Schnorrkel from "@borislav.itskov/schnorrkel.js";
import { defaultAbiCoder } from "ethers/lib/utils";

const publicKeys = [signerOne.publicKey, signerTwo.publicKey];
const publicKey = arrayify(Schnorrkel.getCombinedPublicKey(publicKeys).buffer);
const sigOutputs = [signature, signatureTwo];
const sSummed = Schnorrkel.sumSigs(
sigOutputs.map((output) => output.signature)
);
const challenge = sigOutputs[0].challenge;
const px = publicKey.slice(1, 33);
const parity = publicKey[0] - 2 + 27;
const ecRecoverSchnorr = defaultAbiCoder.encode(
["bytes32", "bytes32", "bytes32", "uint8"],
[px, challenge.buffer, sSummed.buffer, parity]
);
const result = await contract.isValidSignature(msgHash, ecRecoverSchnorr);
```

#### verify offchain

With the multisig provider:

```js
const result = schnorrkel.verify(sSummed, msg, publicNonce, combinedPublicKey);
const result = multisigProvider.verify(msgHash, [signature, signatureTwo]);
```

Without it:

```js
import Schnorrkel from "@borislav.itskov/schnorrkel.js";

const publicKeys = [signerOne.publicKey, signerTwo.publicKey];
const sigOutputs = [signature, signatureTwo];
const sSummed = Schnorrkel.sumSigs(
sigOutputs.map((output) => output.signature)
);

return _verify(
sSummed.buffer,
msgHash,
sigOutputs[0].publicNonce.buffer,
Schnorrkel.getCombinedPublicKey(publicKeys).buffer
);
```

You can find reference to this in `tests/schnorrkel/onchainMultiSign.test.ts` in this repository.

## ecrecover

For the schnorr on-chain verification, we were inspired by the work of [noot](https://github.com/noot). Without his work, it would've required a lot more time for RnD to reach this point. You can take a look at his repository [here](https://github.com/noot/schnorr-verify)

We utilize Ethereum ecrecover to verify the signature. This is how it works:
Expand All @@ -211,6 +273,7 @@ calculate e = H(address(R) || m) and P_x = x-coordinate of P
```

pass:

```
m = -s*P_x
v = parity of P
Expand All @@ -232,5 +295,5 @@ Q = G*s - P*e // same as schnorr verify above

the returned value is address(Q).

* calculate e' = h(address(Q) || m)
* check e' == e to verify the signature.
- calculate e' = h(address(Q) || m)
- check e' == e to verify the signature.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@borislav.itskov/schnorrkel.js",
"version": "2.0.85",
"version": "3.0.0",
"description": "",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
Loading

0 comments on commit 392681c

Please sign in to comment.