commit b69b5d6b4bccde2fe03f2ff7241c0e30d2600162
parent a4efd068975fe63435e2ba566a5d75e0bf881cbc
Author: triesap <tyson@radroots.org>
Date: Fri, 5 Jun 2026 20:40:11 -0700
crypto: add pure rust schnorr verifier
Diffstat:
3 files changed, 376 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3,6 +3,18 @@
version = 4
[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -18,6 +30,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -27,6 +45,18 @@ dependencies = [
]
[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -37,13 +67,68 @@ dependencies = [
]
[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
]
[[package]]
@@ -54,6 +139,38 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
]
[[package]]
@@ -63,6 +180,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature",
+]
+
+[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -75,6 +206,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -93,6 +240,39 @@ dependencies = [
]
[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -146,6 +326,32 @@ dependencies = [
]
[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -164,6 +370,7 @@ version = "0.1.0"
name = "tangle_crypto"
version = "0.1.0"
dependencies = [
+ "k256",
"sha2",
"tangle_protocol",
]
@@ -194,6 +401,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/tangle_crypto/Cargo.toml b/crates/tangle_crypto/Cargo.toml
@@ -8,6 +8,7 @@ license.workspace = true
description = "Nostr event hashing and signature verification for tangle"
[dependencies]
+k256 = { version = "0.13", features = ["schnorr"] }
sha2 = "0.10"
tangle_protocol = { path = "../tangle_protocol" }
diff --git a/crates/tangle_crypto/src/lib.rs b/crates/tangle_crypto/src/lib.rs
@@ -1,5 +1,7 @@
#![forbid(unsafe_code)]
+use k256::schnorr::signature::Verifier;
+use k256::schnorr::{Signature, VerifyingKey};
use sha2::{Digest, Sha256};
use tangle_protocol::{Event, EventId, UnsignedEvent, canonical_event_json};
@@ -31,6 +33,27 @@ pub fn verify_event_id(event: &Event) -> Result<(), String> {
}
}
+pub fn verify_event_signature(event: &Event) -> Result<(), String> {
+ verify_event_id(event)?;
+ let event_id =
+ validated_fixed_hex_bytes(event.id().as_str(), EventId::HEX_LENGTH / 2, "event id");
+ let pubkey = fixed_hex_bytes(
+ event.unsigned().pubkey().as_str(),
+ EventId::HEX_LENGTH / 2,
+ "public key",
+ )
+ .expect("validated public key scalar decodes");
+ let signature = fixed_hex_bytes(event.sig().as_str(), 64, "signature")
+ .expect("validated signature decodes");
+ let verifying_key = VerifyingKey::from_bytes(&pubkey)
+ .map_err(|_| "event public key is not a valid secp256k1 x-only key".to_owned())?;
+ let signature = Signature::try_from(signature.as_slice())
+ .map_err(|_| "event signature is not a valid schnorr signature".to_owned())?;
+ verifying_key
+ .verify(&event_id, &signature)
+ .map_err(|_| "event signature verification failed".to_owned())
+}
+
fn lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
@@ -41,9 +64,42 @@ fn lower_hex(bytes: &[u8]) -> String {
output
}
+fn fixed_hex_bytes(value: &str, expected: usize, scalar: &str) -> Result<Vec<u8>, String> {
+ if value.len() != expected * 2 {
+ return Err(format!(
+ "{scalar} must decode to {expected} bytes, got {} hex characters",
+ value.len()
+ ));
+ }
+ let mut output = Vec::with_capacity(expected);
+ for chunk in value.as_bytes().chunks_exact(2) {
+ let high = hex_value(chunk[0], scalar)?;
+ let low = hex_value(chunk[1], scalar)?;
+ output.push((high << 4) | low);
+ }
+ Ok(output)
+}
+
+fn validated_fixed_hex_bytes(value: &str, expected: usize, scalar: &str) -> Vec<u8> {
+ fixed_hex_bytes(value, expected, scalar).expect("validated hex scalar decodes")
+}
+
+fn hex_value(value: u8, scalar: &str) -> Result<u8, String> {
+ match value {
+ b'0'..=b'9' => Ok(value - b'0'),
+ b'a'..=b'f' => Ok(value - b'a' + 10),
+ _ => Err(format!("{scalar} must be lowercase hex")),
+ }
+}
+
#[cfg(test)]
mod tests {
- use super::{compute_event_id, compute_event_id_hex, event_id_matches, verify_event_id};
+ use super::{
+ compute_event_id, compute_event_id_hex, event_id_matches, fixed_hex_bytes, lower_hex,
+ verify_event_id, verify_event_signature,
+ };
+ use k256::schnorr::signature::Signer;
+ use k256::schnorr::{Signature, SigningKey};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
};
@@ -93,6 +149,86 @@ mod tests {
);
}
+ #[test]
+ fn schnorr_verifier_accepts_deterministically_signed_event() {
+ let event = signed_event();
+
+ assert_eq!(verify_event_signature(&event), Ok(()));
+ }
+
+ #[test]
+ fn schnorr_verifier_rejects_bad_id_bad_pubkey_and_bad_signature() {
+ let event = signed_event();
+ let wrong_id = Event::new(
+ EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"),
+ event.unsigned().clone(),
+ event.sig().clone(),
+ );
+ let invalid_pubkey_unsigned = UnsignedEvent::new(
+ PublicKeyHex::new(&"f".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
+ event.unsigned().created_at(),
+ event.unsigned().kind(),
+ event.unsigned().tags().to_vec(),
+ event.unsigned().content(),
+ );
+ let invalid_pubkey = Event::new(
+ compute_event_id(&invalid_pubkey_unsigned),
+ invalid_pubkey_unsigned,
+ event.sig().clone(),
+ );
+ let invalid_signature = Event::new(
+ compute_event_id(event.unsigned()),
+ event.unsigned().clone(),
+ SignatureHex::new(&"f".repeat(SignatureHex::HEX_LENGTH)).expect("sig"),
+ );
+ let wrong_message_unsigned = UnsignedEvent::new(
+ event.unsigned().pubkey().clone(),
+ event.unsigned().created_at(),
+ event.unsigned().kind(),
+ event.unsigned().tags().to_vec(),
+ "different message",
+ );
+ let wrong_message_signature = Event::new(
+ compute_event_id(&wrong_message_unsigned),
+ wrong_message_unsigned,
+ event.sig().clone(),
+ );
+
+ assert!(
+ verify_event_signature(&wrong_id)
+ .expect_err("bad id")
+ .starts_with("event id mismatch")
+ );
+ assert_eq!(
+ verify_event_signature(&invalid_pubkey).expect_err("bad pubkey"),
+ "event public key is not a valid secp256k1 x-only key"
+ );
+ assert_eq!(
+ verify_event_signature(&invalid_signature).expect_err("bad sig"),
+ "event signature is not a valid schnorr signature"
+ );
+ assert_eq!(
+ verify_event_signature(&wrong_message_signature).expect_err("wrong message"),
+ "event signature verification failed"
+ );
+ }
+
+ #[test]
+ fn hex_decoder_rejects_bad_length_and_non_hex_input() {
+ assert_eq!(
+ fixed_hex_bytes("abc", 2, "sample").expect_err("length"),
+ "sample must decode to 2 bytes, got 3 hex characters"
+ );
+ assert_eq!(
+ fixed_hex_bytes("0G", 1, "sample").expect_err("hex"),
+ "sample must be lowercase hex"
+ );
+ assert_eq!(
+ fixed_hex_bytes("G0", 1, "sample").expect_err("hex"),
+ "sample must be lowercase hex"
+ );
+ }
+
fn unsigned_event(tags: Vec<Tag>, content: &str) -> UnsignedEvent {
UnsignedEvent::new(
PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"),
@@ -102,4 +238,23 @@ mod tests {
content,
)
}
+
+ fn signed_event() -> Event {
+ let signing_key = SigningKey::from_bytes(&[7_u8; 32]).expect("signing key");
+ let public_key =
+ PublicKeyHex::new(&lower_hex(signing_key.verifying_key().to_bytes().as_ref()))
+ .expect("pubkey");
+ let unsigned = UnsignedEvent::new(
+ public_key,
+ UnixTimestamp::new(1_714_124_433),
+ Kind::new(1).expect("kind"),
+ vec![Tag::from_parts("t", &["radroots"]).expect("tag")],
+ "radroots cafe",
+ );
+ let event_id = compute_event_id(&unsigned);
+ let event_id_bytes = fixed_hex_bytes(event_id.as_str(), 32, "event id").expect("event id");
+ let signature: Signature = signing_key.sign(&event_id_bytes);
+ let signature = SignatureHex::new(&lower_hex(signature.to_bytes().as_ref())).expect("sig");
+ Event::new(event_id, unsigned, signature)
+ }
}