Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

实验 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)
}
}

回答两个问题后再继续:

  1. 为什么消息前要写入长度,而固定 32 字节的压缩点不需要?
  2. 为什么不能只计算 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 中移除会失去什么绑定?
改一个消息字节后,哪一侧等式首先不再匹配?
解析阶段和代数验证阶段各拒绝哪些输入?

进阶挑战

  1. 增加上下文字符串 context,使用长度前缀编码,并测试不同上下文的签名不能互用。
  2. 给内部签名函数注入确定性测试 RNG,生成稳定测试向量;确保生产 API 仍使用 OsRng。
  3. 实现批量验证的实验版本,并列出它额外依赖的随机性和失败定位问题。
  4. 阅读 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 或任何钱包协议的兼容实现,禁止用于真实资产。