UltrafastSecp256k1/cpu/src/schnorr.cpp
Vano Chkheidze 28a40d0a37
feat: v3.16.0 -- BIP-340 strict, OpenSSF hardening, FROST RFC 9591, audit infrastructure (#77)
* feat: v3.16.0 -- BIP-340 strict parsing, CT erasure, local Docker CI

Security:
- BIP-340 strict parsing: Scalar::parse_bytes_strict, FieldElement::parse_bytes_strict, SchnorrSignature::parse_strict
- CT buffer erasure via volatile function-pointer trick in schnorr_sign/ecdsa_sign
- lift_x deduplication, Y-parity fix (limbs()[0] & 1), pragma balance fix
- C ABI functions now use strict parsing internally

Audit:
- ct_sidechannel_smoke marked advisory (timing flakes on shared CI runners)
- carry_propagation test: cross-validation (generator vs generic path) + hex diagnostics for ARM64
- 31-test BIP-340 strict suite (test_bip340_strict.cpp)

Local CI (Docker):
- docker-compose.ci.yml: single-command orchestration for 14 CI jobs
- pre-push target: warnings + tests + ASan + audit in ~5 min
- audit job mirrors audit-report.yml (GCC-13 + Clang-17)
- ccache volume for fast rebuilds
- scripts/hooks/pre-push + scripts/pre-push-ci.ps1

Docs:
- COMPATIBILITY.md, BINDINGS_ERROR_MODEL.md updates
- SECURITY.md: library-side erasure, planned items checklist, API stability refs
- UFSECP_BITCOIN_STRICT CMake option
- packaging.yml release workflow race fix

Tests: 26/26 pass locally (0 failures)

* feat: ARM64 native dudect CI + ct-verif LLVM pass CI, docs update

CI:
- ct-arm64.yml: native Apple Silicon (M1) dudect -- smoke per-PR, full nightly
- ct-verif.yml: compile-time CT verification via LLVM pass (deterministic)

Docs:
- SECURITY.md: mark ARM64 dudect + ct-verif as done, update version table
- CT_VERIFICATION.md: update known limitations, planned improvements, v3.16.0
- CHANGELOG.md: add CT Verification CI section
- README.md: add CT ARM64 + CT-Verif badges

* audit: MuSig2/FROST dudect, Valgrind CT CI, SARIF output, perf regression gate

- test_ct_sidechannel.cpp: add group [9] MuSig2/FROST protocol timing
  tests (musig2_partial_sign, frost_sign, frost_lagrange_coefficient)
- unified_audit_runner.cpp: add write_sarif_report() + --sarif CLI flag
  for GitHub Code Scanning integration (SARIF v2.1.0)
- valgrind-ct.yml: new CI workflow wrapping scripts/valgrind_ct_check.sh
  (nightly + on push to main/dev)
- bench-regression.yml: per-commit benchmark regression gate (120% threshold,
  fail-on-alert: true)
- audit-report.yml: add --sarif flag + SARIF upload step for linux-gcc job,
  security-events:write permission
- SECURITY.md: check off Valgrind CT, MuSig2/FROST dudect, SARIF, perf gate
- CHANGELOG.md: document all new items under v3.16.0
- README.md: add Valgrind CT + Perf Gate workflow badges
- CT_VERIFICATION.md: check off dudect expansion + Valgrind CT taint

* v3.16.1: OpenSSF Scorecard hardening, FROST RFC 9591 tests, audit progress bar, community files

OpenSSF Scorecard (7.3 -> 9+ target):
- Pin all GitHub Actions to full SHA (codeql-action v4.32.4, upload-artifact v6.0.0)
- Add harden-runner to discord-commits, packaging RPM jobs
- Add persist-credentials: false to all checkout steps with write permissions
- Standardize action versions across 13 workflow files

FROST RFC 9591 Protocol Invariant Tests:
- test_rfc9591_invariants: 7 invariants (verification share, Lagrange interpolation,
  Feldman VSS, partial sig linearity, partial sig verification, wrong share rejection,
  nonce commitment consistency)
- test_rfc9591_3of5: exhaustive 3-of-5 signing over all C(5,3)=10 subsets

Audit Sub-test Progress Visibility:
- New audit_check.hpp: centralized CHECK macro with 20-char ASCII progress bar
- Migrated all 22 audit .cpp files to use shared CHECK macro
- Windows-safe unbuffered stdout (setvbuf _IONBF)

New Audit Modules:
- test_musig2_bip327_vectors.cpp: 35 BIP-327 reference tests
- test_ffi_round_trip.cpp: 103 FFI boundary tests
- test_fiat_crypto_vectors.cpp: expanded to 752 checks

Community Files:
- ADOPTERS.md with production/development/hobby categories
- 4 GitHub Discussion templates (Q&A, Show-and-Tell, Ideas, Integration Help)

Build: 24/26 CTest pass (2 ct_sidechannel = known Windows timing noise)
Audit: 48/49 AUDIT-READY (1 advisory dudect smoke)

* fix: valgrind_ct_check.sh binary path (audit/ not cpu/), update CHANGELOG for v3.16.0

* fix: valgrind_ct_check.sh grep -c double-zero bug (0\\n0 integer parse failure)

grep -c prints '0' on no match but exits 1. The || echo '0' fallback
appended a second '0', producing '0\n0' which broke bash [[ -eq 0 ]]
comparisons. Changed to || true with  default.
2026-03-01 17:09:31 +04:00

396 lines
14 KiB
C++

#include "secp256k1/schnorr.hpp"
#include "secp256k1/sha256.hpp"
#include "secp256k1/tagged_hash.hpp"
#include "secp256k1/multiscalar.hpp"
#include "secp256k1/config.hpp" // SECP256K1_FAST_52BIT
#include "secp256k1/field_52.hpp"
#include <cstring>
#include <string_view>
namespace secp256k1 {
using fast::Scalar;
using fast::Point;
using fast::FieldElement;
#if defined(SECP256K1_FAST_52BIT)
using FE52 = fast::FieldElement52;
#endif
// -- FE52 sqrt() and inverse() available as FieldElement52 class methods ------
// sqrt() uses FE52 ops (~4us, faster than 4x64 ~6.8us).
// inverse() uses FE52 Fermat (~4us) -- but SafeGCD (~2-3us) is faster for
// variable-time paths (point.cpp batch inverse, verify Y-parity).
// -- lift_x: shared BIP-340 x-only -> affine Point (no duplication) -----------
// Returns Point::infinity() on failure (x not on curve).
static Point lift_x(const uint8_t* x32) {
#if defined(SECP256K1_FAST_52BIT)
// Direct bytes->FE52: avoids FieldElement construction overhead
FE52 const px52 = FE52::from_bytes(x32);
// y^2 = x^3 + 7
FE52 const x3 = px52.square() * px52;
static const FE52 seven52 = FE52::from_fe(FieldElement::from_uint64(7));
FE52 const y2 = x3 + seven52;
// sqrt via FE52 addition chain: a^((p+1)/4), ~253 sqr + 13 mul
FE52 y52 = y2.sqrt();
// Verify: y^2 == y2 (check that sqrt succeeded)
FE52 check = y52.square();
check.normalize();
FE52 y2n = y2;
y2n.normalize();
if (!(check == y2n)) return Point::infinity();
// Ensure even Y (BIP-340 convention): check parity of normalized y
FE52 y_norm = y52;
y_norm.normalize();
if (y_norm.n[0] & 1) {
// Negate: y = p - y
y52 = y52.negate(1);
y52.normalize_weak();
}
// Zero-conversion: construct Point directly from FE52 affine coordinates
return Point::from_affine52(px52, y52);
#else
// Fallback: 4x64 lift_x
std::array<uint8_t, 32> px_arr;
std::memcpy(px_arr.data(), x32, 32);
auto px_fe = FieldElement::from_bytes(px_arr);
auto x3 = px_fe * px_fe * px_fe;
auto y2 = x3 + FieldElement::from_uint64(7);
auto y_fe = y2.sqrt();
auto chk = y_fe * y_fe;
if (!(chk == y2)) return Point::infinity();
// 4x64 mul_impl Barrett-reduces to [0, p), so limbs()[0] & 1 is
// the true parity -- no serialization needed.
if (y_fe.limbs()[0] & 1) y_fe = y_fe.negate();
return Point::from_affine(px_fe, y_fe);
#endif
}
// -- Shared BIP-340 tagged-hash midstates (from tagged_hash.hpp) ---------------
using detail::g_aux_midstate;
using detail::g_nonce_midstate;
using detail::g_challenge_midstate;
using detail::cached_tagged_hash;
// -- Tagged Hash (BIP-340) -- generic fallback ---------------------------------
std::array<uint8_t, 32> tagged_hash(const char* tag,
const void* data, std::size_t len) {
std::string_view const sv(tag);
auto tag_hash = SHA256::hash(sv.data(), sv.size());
SHA256 ctx;
ctx.update(tag_hash.data(), 32);
ctx.update(tag_hash.data(), 32);
ctx.update(data, len);
return ctx.finalize();
}
// -- Schnorr Signature --------------------------------------------------------
std::array<uint8_t, 64> SchnorrSignature::to_bytes() const {
std::array<uint8_t, 64> out{};
std::memcpy(out.data(), r.data(), 32);
auto s_bytes = s.to_bytes();
std::memcpy(out.data() + 32, s_bytes.data(), 32);
return out;
}
SchnorrSignature SchnorrSignature::from_bytes(const uint8_t* data64) {
SchnorrSignature sig{};
std::memcpy(sig.r.data(), data64, 32);
sig.s = Scalar::from_bytes(data64 + 32);
return sig;
}
SchnorrSignature SchnorrSignature::from_bytes(const std::array<uint8_t, 64>& data) {
return from_bytes(data.data());
}
// -- BIP-340 strict signature parsing (r < p, 0 < s < n) ---------------------
bool SchnorrSignature::parse_strict(const uint8_t* data64, SchnorrSignature& out) noexcept {
// BIP-340: fail if r >= p
FieldElement r_fe;
if (!FieldElement::parse_bytes_strict(data64, r_fe)) return false;
// BIP-340: fail if s >= n; also reject s == 0
Scalar s_val;
if (!Scalar::parse_bytes_strict_nonzero(data64 + 32, s_val)) return false;
std::memcpy(out.r.data(), data64, 32);
out.s = s_val;
return true;
}
bool SchnorrSignature::parse_strict(const std::array<uint8_t, 64>& data,
SchnorrSignature& out) noexcept {
return parse_strict(data.data(), out);
}
// -- X-only pubkey ------------------------------------------------------------
std::array<uint8_t, 32> schnorr_pubkey(const Scalar& private_key) {
auto P = Point::generator().scalar_mul(private_key);
auto [px, p_y_odd] = P.x_bytes_and_parity();
(void)p_y_odd;
return px;
}
// -- SchnorrKeypair Creation --------------------------------------------------
SchnorrKeypair schnorr_keypair_create(const Scalar& private_key) {
SchnorrKeypair kp{};
auto d_prime = private_key;
if (d_prime.is_zero()) return kp;
auto P = Point::generator().scalar_mul(d_prime);
auto [px, p_y_odd] = P.x_bytes_and_parity();
kp.d = p_y_odd ? d_prime.negate() : d_prime;
kp.px = px;
return kp;
}
// -- BIP-340 Sign (keypair variant, fast) -------------------------------------
// Uses pre-computed keypair: only 1 gen_mul + 1 FE52 inverse per sign.
SchnorrSignature schnorr_sign(const SchnorrKeypair& kp,
const std::array<uint8_t, 32>& msg,
const std::array<uint8_t, 32>& aux_rand) {
if (kp.d.is_zero()) return SchnorrSignature{};
// Step 1: t = d XOR tagged_hash("BIP0340/aux", aux_rand)
auto t_hash = cached_tagged_hash(g_aux_midstate, aux_rand.data(), 32);
auto d_bytes = kp.d.to_bytes();
uint8_t t[32];
for (std::size_t i = 0; i < 32; ++i) t[i] = d_bytes[i] ^ t_hash[i];
// Step 2: k' = tagged_hash("BIP0340/nonce", t || pubkey_x || msg)
uint8_t nonce_input[96];
std::memcpy(nonce_input, t, 32);
std::memcpy(nonce_input + 32, kp.px.data(), 32);
std::memcpy(nonce_input + 64, msg.data(), 32);
auto rand_hash = cached_tagged_hash(g_nonce_midstate, nonce_input, 96);
auto k_prime = Scalar::from_bytes(rand_hash);
if (k_prime.is_zero()) return SchnorrSignature{};
// Step 3: R = k' * G (single gen_mul -- the only expensive point op)
auto R = Point::generator().scalar_mul(k_prime);
auto [rx, r_y_odd] = R.x_bytes_and_parity();
// Step 4: k = k' if has_even_y(R), else n - k'
auto k = r_y_odd ? k_prime.negate() : k_prime;
// Step 5: e = tagged_hash("BIP0340/challenge", R.x || pubkey_x || msg)
uint8_t challenge_input[96];
std::memcpy(challenge_input, rx.data(), 32);
std::memcpy(challenge_input + 32, kp.px.data(), 32);
std::memcpy(challenge_input + 64, msg.data(), 32);
auto e_hash = cached_tagged_hash(g_challenge_midstate, challenge_input, 96);
auto e = Scalar::from_bytes(e_hash);
// Step 6: sig = (R.x, k + e * d)
SchnorrSignature sig{};
sig.r = rx;
sig.s = k + e * kp.d;
return sig;
}
// -- BIP-340 Sign (raw key, convenience) --------------------------------------
SchnorrSignature schnorr_sign(const Scalar& private_key,
const std::array<uint8_t, 32>& msg,
const std::array<uint8_t, 32>& aux_rand) {
auto kp = schnorr_keypair_create(private_key);
return schnorr_sign(kp, msg, aux_rand);
}
// -- BIP-340 Verify -----------------------------------------------------------
bool schnorr_verify(const uint8_t* pubkey_x32,
const uint8_t* msg32,
const SchnorrSignature& sig) {
// Step 0: BIP-340 strict range checks
// Check s: must be in [1, n-1] -- enforced at parse time by parse_strict,
// but also guard here for callers using from_bytes (reducing parser).
if (sig.s.is_zero()) return false;
// Check r < p: if sig.r bytes represent a value >= p, reject.
FieldElement r_fe_check;
if (!FieldElement::parse_bytes_strict(sig.r.data(), r_fe_check)) return false;
// Check pubkey x < p: if pubkey_x32 bytes represent a value >= p, reject.
FieldElement pk_fe_check;
if (!FieldElement::parse_bytes_strict(pubkey_x32, pk_fe_check)) return false;
// Step 2: e = tagged_hash("BIP0340/challenge", r || pubkey_x || msg) mod n
// Streaming SHA256: feed data directly, no intermediate buffer
SHA256 ctx = g_challenge_midstate;
ctx.update(sig.r.data(), 32);
ctx.update(pubkey_x32, 32);
ctx.update(msg32, 32);
auto e_hash = ctx.finalize();
auto e = Scalar::from_bytes(e_hash);
// Step 3: Lift x-only pubkey to point
auto P = lift_x(pubkey_x32);
if (P.is_infinity()) return false;
// Step 4: R = s*G - e*P (4-stream GLV Strauss: s*G + (-e)*P in one pass)
auto neg_e = e.negate();
auto R = Point::dual_scalar_mul_gen_point(sig.s, neg_e, P);
if (R.is_infinity()) return false;
// Steps 5+6: Combined X-check + Y-parity via single Z inverse (all FE52)
#if defined(SECP256K1_FAST_52BIT)
FE52 const z_inv52 = R.Z52().inverse_safegcd();
FE52 const z_inv2 = z_inv52.square(); // Z^-^2
// X-check: X * Z^-^2 == sig.r (affine x)
FE52 x_aff = R.X52() * z_inv2;
x_aff.normalize();
FE52 r52 = FE52::from_bytes(sig.r);
r52.normalize();
if (!(x_aff == r52)) return false;
// Y-parity: Y * Z^-^3 must be even
FE52 y_aff = (R.Y52() * z_inv2) * z_inv52;
y_aff.normalize();
return (y_aff.n[0] & 1) == 0;
#else
auto rx_fe = R.x();
auto r_fe = FieldElement::from_bytes(sig.r);
if (!(r_fe == rx_fe)) return false;
FieldElement z_inv = R.z_raw().inverse();
FieldElement z_inv2 = z_inv;
z_inv2.square_inplace();
FieldElement y_aff = R.y_raw() * z_inv2 * z_inv;
// 4x64 mul_impl Barrett-reduces to [0, p), so limbs()[0] & 1 is
// the true parity -- no serialization needed.
return (y_aff.limbs()[0] & 1) == 0;
#endif
}
// -- Pre-cached X-only Pubkey -------------------------------------------------
bool schnorr_xonly_pubkey_parse(SchnorrXonlyPubkey& out,
const uint8_t* pubkey_x32) {
// BIP-340 strict: reject x >= p (no reduction)
FieldElement x_check;
if (!FieldElement::parse_bytes_strict(pubkey_x32, x_check)) return false;
auto P = lift_x(pubkey_x32);
if (P.is_infinity()) return false;
out.point = P;
std::memcpy(out.x_bytes.data(), pubkey_x32, 32);
return true;
}
bool schnorr_xonly_pubkey_parse(SchnorrXonlyPubkey& out,
const std::array<uint8_t, 32>& pubkey_x) {
return schnorr_xonly_pubkey_parse(out, pubkey_x.data());
}
SchnorrXonlyPubkey schnorr_xonly_from_keypair(const SchnorrKeypair& kp) {
SchnorrXonlyPubkey pub{};
auto P = Point::generator().scalar_mul(kp.d);
auto [px, p_y_odd] = P.x_bytes_and_parity();
if (p_y_odd) {
#if defined(SECP256K1_FAST_52BIT)
FE52 neg_y = P.Y52().negate(1);
neg_y.normalize_weak();
P = Point::from_jacobian52(P.X52(), neg_y, P.Z52(), false);
#else
auto y_neg = P.y().negate();
P = Point::from_jacobian_coords(P.x(), y_neg, P.z(), false);
#endif
}
pub.point = P;
pub.x_bytes = px;
return pub;
}
// -- BIP-340 Verify (fast, pre-cached pubkey) ---------------------------------
// Skips lift_x sqrt (~1.6us savings). Same algorithm, just uses cached Point.
bool schnorr_verify(const SchnorrXonlyPubkey& pubkey,
const uint8_t* msg32,
const SchnorrSignature& sig) {
// BIP-340 strict: s must be nonzero
if (sig.s.is_zero()) return false;
// BIP-340 strict: r < p
FieldElement r_fe_check;
if (!FieldElement::parse_bytes_strict(sig.r.data(), r_fe_check)) return false;
// Challenge hash: streaming SHA256 (no intermediate buffer)
SHA256 ctx = g_challenge_midstate;
ctx.update(sig.r.data(), 32);
ctx.update(pubkey.x_bytes.data(), 32);
ctx.update(msg32, 32);
auto e_hash = ctx.finalize();
auto e = Scalar::from_bytes(e_hash);
// R = s*G - e*P (direct Point -- no sqrt needed)
auto neg_e = e.negate();
auto R = Point::dual_scalar_mul_gen_point(sig.s, neg_e, pubkey.point);
if (R.is_infinity()) return false;
// Combined X-check + Y-parity via single Z inverse (all FE52)
#if defined(SECP256K1_FAST_52BIT)
FE52 const z_inv52 = R.Z52().inverse_safegcd();
FE52 const z_inv2 = z_inv52.square(); // Z^-^2
// X-check: X * Z^-^2 == sig.r
FE52 x_aff = R.X52() * z_inv2;
x_aff.normalize();
FE52 r52 = FE52::from_bytes(sig.r);
r52.normalize();
if (!(x_aff == r52)) return false;
// Y-parity: Y * Z^-^3 must be even
FE52 y_aff = (R.Y52() * z_inv2) * z_inv52;
y_aff.normalize();
return (y_aff.n[0] & 1) == 0;
#else
auto rx_fe = R.x();
auto r_fe = FieldElement::from_bytes(sig.r);
if (!(r_fe == rx_fe)) return false;
FieldElement z_inv = R.z_raw().inverse();
FieldElement z_inv2 = z_inv;
z_inv2.square_inplace();
FieldElement y_aff = R.y_raw() * z_inv2 * z_inv;
// 4x64 mul_impl Barrett-reduces to [0, p) -- limbs()[0] LSB is parity.
return (y_aff.limbs()[0] & 1) == 0;
#endif
}
// -- Array wrappers (delegate to raw-pointer implementations) -----------------
bool schnorr_verify(const std::array<uint8_t, 32>& pubkey_x,
const std::array<uint8_t, 32>& msg,
const SchnorrSignature& sig) {
return schnorr_verify(pubkey_x.data(), msg.data(), sig);
}
bool schnorr_verify(const std::array<uint8_t, 32>& pubkey_x,
const uint8_t* msg32,
const SchnorrSignature& sig) {
return schnorr_verify(pubkey_x.data(), msg32, sig);
}
bool schnorr_verify(const SchnorrXonlyPubkey& pubkey,
const std::array<uint8_t, 32>& msg,
const SchnorrSignature& sig) {
return schnorr_verify(pubkey, msg.data(), sig);
}
} // namespace secp256k1