tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_crypto/Cargo.toml | 1+
Mcrates/tangle_crypto/src/lib.rs | 157++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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) + } }