Skip to content

Commit d298d91

Browse files
LLFournclaude
andcommitted
feat: implement BIP340 domain separation with 33-byte prefix
Before BIP340 was finalized, schnorr_fun implemented domain separation using a 64-byte padded prefix based on early discussions. Now that BIP340 has standardized domain separation, it recommends using either: - Pre-hashing with a domain-specific hash function (32 bytes), or - Prefixing with a 33-byte padded context string This commit adds Message::new() which implements the BIP340-compliant 33-byte prefix approach and deprecates Message::plain() to guide users toward the standard. The different prefix lengths (64 vs 33 bytes) ensure no collision between old and new signatures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4dac029 commit d298d91

File tree

8 files changed

+113
-29
lines changed

8 files changed

+113
-29
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
- Upgrade to bincode v2
77
- MSRV 1.63 -> 1.85
88
- **BREAKING**: Refactor `CompactProof` in `sigma_fun` to use two type parameters `CompactProof<R, L>` instead of `CompactProof<S: Sigma>` to enable serde support
9+
- Add `Message::new` for BIP340-compliant domain separation using 33-byte padded prefix
10+
- Deprecate `Message::plain` which uses non-standard 64-byte prefix
911

1012
## v0.11.0
1113

schnorr_fun/src/adaptor/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ mod test {
298298
let signing_keypair = schnorr.new_keypair(secret_key);
299299
let verification_key = signing_keypair.public_key();
300300
let encryption_key = schnorr.encryption_key_for(&decryption_key);
301-
let message = Message::<Public>::plain("test", b"give 100 coins to Bob".as_ref());
301+
let message = Message::<Public>::new("test", b"give 100 coins to Bob".as_ref());
302302

303303
let encrypted_signature =
304304
schnorr.encrypted_sign(&signing_keypair, &encryption_key, message);

schnorr_fun/src/frost/chilldkg.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ pub mod encpedpop {
606606
{
607607
schnorr.sign(
608608
keypair,
609-
Message::<Public>::plain("BIP DKG/cert", self.cert_bytes().as_ref()),
609+
Message::<Public>::new("BIP DKG/cert", self.cert_bytes().as_ref()),
610610
)
611611
}
612612

@@ -620,7 +620,7 @@ pub mod encpedpop {
620620
) -> bool {
621621
schnorr.verify(
622622
&cert_key,
623-
Message::<Public>::plain("BIP DKG/cert", self.cert_bytes().as_ref()),
623+
Message::<Public>::new("BIP DKG/cert", self.cert_bytes().as_ref()),
624624
&signature,
625625
)
626626
}

schnorr_fun/src/frost/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ mod test {
446446
let session = frost.coordinator_sign_session(
447447
&frost_poly.into_xonly(),
448448
BTreeMap::from_iter([(s!(1).public(), nonce), (s!(2).public(), malicious_nonce)]),
449-
Message::<Public>::plain("test", b"hello"),
449+
Message::<Public>::new("test", b"hello"),
450450
);
451451

452452
assert_eq!(session.final_nonce(), *G);

schnorr_fun/src/message.rs

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,24 @@ pub struct Message<'a, S = Public> {
1414
/// The message bytes
1515
pub bytes: Slice<'a, S>,
1616
/// The optional application tag to separate the signature from other applications.
17+
#[deprecated(
18+
since = "0.11.0",
19+
note = "Use Message::new for BIP340-style domain separation"
20+
)]
1721
pub app_tag: Option<&'static str>,
22+
/// The domain separator for BIP340-style domain separation (33-byte prefix)
23+
pub bip340_domain_sep: Option<&'static str>,
1824
}
1925

26+
#[allow(deprecated)]
2027
impl<'a, S: Secrecy> Message<'a, S> {
21-
/// Create a raw message with no `app_tag`. The message bytes will be passed straight into the
28+
/// Create a raw message with no domain separation. The message bytes will be passed straight into the
2229
/// challenge hash. Usually, you only use this when signing a pre-hashed message.
2330
pub fn raw(bytes: &'a [u8]) -> Self {
2431
Message {
2532
bytes: Slice::from(bytes),
2633
app_tag: None,
34+
bip340_domain_sep: None,
2735
}
2836
}
2937

@@ -32,18 +40,51 @@ impl<'a, S: Secrecy> Message<'a, S> {
3240
Self::raw(&[])
3341
}
3442

43+
/// Create a message with BIP340-style domain separation using a 33-byte prefix.
44+
///
45+
/// The domain separator will be padded with null bytes to exactly 33 bytes and
46+
/// prefixed to the message, as recommended in BIP340 for domain separation.
47+
///
48+
/// # Example
49+
/// ```
50+
/// use schnorr_fun::{Message, fun::marker::Public};
51+
/// let message = Message::<Public>::new("my-app/sign", b"hello world");
52+
/// ```
53+
pub fn new(domain_sep: &'static str, bytes: &'a [u8]) -> Self {
54+
assert!(!domain_sep.is_empty(), "domain separator must not be empty");
55+
assert!(
56+
domain_sep.len() <= 33,
57+
"domain separator must be 33 bytes or less"
58+
);
59+
Message {
60+
bytes: Slice::from(bytes),
61+
app_tag: None,
62+
bip340_domain_sep: Some(domain_sep),
63+
}
64+
}
65+
3566
/// Signs a plain variable length message.
3667
///
3768
/// You must provide an application tag to make sure signatures valid in one context are not
3869
/// valid in another. The tag is used as described [here].
3970
///
71+
/// **Deprecation Note**: This method was implemented before BIP340 had finalized its
72+
/// recommendation for domain separation. BIP340 now recommends using a 33-byte padded
73+
/// prefix instead of the 64-byte prefix used by this method. Use [`Message::new`] instead,
74+
/// which implements the BIP340-compliant domain separation.
75+
///
4076
/// [here]: https://github.com/sipa/bips/issues/207#issuecomment-673681901
77+
#[deprecated(
78+
since = "0.12.0",
79+
note = "Use Message::new for BIP340-style domain separation. This method uses a 64-byte prefix which predates the BIP340 specification."
80+
)]
4181
pub fn plain(app_tag: &'static str, bytes: &'a [u8]) -> Self {
4282
assert!(app_tag.len() <= 64, "tag must be 64 bytes or less");
4383
assert!(!app_tag.is_empty(), "tag must not be empty");
4484
Message {
4585
bytes: Slice::from(bytes),
4686
app_tag: Some(app_tag),
87+
bip340_domain_sep: None,
4788
}
4889
}
4990

@@ -54,21 +95,28 @@ impl<'a, S: Secrecy> Message<'a, S> {
5495

5596
/// Length of the message as it is hashed
5697
pub fn len(&self) -> usize {
57-
match self.app_tag {
58-
Some(_) => 64 + self.bytes.as_inner().len(),
59-
None => self.bytes.as_inner().len(),
98+
match (self.app_tag, self.bip340_domain_sep) {
99+
(Some(_), _) => 64 + self.bytes.as_inner().len(),
100+
(_, Some(_)) => 33 + self.bytes.as_inner().len(), // BIP340 style uses 33-byte prefix
101+
(None, None) => self.bytes.as_inner().len(),
60102
}
61103
}
62104
}
63105

106+
#[allow(deprecated)]
64107
impl<S> HashInto for Message<'_, S> {
65108
fn hash_into(self, hash: &mut impl digest::Update) {
66109
if let Some(prefix) = self.app_tag {
67110
let mut padded_prefix = [0u8; 64];
68111
padded_prefix[..prefix.len()].copy_from_slice(prefix.as_bytes());
69112
hash.update(&padded_prefix);
113+
} else if let Some(domain_sep) = self.bip340_domain_sep {
114+
// BIP340-style domain separation: 33-byte prefix
115+
let mut padded_prefix = [0u8; 33];
116+
padded_prefix[..domain_sep.len()].copy_from_slice(domain_sep.as_bytes());
117+
hash.update(&padded_prefix);
70118
}
71-
hash.update(<&[u8]>::from(self.bytes));
119+
hash.update(self.bytes.as_inner());
72120
}
73121
}
74122

@@ -78,15 +126,48 @@ mod test {
78126
use sha2::{Digest, Sha256};
79127

80128
#[test]
81-
fn message_hash_into() {
82-
let mut hash1 = Sha256::default();
83-
hash1.update("test");
84-
hash1.update([0u8; 60].as_ref());
85-
hash1.update("hello world");
129+
fn bip340_domain_separation() {
130+
// Test that BIP340 domain separation uses 33-byte prefix
131+
let msg = Message::<Public>::new("test", b"hello");
132+
133+
// Expected: "test" padded to 33 bytes + "hello"
134+
let mut expected_hash = Sha256::default();
135+
let mut padded_prefix = [0u8; 33];
136+
padded_prefix[..4].copy_from_slice(b"test");
137+
expected_hash.update(&padded_prefix);
138+
expected_hash.update(b"hello");
139+
140+
let mut actual_hash = Sha256::default();
141+
msg.hash_into(&mut actual_hash);
142+
143+
assert_eq!(expected_hash.finalize(), actual_hash.finalize());
144+
145+
// Test length calculation
146+
assert_eq!(msg.len(), 33 + 5); // 33-byte prefix + 5-byte message
147+
}
148+
149+
#[test]
150+
fn message_new_fixed_key_signature() {
151+
use crate::{fun::s, new_with_deterministic_nonces};
152+
use core::str::FromStr;
153+
154+
// Fixed test to ensure Message::new domain separation doesn't accidentally change
155+
let schnorr = new_with_deterministic_nonces::<Sha256>();
156+
let secret_key = s!(42);
157+
let keypair = schnorr.new_keypair(secret_key);
158+
159+
let message = Message::<Public>::new("test-app", b"test message");
160+
let signature = schnorr.sign(&keypair, message);
86161

87-
let mut hash2 = Sha256::default();
88-
Message::<Public>::plain("test", b"hello world").hash_into(&mut hash2);
162+
// This signature was generated with the current implementation and should never change
163+
// to ensure backwards compatibility
164+
let expected_sig = crate::Signature::<Public>::from_str(
165+
"5c49762df465f21993af631caedb3e478793142e15f200e70511e5af71387e52a3b9b6af189fa4b28a767254f2a8977f2e9db1866ad4dfbb083bb4fbd8dfe82e"
166+
).unwrap();
89167

90-
assert_eq!(hash1.finalize(), hash2.finalize());
168+
assert_eq!(
169+
signature, expected_sig,
170+
"Message::new signature changed! This breaks backwards compatibility."
171+
);
91172
}
92173
}

schnorr_fun/src/musig.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,7 @@ mod test {
761761
assert_eq!(agg_key1.agg_public_key(), agg_key3.agg_public_key());
762762

763763
let message =
764-
Message::<Public>::plain("test", b"Chancellor on brink of second bailout for banks");
764+
Message::<Public>::new("test", b"Chancellor on brink of second bailout for banks");
765765

766766
let session_id = message.bytes.into();
767767

@@ -856,7 +856,7 @@ mod test {
856856
]).into_xonly_key();
857857

858858
let message =
859-
Message::<Public>::plain("test", b"Chancellor on brink of second bailout for banks");
859+
Message::<Public>::new("test", b"Chancellor on brink of second bailout for banks");
860860

861861
let session_id = message.bytes.into();
862862

schnorr_fun/src/schnorr.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ where
122122
/// # };
123123
/// let schnorr = schnorr_fun::new_with_deterministic_nonces::<sha2::Sha256>();
124124
/// let keypair = schnorr.new_keypair(Scalar::random(&mut rand::thread_rng()));
125-
/// let message = Message::<Public>::plain(
125+
/// let message = Message::<Public>::new(
126126
/// "times-of-london",
127127
/// b"Chancellor on brink of second bailout for banks",
128128
/// );
@@ -171,7 +171,7 @@ impl<NG, CH: Hash32> Schnorr<CH, NG> {
171171
/// ```
172172
/// use schnorr_fun::{Message, Schnorr, Signature, fun::prelude::*};
173173
/// let schnorr = schnorr_fun::new_with_deterministic_nonces::<sha2::Sha256>();
174-
/// let message = Message::<Public>::plain("my-app", b"we rolled our own schnorr!");
174+
/// let message = Message::<Public>::new("my-app", b"we rolled our own schnorr!");
175175
/// let keypair = schnorr.new_keypair(Scalar::random(&mut rand::thread_rng()));
176176
/// let mut r = Scalar::random(&mut rand::thread_rng());
177177
/// let R = Point::even_y_from_scalar_mul(G, &mut r);
@@ -300,24 +300,25 @@ mod test {
300300
Scalar::from_str("18451f9e08af9530814243e202a4a977130e672079f5c14dcf15bd4dee723072")
301301
.unwrap();
302302
let keypair = schnorr.new_keypair(x);
303+
304+
// Test new method for domain separation
303305
assert_ne!(
304306
schnorr.sign(&keypair, Message::<Public>::raw(b"foo")).R,
305307
schnorr
306-
.sign(&keypair, Message::<Public>::plain("one", b"foo"))
308+
.sign(&keypair, Message::<Public>::new("one", b"foo"))
307309
.R
308310
);
309311
assert_ne!(
310312
schnorr
311-
.sign(&keypair, Message::<Public>::plain("one", b"foo"))
313+
.sign(&keypair, Message::<Public>::new("one", b"foo"))
312314
.R,
313315
schnorr
314-
.sign(&keypair, Message::<Public>::plain("two", b"foo"))
316+
.sign(&keypair, Message::<Public>::new("two", b"foo"))
315317
.R
316318
);
317319

318320
// make sure deterministic signatures don't change
319321
assert_eq!(schnorr.sign(&keypair, Message::<Public>::raw(b"foo")), Signature::<Public>::from_str("fe9e5d0319d5d221988d6fd7fe1c4bedd2fb4465f592f1002f461503332a266977bb4a0b00c00d07072c796212cbea0957ebaaa5139143761c45d997ebe36cbe").unwrap());
320-
assert_eq!(schnorr.sign(&keypair, Message::<Public>::plain("one", b"foo")), Signature::<Public>::from_str("2fcf6fd140bbc4048e802c62f028e24f6534e0d15d450963265b67eead774d8b4aa7638bec9d70aa60b97e86bc4a60bf43ad2ff58e981ee1bba4f45ce02ff2c0").unwrap());
321322
}
322323

323324
proptest! {
@@ -326,7 +327,7 @@ mod test {
326327
fn anticipated_signature_on_should_correspond_to_actual_signature(sk in any::<Scalar>()) {
327328
let schnorr = crate::new_with_deterministic_nonces::<sha2::Sha256>();
328329
let keypair = schnorr.new_keypair(sk);
329-
let msg = Message::<Public>::plain(
330+
let msg = Message::<Public>::new(
330331
"test",
331332
b"Chancellor on brink of second bailout for banks",
332333
);
@@ -349,8 +350,8 @@ mod test {
349350
let schnorr = crate::new_with_deterministic_nonces::<sha2::Sha256>();
350351
let keypair_1 = schnorr.new_keypair(s1);
351352
let keypair_2 = schnorr.new_keypair(s2);
352-
let msg_atkdwn = Message::<Public>::plain("test", b"attack at dawn");
353-
let msg_rtrtnoon = Message::<Public>::plain("test", b"retreat at noon");
353+
let msg_atkdwn = Message::<Public>::new("test", b"attack at dawn");
354+
let msg_rtrtnoon = Message::<Public>::new("test", b"retreat at noon");
354355
let signature_1 = schnorr.sign(&keypair_1, msg_atkdwn);
355356
let signature_2 = schnorr.sign(&keypair_1, msg_atkdwn);
356357
let signature_3 = schnorr.sign(&keypair_1, msg_rtrtnoon);

schnorr_fun/tests/frost_prop.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ proptest! {
8282

8383

8484
let sid = b"frost-prop-test".as_slice();
85-
let message = Message::plain("test", b"test");
85+
let message = Message::new("test", b"test");
8686

8787
let secret_nonces: BTreeMap<_, _> = secret_shares_of_signers.iter().map(|paired_secret_share| {
8888
(paired_secret_share.secret_share().index, frost.gen_nonce::<ChaCha20Rng>(

0 commit comments

Comments
 (0)