Skip to content

Commit 1b1f081

Browse files
LLFournnickfarrow
authored andcommitted
feat: implement BIP340 domain separation with 33-byte prefix
This implements proper domain separation as recommended in BIP340. The old Message::plain() method that used a 64-byte prefix (which predated the BIP340 specification) has been deprecated in favor of the new Message::new() method that uses BIP340's recommended 33-byte padded prefix for domain separation.
1 parent bc3ca5b commit 1b1f081

File tree

9 files changed

+126
-35
lines changed

9 files changed

+126
-35
lines changed

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- `hash_to_curve` - Simple try-and-increment with uniform distribution (recommended)
1111
- `hash_to_curve_sswu` - RFC 9380 compliant constant-time hashing
1212
- `hash_to_curve_rfc9381_tai` - RFC 9381 VRF try-and-increment format
13+
- Add `Message::new` for BIP340-compliant domain separation using 33-byte padded prefix
14+
- Deprecate `Message::plain` which uses non-standard 64-byte prefix
1315

1416
## v0.11.0
1517

@@ -21,7 +23,6 @@
2123
- Add `Hash32` trait to collect all the useful hash traits we use all over the place
2224
- Add our own take on [chill-dkg](ttps://github.com/BlockstreamResearch/bip-frost-dkg/tree/master) WIP BIP
2325

24-
2526
## v0.10.0
2627

2728
- Change `Scalar::from_bytes` to work for `Scalar<_, NonZero>` as well.
@@ -67,7 +68,6 @@
6768
- Change the `from_bytes` type commands to not assume secrecy in `Scalar` and `Point`.
6869
- Update to rust-secp256k1 v0.25.0
6970

70-
7171
## 0.7.1
7272

7373
- Fix critical bug in MuSig2 implementation where multiple tweaks would break it
@@ -88,4 +88,3 @@
8888
## 0.6.1
8989

9090
- Fix serialization of `Point<EvenY>`
91-

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: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,26 @@ 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+
///
24+
/// [BIP340]: https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
25+
pub bip340_domain_sep: Option<&'static str>,
1826
}
1927

28+
#[allow(deprecated)]
2029
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
30+
/// Create a raw message with no domain separation. The message bytes will be passed straight into the
2231
/// challenge hash. Usually, you only use this when signing a pre-hashed message.
2332
pub fn raw(bytes: &'a [u8]) -> Self {
2433
Message {
2534
bytes: Slice::from(bytes),
2635
app_tag: None,
36+
bip340_domain_sep: None,
2737
}
2838
}
2939

@@ -32,18 +42,55 @@ impl<'a, S: Secrecy> Message<'a, S> {
3242
Self::raw(&[])
3343
}
3444

45+
/// Create a message with [BIP340]-style domain separation using a 33-byte prefix.
46+
///
47+
/// The domain separator will be padded with null bytes to exactly 33 bytes and
48+
/// prefixed to the message, as recommended in [BIP340] for domain separation.
49+
///
50+
/// [BIP340]: https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
51+
///
52+
/// # Example
53+
/// ```
54+
/// use schnorr_fun::{Message, fun::marker::Public};
55+
/// let message = Message::<Public>::new("my-app/sign", b"hello world");
56+
/// ```
57+
pub fn new(domain_sep: &'static str, bytes: &'a [u8]) -> Self {
58+
assert!(!domain_sep.is_empty(), "domain separator must not be empty");
59+
assert!(
60+
domain_sep.len() <= 33,
61+
"domain separator must be 33 bytes or less"
62+
);
63+
Message {
64+
bytes: Slice::from(bytes),
65+
app_tag: None,
66+
bip340_domain_sep: Some(domain_sep),
67+
}
68+
}
69+
3570
/// Signs a plain variable length message.
3671
///
3772
/// You must provide an application tag to make sure signatures valid in one context are not
3873
/// valid in another. The tag is used as described [here].
3974
///
75+
/// **Deprecation Note**: This method was implemented before [BIP340] had finalized its
76+
/// recommendation for domain separation. [BIP340] now recommends using a 33-byte padded
77+
/// prefix instead of the 64-byte prefix used by this method. Use [`Message::new`] instead,
78+
/// which implements the [BIP340]-compliant domain separation.
79+
///
80+
/// [BIP340]: https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
81+
///
4082
/// [here]: https://github.com/sipa/bips/issues/207#issuecomment-673681901
83+
#[deprecated(
84+
since = "0.12.0",
85+
note = "Use Message::new for BIP340-style domain separation. This method uses a 64-byte prefix which predates the BIP340 specification."
86+
)]
4187
pub fn plain(app_tag: &'static str, bytes: &'a [u8]) -> Self {
4288
assert!(app_tag.len() <= 64, "tag must be 64 bytes or less");
4389
assert!(!app_tag.is_empty(), "tag must not be empty");
4490
Message {
4591
bytes: Slice::from(bytes),
4692
app_tag: Some(app_tag),
93+
bip340_domain_sep: None,
4794
}
4895
}
4996

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

55102
/// Length of the message as it is hashed
56103
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(),
104+
match (self.app_tag, self.bip340_domain_sep) {
105+
(Some(_), _) => 64 + self.bytes.as_inner().len(),
106+
(_, Some(_)) => 33 + self.bytes.as_inner().len(), // BIP340 style uses 33-byte prefix
107+
(None, None) => self.bytes.as_inner().len(),
60108
}
61109
}
62110
}
63111

112+
#[allow(deprecated)]
64113
impl<S> HashInto for Message<'_, S> {
65114
fn hash_into(self, hash: &mut impl digest::Update) {
66115
if let Some(prefix) = self.app_tag {
67116
let mut padded_prefix = [0u8; 64];
68117
padded_prefix[..prefix.len()].copy_from_slice(prefix.as_bytes());
69118
hash.update(&padded_prefix);
119+
} else if let Some(domain_sep) = self.bip340_domain_sep {
120+
// BIP340-style domain separation: 33-byte prefix
121+
let mut padded_prefix = [0u8; 33];
122+
padded_prefix[..domain_sep.len()].copy_from_slice(domain_sep.as_bytes());
123+
hash.update(&padded_prefix);
70124
}
71-
hash.update(<&[u8]>::from(self.bytes));
125+
hash.update(self.bytes.as_inner());
72126
}
73127
}
74128

@@ -78,15 +132,48 @@ mod test {
78132
use sha2::{Digest, Sha256};
79133

80134
#[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");
135+
fn bip340_domain_separation() {
136+
// Test that BIP340 domain separation uses 33-byte prefix
137+
let msg = Message::<Public>::new("test", b"hello");
138+
139+
// Expected: "test" padded to 33 bytes + "hello"
140+
let mut expected_hash = Sha256::default();
141+
let mut padded_prefix = [0u8; 33];
142+
padded_prefix[..4].copy_from_slice(b"test");
143+
expected_hash.update(&padded_prefix);
144+
expected_hash.update(b"hello");
145+
146+
let mut actual_hash = Sha256::default();
147+
msg.hash_into(&mut actual_hash);
148+
149+
assert_eq!(expected_hash.finalize(), actual_hash.finalize());
150+
151+
// Test length calculation
152+
assert_eq!(msg.len(), 33 + 5); // 33-byte prefix + 5-byte message
153+
}
154+
155+
#[test]
156+
fn message_new_fixed_key_signature() {
157+
use crate::{fun::s, new_with_deterministic_nonces};
158+
use core::str::FromStr;
159+
160+
// Fixed test to ensure Message::new domain separation doesn't accidentally change
161+
let schnorr = new_with_deterministic_nonces::<Sha256>();
162+
let secret_key = s!(42);
163+
let keypair = schnorr.new_keypair(secret_key);
164+
165+
let message = Message::<Public>::new("test-app", b"test message");
166+
let signature = schnorr.sign(&keypair, message);
86167

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

90-
assert_eq!(hash1.finalize(), hash2.finalize());
174+
assert_eq!(
175+
signature, expected_sig,
176+
"Message::new signature changed! This breaks backwards compatibility."
177+
);
91178
}
92179
}

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: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ where
9191
CH: Default + Tag,
9292
NG: Default + Tag,
9393
{
94-
/// Returns a Schnorr instance tagged in the default way according to BIP340.
94+
/// Returns a Schnorr instance tagged in the default way according to [BIP340].
95+
///
96+
/// [BIP340]: https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
9597
///
9698
/// # Examples
9799
///
@@ -122,7 +124,7 @@ where
122124
/// # };
123125
/// let schnorr = schnorr_fun::new_with_deterministic_nonces::<sha2::Sha256>();
124126
/// let keypair = schnorr.new_keypair(Scalar::random(&mut rand::thread_rng()));
125-
/// let message = Message::<Public>::plain(
127+
/// let message = Message::<Public>::new(
126128
/// "times-of-london",
127129
/// b"Chancellor on brink of second bailout for banks",
128130
/// );
@@ -171,7 +173,7 @@ impl<NG, CH: Hash32> Schnorr<CH, NG> {
171173
/// ```
172174
/// use schnorr_fun::{Message, Schnorr, Signature, fun::prelude::*};
173175
/// let schnorr = schnorr_fun::new_with_deterministic_nonces::<sha2::Sha256>();
174-
/// let message = Message::<Public>::plain("my-app", b"we rolled our own schnorr!");
176+
/// let message = Message::<Public>::new("my-app", b"we rolled our own schnorr!");
175177
/// let keypair = schnorr.new_keypair(Scalar::random(&mut rand::thread_rng()));
176178
/// let mut r = Scalar::random(&mut rand::thread_rng());
177179
/// let R = Point::even_y_from_scalar_mul(G, &mut r);
@@ -300,24 +302,25 @@ mod test {
300302
Scalar::from_str("18451f9e08af9530814243e202a4a977130e672079f5c14dcf15bd4dee723072")
301303
.unwrap();
302304
let keypair = schnorr.new_keypair(x);
305+
306+
// Test new method for domain separation
303307
assert_ne!(
304308
schnorr.sign(&keypair, Message::<Public>::raw(b"foo")).R,
305309
schnorr
306-
.sign(&keypair, Message::<Public>::plain("one", b"foo"))
310+
.sign(&keypair, Message::<Public>::new("one", b"foo"))
307311
.R
308312
);
309313
assert_ne!(
310314
schnorr
311-
.sign(&keypair, Message::<Public>::plain("one", b"foo"))
315+
.sign(&keypair, Message::<Public>::new("one", b"foo"))
312316
.R,
313317
schnorr
314-
.sign(&keypair, Message::<Public>::plain("two", b"foo"))
318+
.sign(&keypair, Message::<Public>::new("two", b"foo"))
315319
.R
316320
);
317321

318322
// make sure deterministic signatures don't change
319323
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());
321324
}
322325

323326
proptest! {
@@ -326,7 +329,7 @@ mod test {
326329
fn anticipated_signature_on_should_correspond_to_actual_signature(sk in any::<Scalar>()) {
327330
let schnorr = crate::new_with_deterministic_nonces::<sha2::Sha256>();
328331
let keypair = schnorr.new_keypair(sk);
329-
let msg = Message::<Public>::plain(
332+
let msg = Message::<Public>::new(
330333
"test",
331334
b"Chancellor on brink of second bailout for banks",
332335
);
@@ -349,8 +352,8 @@ mod test {
349352
let schnorr = crate::new_with_deterministic_nonces::<sha2::Sha256>();
350353
let keypair_1 = schnorr.new_keypair(s1);
351354
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");
355+
let msg_atkdwn = Message::<Public>::new("test", b"attack at dawn");
356+
let msg_rtrtnoon = Message::<Public>::new("test", b"retreat at noon");
354357
let signature_1 = schnorr.sign(&keypair_1, msg_atkdwn);
355358
let signature_2 = schnorr.sign(&keypair_1, msg_atkdwn);
356359
let signature_3 = schnorr.sign(&keypair_1, msg_rtrtnoon);

schnorr_fun/tests/against_c_lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ use secp256kfun::{
1212
};
1313
use sha2::Sha256;
1414

15-
/// Compliance type for no aux BIP340 libsecp256k1 implementation.
15+
/// Compliance type for no aux [BIP340] libsecp256k1 implementation.
1616
///
1717
/// This type is expected to be used in [`Schnorr`] context and receive a tag "BIP0340" to be
18-
/// compatible with BIP 340 no auxiliary data, i.e. aux is set to null 32-bytes array.
18+
/// compatible with [BIP340] no auxiliary data, i.e. aux is set to null 32-bytes array.
19+
///
20+
/// [BIP340]: https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
1921
#[derive(Clone, Debug, Default)]
2022
struct Bip340NoAux {
2123
nonce_hash: Sha256,

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)