Skip to content

Conversation

@brice-stacks
Copy link
Contributor

This is a consensus breaking change and will need to go out in a hard-fork with a new version of Clarity (Clarity5). In the existing implementation, the code was incorrectly hashing the passed in message-hash. Unfortunately, the test code was also double-hashing on the signing side as well, so this was not caught. The bug was discovered by @eric-stacks when attempting to generate signatures externally (e.g. with WebAuthn) and verify them in Clarity code. The docs are updated to match the actual behavior and will need to be updated again once Clarity 5 is added.

This is a consensus breaking change and will need to go out in a
hard-fork with a new version of Clarity (Clarity5).
In the existing implementation, the code was incorrectly hashing the
passed in `message-hash`. Unfortunately, the test code was also
double-hashing on the signing side as well, so this was not caught. The
bug was discovered by @eric-stacks when attempting to generate
signatures externally (e.g. with WebAuthn) and verify them in Clarity
code.
@codecov
Copy link

codecov bot commented Dec 15, 2025

Codecov Report

❌ Patch coverage is 81.17647% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.67%. Comparing base (3da96c1) to head (d272eb8).
⚠️ Report is 35 commits behind head on develop.

Files with missing lines Patch % Lines
stacks-common/src/util/secp256r1.rs 80.26% 15 Missing ⚠️
clarity/src/vm/functions/crypto.rs 80.00% 1 Missing ⚠️

❌ Your project check has failed because the head coverage (72.67%) is below the target coverage (80.00%). You can increase the head coverage or adjust the target coverage.

❗ There is a different number of reports uploaded between BASE (3da96c1) and HEAD (d272eb8). Click for more details.

HEAD has 31 uploads less than BASE
Flag BASE (3da96c1) HEAD (d272eb8)
88 57
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #6763      +/-   ##
===========================================
- Coverage    78.18%   72.67%   -5.51%     
===========================================
  Files          580      580              
  Lines       361096   361175      +79     
===========================================
- Hits        282312   262488   -19824     
- Misses       78784    98687   +19903     
Files with missing lines Coverage Δ
clarity/src/vm/docs/mod.rs 88.26% <ø> (-2.49%) ⬇️
clarity/src/vm/version.rs 85.71% <100.00%> (+1.50%) ⬆️
clarity/src/vm/functions/crypto.rs 77.64% <80.00%> (-0.22%) ⬇️
stacks-common/src/util/secp256r1.rs 85.93% <80.26%> (-1.53%) ⬇️

... and 264 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3da96c1...d272eb8. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@aaronb-stacks
Copy link
Contributor

I think there's some test vectors for secp256r1 produced by NIST -- see the test vectors section at the bottom here (https://csrc.nist.gov/Projects/cryptographic-algorithm-validation-program/Component-Testing):

[P-256,SHA-256]

Msg = 44acf6b7e36c1342c2c5897204fe09504e1e2efb1a900377dbc4e7a6a133ec56
Qx = 1ccbe91c075fc7f4f033bfa248db8fccd3565de94bbfb12f3c59ff46c271bf83
Qy = ce4014c68811f9a21a1fdb2c0e6113e06db7ca93b7404e78dc7ccd5ca89a4ca9
R = f3ac8061b514795b8843e3d6629527ed2afd6b1f6a555a7acabb5e6f79c8c2ac
S = 8bf77819ca05a6b2786c76262bf7371cef97b218e96f175a3ccdda2acc058903

Msg = 9b2db89cb0e8fa3cc7608b4d6cc1dec0114e0b9ff4080bea12b134f489ab2bbc
Qx = e266ddfdc12668db30d4ca3e8f7749432c416044f2d2b8c10bf3d4012aeffa8a
Qy = bfa86404a2e9ffe67d47c587ef7a97a7f456b863b4d02cfc6928973ab5b1cb39
R = 976d3a4e9d23326dc0baa9fa560b7c4e53f42864f508483a6473b6a11079b2db
S = 1b766e9ceb71ba6c01dcd46e0af462cd4cfa652ae5017d4555b8eeefe36e1932

Msg = b804cf88af0c2eff8bbbfb3660ebb3294138e9d3ebd458884e19818061dacff0
Qx = 74ccd8a62fba0e667c50929a53f78c21b8ff0c3c737b0b40b1750b2302b0bde8
Qy = 29074e21f3a0ef88b9efdf10d06aa4c295cc1671f758ca0e4cd108803d0f2614
R = 35fb60f5ca0f3ca08542fb3cc641c8263a2cab7a90ee6a5e1583fac2bb6f6bd1
S = ee59d81bc9db1055cc0ed97b159d8784af04e98511d0a9a407b99bb292572e96

Msg = 85b957d92766235e7c880ac5447cfbe97f3cb499f486d1e43bcb5c2ff9608a1a
Qx = 322f80371bf6e044bc49391d97c1714ab87f990b949bc178cb7c43b7c22d89e1
Qy = 3c15d54a5cc6b9f09de8457e873eb3deb1fceb54b0b295da6050294fae7fd999
R = d7c562370af617b581c84a2468cc8bd50bb1cbf322de41b7887ce07c0e5884ca
S = b46d9f2d8c4bf83546ff178f1d78937c008d64e8ecc5cbb825cb21d94d670d89

Msg = 3360d699222f21840827cf698d7cb635bee57dc80cd7733b682d41b55b666e22
Qx = 1bcec4570e1ec2436596b8ded58f60c3b1ebc6a403bc5543040ba82963057244
Qy = 8af62a4c683f096b28558320737bf83b9959a46ad2521004ef74cf85e67494e1
R = 18caaf7b663507a8bcd992b836dec9dc5703c080af5e51dfa3a9a7c387182604
S = 77c68928ac3b88d985fb43fb615fb7ff45c18ba5c81af796c613dfa98352d29c

Msg = c413c4908cd0bc6d8e32001aa103043b2cf5be7fcbd61a5cec9488c3a577ca57
Qx = a32e50be3dae2c8ba3f5e4bdae14cf7645420d425ead94036c22dd6c4fc59e00
Qy = d623bf641160c289d6742c6257ae6ba574446dd1d0e74db3aaa80900b78d4ae9
R = 8524c5024e2d9a73bde8c72d9129f57873bbad0ed05215a372a84fdbc78f2e68
S = d18c2caf3b1072f87064ec5e8953f51301cada03469c640244760328eb5a05cb

Msg = 88fc1e7d849794fc51b135fa135deec0db02b86c3cd8cebdaa79e8689e5b2898
Qx = 8bcfe2a721ca6d753968f564ec4315be4857e28bef1908f61a366b1f03c97479
Qy = 0f67576a30b8e20d4232d8530b52fb4c89cbc589ede291e499ddd15fe870ab96
R = c5a186d72df452015480f7f338970bfe825087f05c0088d95305f87aacc9b254
S = 84a58f9e9d9e735344b316b1aa1ab5185665b85147dc82d92e969d7bee31ca30

Msg = 41fa8d8b4cd0a5fdf021f4e4829d6d1e996bab6b4a19dcb85585fe76c582d2bc
Qx = a88bc8430279c8c0400a77d751f26c0abc93e5de4ad9a4166357952fe041e767
Qy = 2d365a1eef25ead579cc9a069b6abc1b16b81c35f18785ce26a10ba6d1381185
R = 9d0c6afb6df3bced455b459cc21387e14929392664bb8741a3693a1795ca6902
S = d7f9ddd191f1f412869429209ee3814c75c72fa46a9cccf804a2f5cc0b7e739f

Msg = 2d72947c1731543b3d62490866a893952736757746d9bae13e719079299ae192
Qx = 1bc487570f040dc94196c9befe8ab2b6de77208b1f38bdaae28f9645c4d2bc3a
Qy = ec81602abd8345e71867c8210313737865b8aa186851e1b48eaca140320f5d8f
R = 2f9e2b4e9f747c657f705bffd124ee178bbc5391c86d056717b140c153570fd9
S = f5413bfd85949da8d83de83ab0d19b2986613e224d1901d76919de23ccd03199

Msg = e138bd577c3729d0e24a98a82478bcc7482499c4cdf734a874f7208ddbc3c116
Qx = b8188bd68701fc396dab53125d4d28ea33a91daf6d21485f4770f6ea8c565dde
Qy = 423f058810f277f8fe076f6db56e9285a1bf2c2a1dae145095edd9c04970bc4a
R = 1cc628533d0004b2b20e7f4baad0b8bb5e0673db159bbccf92491aef61fc9620
S = 880e0bbf82a8cf818ed46ba03cf0fc6c898e36fca36cc7fdb1d2db7503634430

Not sure if these would have caught the fact that we're double hashing.

@aaronb-stacks
Copy link
Contributor

Yeah -- I think we probably would have caught this if we used the NIST test vectors:

diff --git a/clarity/src/vm/tests/crypto.rs b/clarity/src/vm/tests/crypto.rs
index 05d9460320..842090421e 100644
--- a/clarity/src/vm/tests/crypto.rs
+++ b/clarity/src/vm/tests/crypto.rs
@@ -3,13 +3,81 @@ use clarity_types::VmExecutionError;
 use proptest::prelude::*;
 use stacks_common::types::chainstate::{StacksPrivateKey, StacksPublicKey};
 use stacks_common::types::{PrivateKey, StacksEpochId};
-use stacks_common::util::hash::{to_hex, Sha256Sum};
+use stacks_common::util::hash::{Sha256Sum, hex_bytes, to_hex};
 use stacks_common::util::secp256k1::MessageSignature as Secp256k1Signature;
-use stacks_common::util::secp256r1::{Secp256r1PrivateKey, Secp256r1PublicKey};
+use stacks_common::util::secp256r1::{MessageSignature, Secp256r1PrivateKey, Secp256r1PublicKey};
 
 use crate::vm::types::{ResponseData, TypeSignature, Value};
 use crate::vm::{execute_with_parameters, ClarityVersion};
 
+struct NistVector {
+    msg: &'static str,
+    d: &'static str,
+    q_x: &'static str,
+    q_y: &'static str,
+    r: &'static str,
+    s: &'static str,
+}
+
+static NIST_VECTORS: &[NistVector] = &[
+    NistVector {
+        msg: "44acf6b7e36c1342c2c5897204fe09504e1e2efb1a900377dbc4e7a6a133ec56",
+        d: "519b423d715f8b581f4fa8ee59f4771a5b44c8130b4e3eacca54a56dda72b464",
+        q_x: "1ccbe91c075fc7f4f033bfa248db8fccd3565de94bbfb12f3c59ff46c271bf83",
+        q_y: "ce4014c68811f9a21a1fdb2c0e6113e06db7ca93b7404e78dc7ccd5ca89a4ca9",
+        r: "f3ac8061b514795b8843e3d6629527ed2afd6b1f6a555a7acabb5e6f79c8c2ac",
+        s: "8bf77819ca05a6b2786c76262bf7371cef97b218e96f175a3ccdda2acc058903",
+    }
+];
+
+#[rstest]
+#[case(0)]
+fn secp256r1_verify_valid_signatures_nist(
+    #[case] nist_index: usize
+) {
+    let NistVector { msg, d, q_x, q_y, r, s } = NIST_VECTORS[nist_index];
+
+    let message_hash = hex_bytes(msg).unwrap();
+    let privk = Secp256r1PrivateKey::from_hex(d).unwrap();
+    let mut pubk = Secp256r1PublicKey::from_hex(&format!("04{q_x}{q_y}")).unwrap();
+    pubk.set_compressed(true);
+
+    let signature_dh = privk.sign(&message_hash).unwrap();
+    let signature_nist = MessageSignature::from_hex(&format!("{r}{s}")).unwrap();
+
+    let verified_double_hash = pubk.verify(&message_hash, &signature_dh).is_ok();
+    let verified_nist_sign = pubk.verify_digest(&message_hash, &signature_nist).is_ok();
+
+    info!(
+        "Signed with SK";
+        "nist_signature" => format!("{r}{s}"),
+        "double_hash" => to_hex(&signature_dh.0),
+        "pubk" => to_hex(&pubk.to_bytes()),
+        "verified_double_hash" => verified_double_hash,
+        "verified_nist_sign" => verified_nist_sign,
+    );
+
+    let program = format!(
+        "(secp256r1-verify {} {} {})",
+        buff_literal(&message_hash),
+        buff_literal(&signature_nist.0),
+        buff_literal(&pubk.to_bytes())
+    );
+
+    assert_eq!(
+        Value::Bool(true),
+        execute_with_parameters(
+            program.as_str(),
+            ClarityVersion::Clarity4,
+            StacksEpochId::Epoch33,
+            false
+        )
+        .expect("execution should succeed")
+        .expect("should return a value")
+    );
+}
+
+
 fn secp256r1_vectors() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
     let privk = Secp256r1PrivateKey::from_seed(&[7u8; 32]);
     let pubk = Secp256r1PublicKey::from_private(&privk);

That test fails:

running 1 test
INFO [1766004780.258038] [clarity/src/vm/tests/crypto.rs:51] [vm::tests::crypto::secp256r1_verify_valid_signatures_nist::case_1] Signed with SK, nist_signature: f3ac8061b514795b8843e3d6629527ed2afd6b1f6a555a7acabb5e6f79c8c2ac8bf77819ca05a6b2786c76262bf7371cef97b218e96f175a3ccdda2acc058903, double_hash: c07f87be9e3531eb0b637e253bd26653c55fdfb8a42abc6a614802e0bf340b8dc7ee6dfab74cde5a20e3ceefdd732d5b409ccd4085dae379fbe647bf87b66c85, pubk: 031ccbe91c075fc7f4f033bfa248db8fccd3565de94bbfb12f3c59ff46c271bf83, verified_double_hash: true, verified_nist_sign: true
test vm::tests::crypto::secp256r1_verify_valid_signatures_nist::case_1 ... FAILED

failures:

---- vm::tests::crypto::secp256r1_verify_valid_signatures_nist::case_1 stdout ----

thread 'vm::tests::crypto::secp256r1_verify_valid_signatures_nist::case_1' (15778) panicked at clarity/src/vm/tests/crypto.rs:67:5:
assertion `left == right` failed
  left: Bool(true)
 right: Bool(false)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

But you can see that it would have passed with pre-hash verification.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants