Skip to main content

thru_base/
tn_signature.rs

1//! Standardized Thru Ed25519 signing: RFC-8032 Ed25519 over a domain-separated
2//! message `M = DST_domain ‖ context`, where `DST_domain` is a fixed 16-byte
3//! ASCII tag.  This is byte-identical to any stock Ed25519 signer over `M`
4//! (HSM / MPC / SDK), unlike the previous non-standard in-hash-domain scheme.
5//! See specs/mpc-hsm-signer-design.md §4.1.
6
7use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
8use sha2::{Digest, Sha256};
9use std::fmt;
10
11/// Field prime p = 2^255 - 19, little-endian.
12const P_LE: [u8; 32] = [
13    0xED, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
14    0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F,
15];
16
17/// A 32-byte little-endian Ed25519 point encoding is canonical iff its
18/// y-coordinate (sign bit masked off) is `< p`.
19fn enc_is_canonical(enc: &[u8; 32]) -> bool {
20    let mut y = *enc;
21    y[31] &= 0x7F;
22    for i in (0..32).rev() {
23        if y[i] != P_LE[i] {
24            return y[i] < P_LE[i];
25        }
26    }
27    false // y == p is non-canonical
28}
29
30/// Signing domain enum -- byte-for-byte in step with `tn_signature_domain_t`
31/// in `src/thru/util/tn_signature.h` (variants must not be reordered).
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33#[repr(u8)]
34pub enum SignatureDomain {
35    Transaction = 0,
36    BlockHeader = 1,
37    Block = 2,
38    Gossip = 3,
39    NodeRecord = 4,
40    EoaCreate = 5,
41    EoaDelete = 6,
42    /// Generic wallet / user message signing (see `sign_message`).
43    Msg = 7,
44}
45
46impl SignatureDomain {
47    /// Fixed 16-byte ASCII domain-separation tag.  Where the version suffix is
48    /// shorter than the tag width, the tag is padded with trailing '_' rather
49    /// than zero-digits (e.g. `v1__`, not `v001`).  This table is the Rust
50    /// mirror of the C `tn_signature_dst_table[]` in
51    /// `src/thru/util/tn_signature.c` and must be kept in step with it.
52    pub fn dst(self) -> &'static [u8; 16] {
53        match self {
54            SignatureDomain::Transaction => b"tn_txn_sign_v1__",
55            SignatureDomain::BlockHeader => b"tn_blkhdr_sig_v1",
56            SignatureDomain::Block       => b"tn_block_sig_v1_",
57            SignatureDomain::Gossip      => b"tn_gossip_sig_v1",
58            SignatureDomain::NodeRecord  => b"tn_node_rec_v1__",
59            SignatureDomain::EoaCreate   => b"tn_eoa_create_v1",
60            SignatureDomain::EoaDelete   => b"tn_eoa_delete_v1",
61            SignatureDomain::Msg         => b"tn_msg_sign_v1__",
62        }
63    }
64}
65
66#[derive(Debug)]
67pub enum TnSignatureError {
68    InvalidSignature,
69    InvalidPublicKey,
70    InvalidScalar,
71}
72
73impl fmt::Display for TnSignatureError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            TnSignatureError::InvalidSignature => write!(f, "invalid signature"),
77            TnSignatureError::InvalidPublicKey => write!(f, "invalid public key"),
78            TnSignatureError::InvalidScalar => write!(f, "invalid scalar"),
79        }
80    }
81}
82
83impl std::error::Error for TnSignatureError {}
84
85/// `M = DST_domain ‖ context`.  For the transaction domain the context is
86/// `SHA-256(msg)`; for the others it is `msg` verbatim.
87fn build_msg(domain: SignatureDomain, msg: &[u8]) -> Vec<u8> {
88    let dst = domain.dst();
89    match domain {
90        // TXN signs DST ‖ SHA-256(body): a fixed 48 bytes regardless of body size.
91        SignatureDomain::Transaction => {
92            let digest = Sha256::digest(msg);
93            let mut m = Vec::with_capacity(dst.len() + digest.len());
94            m.extend_from_slice(dst);
95            m.extend_from_slice(&digest);
96            m
97        }
98        // Other domains prefix the raw context (e.g. the 104-byte block header),
99        // so size to msg.len() to avoid a reallocation on extend.
100        _ => {
101            let mut m = Vec::with_capacity(dst.len() + msg.len());
102            m.extend_from_slice(dst);
103            m.extend_from_slice(msg);
104            m
105        }
106    }
107}
108
109/// Sign `M = DST_domain ‖ context` with standard RFC-8032 Ed25519.  The public
110/// key is derived from `private_key`; the `public_key` argument is accepted for
111/// API parity but not used (a correct keypair yields the same bytes).
112pub fn sign(
113    domain: SignatureDomain,
114    msg: &[u8],
115    public_key: &[u8; 32],
116    private_key: &[u8; 32],
117) -> Result<[u8; 64], TnSignatureError> {
118    let signing_key = SigningKey::from_bytes(private_key);
119    // Require the supplied public key equals the one derived from the seed, then
120    // sign with the derived key (byte-identical across langs; mismatch = error).
121    if signing_key.verifying_key().to_bytes() != *public_key {
122        return Err(TnSignatureError::InvalidPublicKey);
123    }
124    let m = build_msg(domain, msg);
125    let sig: Signature = signing_key.sign(&m);
126    Ok(sig.to_bytes())
127}
128
129/// Verify under the Thru strict/canonical policy: reject non-canonical `A`/`R`
130/// encodings, then `verify_strict` (canonical `S`, small-order rejection,
131/// cofactorless) over `M = DST_domain ‖ context`.
132pub fn verify(
133    domain: SignatureDomain,
134    msg: &[u8],
135    sig: &[u8; 64],
136    public_key: &[u8; 32],
137) -> Result<(), TnSignatureError> {
138    if !enc_is_canonical(public_key) {
139        return Err(TnSignatureError::InvalidPublicKey);
140    }
141    let r_bytes: [u8; 32] = sig[..32].try_into().unwrap();
142    if !enc_is_canonical(&r_bytes) {
143        return Err(TnSignatureError::InvalidSignature);
144    }
145
146    let verifying_key =
147        VerifyingKey::from_bytes(public_key).map_err(|_| TnSignatureError::InvalidPublicKey)?;
148    let signature = Signature::from_bytes(sig);
149    let m = build_msg(domain, msg);
150    verifying_key
151        .verify_strict(&m, &signature)
152        .map_err(|_| TnSignatureError::InvalidSignature)
153}
154
155/// Strict/canonical verify of `sig` over a raw, already-domain-prefixed message
156/// `m` (e.g. the 48-byte `M = DST_TXN ‖ SHA-256(body)` an external signer signs).
157/// Unlike [`verify`], this does no inner hashing — `m` is used verbatim.
158pub fn verify_message(
159    m: &[u8],
160    sig: &[u8; 64],
161    public_key: &[u8; 32],
162) -> Result<(), TnSignatureError> {
163    if !enc_is_canonical(public_key) {
164        return Err(TnSignatureError::InvalidPublicKey);
165    }
166    let r_bytes: [u8; 32] = sig[..32].try_into().unwrap();
167    if !enc_is_canonical(&r_bytes) {
168        return Err(TnSignatureError::InvalidSignature);
169    }
170    let verifying_key =
171        VerifyingKey::from_bytes(public_key).map_err(|_| TnSignatureError::InvalidPublicKey)?;
172    verifying_key
173        .verify_strict(m, &Signature::from_bytes(sig))
174        .map_err(|_| TnSignatureError::InvalidSignature)
175}
176
177/// Compute `M = DST_domain ‖ context` (public so the external-signer surface can
178/// hand the exact bytes to an HSM/MPC backend).
179pub fn signing_message(domain: SignatureDomain, msg: &[u8]) -> Vec<u8> {
180    build_msg(domain, msg)
181}
182
183pub fn sign_transaction(
184    msg: &[u8],
185    public_key: &[u8; 32],
186    private_key: &[u8; 32],
187) -> Result<[u8; 64], TnSignatureError> {
188    sign(SignatureDomain::Transaction, msg, public_key, private_key)
189}
190
191pub fn verify_transaction(
192    msg: &[u8],
193    sig: &[u8; 64],
194    public_key: &[u8; 32],
195) -> Result<(), TnSignatureError> {
196    verify(SignatureDomain::Transaction, msg, sig, public_key)
197}
198
199/// Sign a generic user/wallet message under `SignatureDomain::Msg`.  Distinct
200/// DST from txn / block / node record / EOA authorizations so a signature
201/// produced by a wallet's "sign message" flow can never be replayed as any of
202/// them.
203pub fn sign_message(
204    msg: &[u8],
205    public_key: &[u8; 32],
206    private_key: &[u8; 32],
207) -> Result<[u8; 64], TnSignatureError> {
208    sign(SignatureDomain::Msg, msg, public_key, private_key)
209}
210
211/// Verify a generic user/wallet message signature produced by [`sign_message`].
212pub fn verify_wallet_message(
213    msg: &[u8],
214    sig: &[u8; 64],
215    public_key: &[u8; 32],
216) -> Result<(), TnSignatureError> {
217    verify(SignatureDomain::Msg, msg, sig, public_key)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    fn hex32(s: &str) -> [u8; 32] {
225        let b = hex_decode(s);
226        b.try_into().unwrap()
227    }
228    fn hex_decode(s: &str) -> Vec<u8> {
229        (0..s.len())
230            .step_by(2)
231            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
232            .collect()
233    }
234
235    #[test]
236    fn round_trip_and_domain_separation() {
237        let seed = [0xA0u8; 32];
238        let sk = SigningKey::from_bytes(&seed);
239        let pk = sk.verifying_key().to_bytes();
240        let msg = b"hello thru";
241        let sig = sign(SignatureDomain::Transaction, msg, &pk, &seed).unwrap();
242        assert!(verify(SignatureDomain::Transaction, msg, &sig, &pk).is_ok());
243        // wrong domain must fail
244        assert!(verify(SignatureDomain::Block, msg, &sig, &pk).is_err());
245        // tampered sig must fail
246        let mut bad = sig;
247        bad[40] ^= 1;
248        assert!(verify(SignatureDomain::Transaction, msg, &bad, &pk).is_err());
249    }
250
251    /// Cross-language golden anchor: must match the C `test_tn_signature`
252    /// GOLDEN txn vector (RFC-8032 test-1 seed, body "Thru golden1").
253    #[test]
254    fn golden_txn_matches_c() {
255        let seed = hex32("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
256        let pk_expected =
257            hex32("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
258        // Regenerated for DST "tn_txn_sign_v1__" (v001 → v1__ underscore padding).
259        let sig_expected = "8edfec88b713fce257ffa9da964d89b43d31b8d179aa1a467bbb32e024461c360bb5daf4f10cf774e439d4446b25297567cddb6bcd08ca931f6fb723b8fdfc0a";
260        let body = b"Thru golden1";
261
262        let sk = SigningKey::from_bytes(&seed);
263        assert_eq!(sk.verifying_key().to_bytes(), pk_expected);
264
265        let sig = sign(SignatureDomain::Transaction, body, &pk_expected, &seed).unwrap();
266        assert_eq!(sig.to_vec(), hex_decode(sig_expected), "Rust txn sig must match C golden");
267        assert!(verify(SignatureDomain::Transaction, body, &sig, &pk_expected).is_ok());
268    }
269
270    #[test]
271    fn rejects_non_canonical() {
272        assert!(!enc_is_canonical(&P_LE)); // y == p
273        let mut z = [0u8; 32];
274        assert!(enc_is_canonical(&z)); // y == 0
275        z[31] = 0x80; // sign bit only
276        assert!(enc_is_canonical(&z));
277    }
278}