实验 03:Ristretto255 上的 Schnorr 签名
先把单方 Schnorr 写对,再做门限化。FROST 的聚合结果仍是一份 Schnorr 形态的签名。
目标
使用 curve25519-dalek 4 和 SHA-512 实现教学版 Schnorr:
\[ R=rB,\qquad c=H(R,X,m),\qquad z=r+cx \]
验证等式:
\[ zB=R+cX \]
完成后应能说明标量和曲线点的区别、挑战值为何绑定消息与公钥,以及随机 nonce 重用为何会泄露私钥。
前置知识
- 已完成实验 01,理解标量上的加、乘和求逆;
- 知道
B是固定基点、x是私钥标量、X = xB是公钥点; - 熟悉 Rust 所有权、字节切片和错误处理。
不要求自己实现椭圆曲线。曲线点运算、标量运算和压缩编码必须交给成熟库。
建议 API 与文件
建议文件:
src/transcript.rs:域分离和挑战哈希;src/schnorr.rs:密钥、签名、验证与编解码;tests/schnorr.rs:正反例。
依赖:
[dependencies]
curve25519-dalek = { version = "4", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
建议类型:
#![allow(unused)]
fn main() {
use curve25519_dalek::{
ristretto::RistrettoPoint,
scalar::Scalar,
traits::Identity,
};
pub struct SigningKey(pub(crate) Scalar);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VerifyingKey(RistrettoPoint);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Signature {
commitment: RistrettoPoint,
response: Scalar,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SchnorrTypeError {
IdentityPublicKey,
IdentityCommitment,
}
impl VerifyingKey {
pub fn try_from_point(point: RistrettoPoint) -> Result<Self, SchnorrTypeError> {
if point == RistrettoPoint::identity() {
return Err(SchnorrTypeError::IdentityPublicKey);
}
Ok(Self(point))
}
pub fn as_point(&self) -> &RistrettoPoint {
&self.0
}
}
impl Signature {
pub fn try_from_components(
commitment: RistrettoPoint,
response: Scalar,
) -> Result<Self, SchnorrTypeError> {
if commitment == RistrettoPoint::identity() {
return Err(SchnorrTypeError::IdentityCommitment);
}
Ok(Self { commitment, response })
}
pub fn commitment(&self) -> &RistrettoPoint {
&self.commitment
}
pub fn response(&self) -> Scalar {
self.response
}
}
}
不要为 SigningKey 派生 Clone、Copy 或 Debug。公钥和签名字段保持私有,只允许通过会检查规范编码和单位元的构造器/from_bytes 创建;可以另加只读访问器。否则调用者可绕过解析验证直接构造退化对象。实验早期可暂不做序列化,但内部构造也必须维护相同不变量。
分步任务
1. 区分 Scalar 与 RistrettoPoint
在代码旁写下每个量的类型:x、r、c、z 是标量,B、X、R 是曲线点。尝试把错误类型相加,观察编译器如何阻止一部分代数错误。
2. 构造无歧义的挑战哈希
可以从下面的可编译函数开始:
#![allow(unused)]
fn main() {
use curve25519_dalek::{ristretto::RistrettoPoint, scalar::Scalar};
use sha2::{Digest, Sha512};
pub fn challenge(
commitment: &RistrettoPoint,
public_key: &RistrettoPoint,
message: &[u8],
) -> Scalar {
let mut hash = Sha512::new();
hash.update(b"TSS-STUDY/SCHNORR-RISTRETTO255-SHA512/v1");
hash.update(commitment.compress().as_bytes());
hash.update(public_key.compress().as_bytes());
hash.update((message.len() as u64).to_le_bytes());
hash.update(message);
let wide: [u8; 64] = hash.finalize().into();
Scalar::from_bytes_mod_order_wide(&wide)
}
}
回答两个问题后再继续:
- 为什么消息前要写入长度,而固定 32 字节的压缩点不需要?
- 为什么不能只计算
H(message)?
这个 transcript 是本学习工程自定义的,不与任何外部 Schnorr 或 FROST 标准互操作。
3. 生成密钥
用 Scalar::random(&mut OsRng) 生成 x,计算 X = xB。为教学代码决定如何处理极小概率的零标量,并用测试固定这个行为。
建议方法:
#![allow(unused)]
fn main() {
impl SigningKey {
pub fn generate() -> Self {
todo!("从 OsRng 采样;按你的策略排除零标量")
}
pub fn verifying_key(&self) -> VerifyingKey {
todo!("计算 xB,并通过 VerifyingKey::try_from_point 维护非单位元不变量")
}
pub fn sign(&self, message: &[u8]) -> Signature {
todo!("采样一次性 nonce,计算 R、c、z")
}
}
pub fn verify(key: &VerifyingKey, message: &[u8], signature: &Signature) -> bool {
todo!("比较 zB 与 R+cX")
}
}
以上是可编译骨架。固定基点可从 curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT 导入。
4. 实现签名和验证
先逐行对照公式实现,不要急于抽象。签名时 challenge 必须调用与验证端完全相同的函数;域标签、字段顺序、编码或消息长度只要有一处不同,验证就应失败。
为测试可增加一个接收外部 RNG 的内部方法,但公开 API 不应允许调用者直接传入任意 nonce 标量。
5. 增加规范序列化
建议线格式为版本号加固定长度字段,例如:
version(1 byte) || compressed_R(32 bytes) || canonical_z(32 bytes)
解析时必须:
- 检查总长度和版本;
CompressedRistretto::decompress()失败就拒绝;- 公钥或签名承诺解压为群单位元时拒绝(可导入
curve25519_dalek::traits::Identity与RistrettoPoint::identity()比较); - 用
Scalar::from_canonical_bytes()拒绝非规范标量编码; - 不要把任意 32 字节用
from_bytes_mod_order静默约简成签名响应。
6. 跟踪一次完整签名
在仅测试构建中记录压缩后的 X、R、challenge c 和 z。不要打印私钥 x 或 nonce r。观察修改一个消息字节后,c 和验证结果如何变化。
验收测试
至少覆盖:
#![allow(unused)]
fn main() {
#[test]
fn sign_then_verify() {
let signing_key = SigningKey::generate();
let verifying_key = signing_key.verifying_key();
let signature = signing_key.sign(b"threshold signatures");
assert!(verify(&verifying_key, b"threshold signatures", &signature));
}
#[test]
fn rejects_changed_message_and_wrong_key() {
let alice = SigningKey::generate();
let bob = SigningKey::generate();
let signature = alice.sign(b"pay 10");
assert!(!verify(&alice.verifying_key(), b"pay 100", &signature));
assert!(!verify(&bob.verifying_key(), b"pay 10", &signature));
}
}
还需自行编写:
- 修改
R后失败; - 修改
z后失败; - 空消息可被一致签名和验证;
- 序列化往返保持相同签名;
- 截断、附加字节、非法版本、不可解压点、非规范标量全部被拒绝;
- 单位元公钥和单位元
R在计算 challenge 前被拒绝; - 演示若错误接受单位元公钥,攻击者任选
z并令R = zB就能伪造,从而证明该负例的必要性; - 同一消息正常签两次时,随机
R通常不同但都能验证。
不要写“两个随机签名一定不同”的数学断言;说明这是概率事件,并讨论测试为什么仍实用。
观察记录
| 问题 | 你的记录 |
|---|---|
| 签名中哪两个值需要公开? | |
验证者为什么不需要知道 r 或 x? | |
| 把公钥从 challenge 中移除会失去什么绑定? | |
| 改一个消息字节后,哪一侧等式首先不再匹配? | |
| 解析阶段和代数验证阶段各拒绝哪些输入? |
进阶挑战
- 增加上下文字符串
context,使用长度前缀编码,并测试不同上下文的签名不能互用。 - 给内部签名函数注入确定性测试 RNG,生成稳定测试向量;确保生产 API 仍使用
OsRng。 - 实现批量验证的实验版本,并列出它额外依赖的随机性和失败定位问题。
- 阅读 RFC 9591 中 FROST ciphersuite 的 transcript 规则,对比本实验自定义 Schnorr 编码,暂时不要声称兼容。
安全警告
- nonce 必须不可预测且永不重用。相同
r对不同 challenge 签名会直接泄露x;实验 05 会亲自复现。 - nonce 还必须非零。若
R是单位元,则z = c*x会在c != 0时直接暴露私钥;不能只依赖“随机取到零的概率很小”。 - 不要打印、序列化或持久化原始私钥和 nonce;Rust 的普通析构也不等于可靠清零。
curve25519-dalek负责曲线算术,不代表外围协议自动安全。域分离、编码、状态机和随机数同样属于安全边界。- 本章协议是教学格式,不是 RFC 9591、Ed25519、Ristretto Schnorr 或任何钱包协议的兼容实现,禁止用于真实资产。