Skip to content

Commit

Permalink
Add ECDSA over secp256k1 signatures and verification (#490)
Browse files Browse the repository at this point in the history
* [zoo] add generator for secp256k1

* [ECDSA] add initial ECDSA signing / verifying implementation

* [ecdsa] fix imports

* [ecdsa] export Secp256k1 as `C` for convenience

(We might want to take this out before we merge?)

* [ecdsa] export `toDER` proc

* [ecdsa] use `isZero` instead of old zero comparison

* [ecdsa] rename private key generator & add private -> public key

* [tests] add test cases for ECDSA signature verification

Adds a test vector generator and a test case (and test vectors) for
verification of OpenSSL generated signatures.

* [ecdsa] handle some `.noinit.` cases

* [ecdsa] turn `toBytes`, `arrayWith` into in-place procedures

* [ecdsa] clean up comment about Fp -> Fr conversion

* [ecdsa] replace toPemPrivateKey/PublicKey by in-place array variants

* [ecdsa] replace `toDER` by non allocating variant

Inspired by Bitcoin's implementation:

https://github.com/bitcoin-core/secp256k1/blob/f79f46c70386c693ff4e7aef0b9e7923ba284e56/src/ecdsa_impl.h#L171-L193

Essentially, we just have a 72 byte buffer and only fill it up to
`len` bytes.

* [ecdsa] replace out-of-place arithmetic by in-place

* [ecdsa] move ECDSA implementation to ~signatures~ directory

* [ecdsa] remove dependence on explicit SHA256 hash function

* [ecdsa] make DERSignature generic under curve by having static size

The size is now a static argument of the type, depending on the
curve. `DERSigSize` can be used to calculate the size required for the
given curve.

* [ecdsa] turn more procs generic over curve and hash function

* [ecdsa] replace sign/verify API by one matching BLS signatures

* [ecdsa] remove global curve & generator constants

* [ecdsa] correctly handle truncation of digests > Fr BigInts

* create file for common signature ops, `derivePubkey` for ECDSA & BLS

Also cleans up the imports of the ECDSA file and adds the copyright header

* create file specifically for ECDSA over secp256k1

* [ecdsa] add `fromDER` to split DER encoded signature back into r, s arrays

Written for OpenSSL interop for test suite (to be moved to
serialization with the other related procs).

* [tests] add OpenSSL wrapper intended for test cases

* [tests] first step towards OpenSSL tests

Next is to merge this into the actual test case and avoid the JSON
intermediate stage altogether.

* [tests] fully avoid JSON intermediary files for ECDSA tests

* [tests] rename file back to test case name, add DERSigSize tests

* [tests] also test our DER encoder

* [tests] extend OpenSSL wrapper for required functionality

* [tests] move openssl wrapper to root of tests to share between tests

* [tests] add test case to verify PEM file writer

* [ecdsa] clean up and fix PEM file writers

The total length of the public key was wrong. Needs to be 86 bytes and
not 88. I counted the SEQUENCE and total length bytes by accident.

* [tests] [bench] use shared OpenSSL wrapper where appropriate

* [codecs] move serialization logic to ecdsa secp256k1 submodule

* [codecs] move DER signature serialization to codecs_ecdsa submodule

* [ecdsa] adjust ECDSA secp256k1 API & test cases

* [ecdsa] add mini docstring for `verify`

* [codecs] clean up imports in `codecs_ecdsa.nim`

* [ecdsa] clean up imports of `ecdsa_secp256k1.nim`

* [ecdsa] do not export `raw` field in ecdsa_secp256k1

* [CI] fix CI failures by including OpenSSL wrapper instead of import

Not sure what's happening here. Never seen that with any C wrappers.

* [bench] disable OpenSSL bench for sha256 on windows

API not currently available in the OpenSSL version on GH actions

* [nimble] add ECDSA signature test to nimble task

* [ecdsa] replace brainfart using pointer size for bits in byte

...

* [ecdsa] fix final related brainfart :)

* [tests] when the brainfart infects the test cases too! 🤯

* replace DERSig* by DerSig*

* replace `toPemFile` by simply `toPem`

* rename `common_signature_ops` to `ecc_sig_ops`

to clarify that these are only for EC based signatures.

* [tests] disable ECDSA test for Windows

* [ecdsa] avoid awkward arrayWith declaration & call
  • Loading branch information
Vindaar authored Jan 5, 2025
1 parent 64ae9c8 commit dca3a85
Show file tree
Hide file tree
Showing 13 changed files with 1,123 additions and 91 deletions.
45 changes: 11 additions & 34 deletions benchmarks/bench_h_sha256.nim
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,16 @@ import
helpers/prng_unsafe,
./bench_blueprint

proc separator*() = separator(69)

# Deal with platform mess
# --------------------------------------------------------------------
when defined(windows):
when sizeof(int) == 8:
const DLLSSLName* = "(libssl-1_1-x64|ssleay64|libssl64).dll"
else:
const DLLSSLName* = "(libssl-1_1|ssleay32|libssl32).dll"
else:
when defined(macosx) or defined(macos) or defined(ios):
const versions = "(.1.1|.38|.39|.41|.43|.44|.45|.46|.47|.48|.10|.1.0.2|.1.0.1|.1.0.0|.0.9.9|.0.9.8|)"
else:
const versions = "(.1.1|.1.0.2|.1.0.1|.1.0.0|.0.9.9|.0.9.8|.48|.47|.46|.45|.44|.43|.41|.39|.38|.10|)"
## NOTE: For a reason that evades me at the moment, if we only `import`
## the wrapper, we get a linker error of the form:
##
## @mopenssl_wrapper.nim.c:(.text+0x110): undefined reference to `Dl_1073742356_'
## /usr/bin/ld: warning: creating DT_TEXTREL in a PIE
##
## So for the moment, we just include the wrapper.
include ../tests/openssl_wrapper

when defined(macosx) or defined(macos) or defined(ios):
const DLLSSLName* = "libssl" & versions & ".dylib"
elif defined(genode):
const DLLSSLName* = "libssl.lib.so"
else:
const DLLSSLName* = "libssl.so" & versions

# OpenSSL wrapper
# --------------------------------------------------------------------

proc SHA256[T: byte|char](
msg: openarray[T],
digest: ptr array[32, byte] = nil
): ptr array[32, byte] {.noconv, dynlib: DLLSSLName, importc.}

proc SHA256_OpenSSL[T: byte|char](
digest: var array[32, byte],
s: openarray[T]) =
discard SHA256(s, digest.addr)
proc separator*() = separator(69)

# --------------------------------------------------------------------

Expand Down Expand Up @@ -80,6 +56,7 @@ when isMainModule:
let msg = rng.random_byte_seq(s)
let iters = int(target_cycles div (s.int64 * worst_cycles_per_bytes))
benchSHA256_constantine(msg, $s & "B", iters)
benchSHA256_openssl(msg, $s & "B", iters)
when not defined(windows): # not available on Windows in GH actions atm
benchSHA256_openssl(msg, $s & "B", iters)

main()
3 changes: 3 additions & 0 deletions constantine.nimble
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,9 @@ const testDesc: seq[tuple[path: string, useGMP: bool]] = @[
("tests/t_ethereum_verkle_primitives.nim", false),
("tests/t_ethereum_verkle_ipa_primitives.nim", false),

# Signatures
("tests/ecdsa/t_ecdsa_verify_openssl.nim", false),

# Proof systems
# ----------------------------------------------------------
("tests/proof_systems/t_r1cs_parser.nim", false),
Expand Down
68 changes: 68 additions & 0 deletions constantine/ecdsa_secp256k1.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Constantine
# Copyright (c) 2018-2019 Status Research & Development GmbH
# Copyright (c) 2020-Present Mamy André-Ratsimbazafy
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at http://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at http://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.

import
constantine/zoo_exports,
constantine/signatures/ecdsa,
constantine/hashes/h_sha256,
constantine/named/algebras,
constantine/math/elliptic/[ec_shortweierstrass_affine],
constantine/math/[arithmetic, ec_shortweierstrass],
constantine/platforms/[abstractions, views]

export NonceSampler

const prefix_ffi = "ctt_ecdsa_secp256k1_"
type
SecretKey* {.byref, exportc: prefix_ffi & "seckey".} = object
## A Secp256k1 secret key
raw: Fr[Secp256k1]

PublicKey* {.byref, exportc: prefix_ffi & "pubkey".} = object
## A Secp256k1 public key for ECDSA signatures
raw: EC_ShortW_Aff[Fp[Secp256k1], G1]

Signature* {.byref, exportc: prefix_ffi & "signature".} = object
## A Secp256k1 signature for ECDSA signatures
r: Fr[Secp256k1]
s: Fr[Secp256k1]

func pubkey_is_zero*(pubkey: PublicKey): bool {.libPrefix: prefix_ffi.} =
## Returns true if input is 0
bool(pubkey.raw.isNeutral())

func pubkeys_are_equal*(a, b: PublicKey): bool {.libPrefix: prefix_ffi.} =
## Returns true if inputs are equal
bool(a.raw == b.raw)

func signatures_are_equal*(a, b: Signature): bool {.libPrefix: prefix_ffi.} =
## Returns true if inputs are equal
bool(a.r == b.r and a.s == b.s)

proc sign*(sig: var Signature,
secretKey: SecretKey,
message: openArray[byte],
nonceSampler: NonceSampler = nsRandom) {.libPrefix: prefix_ffi, genCharAPI.} =
## Sign `message` using `secretKey` and store the signature in `sig`. The nonce
## will either be randomly sampled `nsRandom` or deterministically calculated according
## to RFC6979 (`nsRfc6979`)
sig.coreSign(secretKey.raw, message, sha256, nonceSampler)

proc verify*(
publicKey: PublicKey,
message: openArray[byte],
signature: Signature
): bool {.libPrefix: prefix_ffi, genCharAPI.} =
## Verify `signature` using `publicKey` for `message`.
result = publicKey.raw.coreVerify(message, signature, sha256)

func derive_pubkey*(public_key: var PublicKey, secret_key: SecretKey) {.libPrefix: prefix_ffi.} =
## Derive the public key matching with a secret key
##
## The secret_key MUST be validated
public_key.raw.derivePubkey(secret_key.raw)
26 changes: 26 additions & 0 deletions constantine/named/constants/secp256k1_generators.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Constantine
# Copyright (c) 2018-2019 Status Research & Development GmbH
# Copyright (c) 2020-Present Mamy André-Ratsimbazafy
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at http://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at http://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.

import
constantine/named/algebras,
constantine/math/elliptic/ec_shortweierstrass_affine,
constantine/math/io/[io_fields, io_extfields]

{.used.}

# Generators
# -----------------------------------------------------------------
# https://www.secg.org/sec2-v2.pdf page 9 (13 of PDF), sec. 2.4.1

# The group G_1 (== G) is defined on the curve Y^2 = X^3 + 7 over the field F_p
# with p = 2^256 - 2^32 - 2^9 - 2^8 - 2^7 - 2^6 - 2^4 - 1
# with generator:
const Secp256k1_generator_G1* = EC_ShortW_Aff[Fp[Secp256k1], G1](
x: Fp[Secp256k1].fromHex"0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798",
y: Fp[Secp256k1].fromHex"0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"
)
3 changes: 2 additions & 1 deletion constantine/named/zoo_generators.nim
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import
./constants/bls12_381_generators,
./constants/bn254_snarks_generators,
./constants/bandersnatch_generators,
./constants/banderwagon_generators
./constants/banderwagon_generators,
./constants/secp256k1_generators

{.experimental: "dynamicbindsym".}

Expand Down
162 changes: 162 additions & 0 deletions constantine/serialization/codecs_ecdsa.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Constantine
# Copyright (c) 2018-2019 Status Research & Development GmbH
# Copyright (c) 2020-Present Mamy André-Ratsimbazafy
# Licensed and distributed under either of
# * MIT license (license terms in the root directory or at http://opensource.org/licenses/MIT).
# * Apache v2 license (license terms in the root directory or at http://www.apache.org/licenses/LICENSE-2.0).
# at your option. This file may not be copied, modified, or distributed except according to those terms.

##[
Performs (de-)serialization of ECDSA signatures into ASN.1 DER encoded
data following SEC1:
https://www.secg.org/sec1-v2.pdf
In contrast to `codecs_ecdsa_secp256k1.nim` this file is generic under the choice
of elliptic curve.
]##

import
constantine/named/algebras,
constantine/math/io/[io_bigints, io_fields],
constantine/math/elliptic/[ec_shortweierstrass_affine],
constantine/platforms/[abstractions, views],
constantine/serialization/codecs # for fromHex and (in the future) base64 encoding

type
## Helper type for ASN.1 DER signatures to avoid allocation.
## Has a `data` buffer of 72 bytes (maximum possible size for
## a signature for `secp256k1`) and `len` of actually used data.
## `data[0 ..< len]` is the actual signature.
DerSignature*[N: static int] = object
data*: array[N, byte] # Max size: 6 bytes overhead + 33 bytes each for r,s
len*: int # Actual length used

template DerSigSize*(Name: static Algebra): int =
const OctetWidth = 8
6 + 2 * (Fr[Name].bits.ceilDiv_vartime(OctetWidth) + 1)

proc toDER*[Name: static Algebra; N: static int](derSig: var DerSignature[N], r, s: Fr[Name]) =
## Converts signature (r,s) to DER format without allocation.
## Max size is 72 bytes (for Secp256k1 or any curve with 32 byte scalars in `Fr`):
## 6 bytes overhead + up to 32+1 bytes each for r,s.
## 6 byte 'overhead' for:
## - `0x30` byte SEQUENCE designator
## - total length of the array
## - integer type designator `0x02` (before `r` and `s`)
## - length of `r` and `s`
##
## Implementation follows ideas of Bitcoin's secp256k1 implementation:
## https://github.com/bitcoin-core/secp256k1/blob/f79f46c70386c693ff4e7aef0b9e7923ba284e56/src/ecdsa_impl.h#L171-L193
const OctetWidth = 8
const N = Fr[Name].bits.ceilDiv_vartime(OctetWidth) # 32 for `secp256k1`

template toByteArray(x: Fr[Name]): untyped =
## Convert to a 33 byte array. Leading zero byte required if
## first real byte (idx 1) highest bit set (> 0x80).
var a: array[N+1, byte]
discard toOpenArray[byte](a, 1, N).marshal(x.toBig(), bigEndian)
a

# 1. Prepare the data & determine required sizes

# Convert r,s to big-endian bytes
var rBytes = r.toByteArray()
var sBytes = s.toByteArray()
var rLen = N + 1
var sLen = N + 1

# Skip leading zeros but ensure high bit constraint
var rPos = 0
while rLen > 1 and rBytes[rPos] == 0 and (rBytes[rPos+1] < 0x80.byte):
dec rLen
inc rPos
var sPos = 0
while sLen > 1 and sBytes[sPos] == 0 and (sBytes[sPos+1] < 0x80.byte):
dec sLen
inc sPos

# Set total length
derSig.len = 6 + rLen + sLen


# 2. Write the actual data
var pos = 0
template setInc(val: byte): untyped =
# Set `val` at `pos` and increase `pos`
derSig.data[pos] = val
inc pos

# Write DER structure, global
setInc 0x30 # sequence
setInc (4 + rLen + sLen).byte # total length

# `r` prefix
setInc 0x02 # integer
setInc rLen.byte # length of `r`
# Write `r` bytes in valid region
derSig.data.rawCopy(pos, rBytes, rPos, rLen)
inc pos, rLen

# `s` prefix
setInc 0x02 # integer
setInc sLen.byte # length of `s`
# Write `s` bytes in valid region
derSig.data.rawCopy(pos, sBytes, sPos, sLen)
inc pos, sLen

assert derSig.len == pos

proc fromRawDER*(r, s: var array[32, byte], sig: openArray[byte]): bool =
## Extracts the `r` and `s` values from a given DER signature.
##
## Returns `true` if the input is a valid DER encoded signature
## for `secp256k1` (or any curve with 32 byte scalars).
var pos = 0

template checkInc(val: untyped): untyped =
if pos > sig.high or sig[pos] != val:
# Invalid signature
return false
inc pos
template readInc(val: untyped): untyped =
if pos > sig.high:
return false
val = sig[pos]
inc pos

checkInc(0x30) # SEQUENCE
var totalLen: byte; readInc(totalLen)

template parseElement(el: var array[32, byte]): untyped =
var eLen: byte; readInc(eLen) # len of `r`
if pos + eLen.int > sig.len: # would need more data than available
return false
# read `r` into *last* `rLen` bytes
var eStart = el.len - eLen.int
if eStart < 0: # indicates prefix 0 due to first byte >= 0x80 (highest bit set)
doAssert eLen == 33
inc pos # skip first byte
eStart = 0 # start from 0 in `el`
dec eLen # decrease eLen by 1
el.rawCopy(eStart, sig, pos, eLen.int)
inc pos, eLen.int

# `r`
checkInc(0x02) # INTEGER
parseElement(r)

# `s`
checkInc(0x02) # INTEGER
parseElement(s)

# NOTE: `totalLen` does not include the prefix [0x30, totalLen] 2 bytes. Hence -2.
assert pos - 2 == totalLen.int, "Pos = " & $pos & ", totalLen = " & $totalLen

result = true

proc fromDER*(r, s: var array[32, byte], derSig: DerSignature) =
## Splits a given `DerSignature` back into the `r` and `s` elements as
## raw byte arrays.
fromRawDER(r, s, derSig.data)
Loading

0 comments on commit dca3a85

Please sign in to comment.