Skip to main content

thru_base/
signer.rs

1//! Bring-your-own-signer surface (external-signer Workstream B).
2//!
3//! Lets a custody partner (e.g. one using a Blockdaemon Builder Vault TSM) sign
4//! Thru transactions without the SDK ever holding a key: build the exact bytes
5//! `M` the external signer must sign, hand `M` to the HSM/MPC backend, then
6//! attach the returned signature with a verify-before-attach check.
7//!
8//! `SigningMessage` is opaque and constructible only by the domain builders, so
9//! a caller cannot steer a signer to arbitrary bytes or a mismatched domain.
10//! `attach_signature` runs the strict/canonical verifier before accepting a
11//! signature, so a wrong-key or garbage signature fails locally rather than on
12//! chain.  `LocalSigner` is a key-holding control implementation (kept out of any
13//! key-free core).  See specs/mpc-hsm-signer-design.md §4.2.
14
15use crate::tn_signature::{self, SignatureDomain};
16use ed25519_dalek::{Signer as _, SigningKey};
17use std::fmt;
18
19#[derive(Debug)]
20pub enum SignError {
21    /// The signer's public key does not match the message's expected key.
22    KeyMismatch { expected: [u8; 32], got: [u8; 32] },
23    /// The signature does not verify over the message (wrong key or garbage;
24    /// Ed25519 has no key recovery, so the two are indistinguishable here).
25    InvalidSignature,
26    /// The signer (or an approver) declined.
27    Rejected,
28    /// A backend-specific error.
29    Backend(String),
30}
31
32impl fmt::Display for SignError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            SignError::KeyMismatch { .. } => write!(f, "signer public key does not match expected key"),
36            SignError::InvalidSignature => write!(f, "invalid signature"),
37            SignError::Rejected => write!(f, "signing rejected"),
38            SignError::Backend(s) => write!(f, "signer backend error: {s}"),
39        }
40    }
41}
42impl std::error::Error for SignError {}
43
44/// Byte offset of the fee-payer public key within a transaction body (the fixed
45/// `WireTxnHdrV1` header; see `txn_lib::WireTxnHdrV1`).
46const TXN_FEE_PAYER_OFFSET: usize = 48;
47/// Length of the fixed transaction wire header — the minimum body length.
48const TXN_HEADER_SZ: usize = 112;
49
50/// An opaque, frozen transaction signing message: the exact bytes `M` an
51/// external signer signs (48 bytes), the fee-payer key the signature must verify
52/// under (derived from the body, never independent caller input), and an
53/// immutable snapshot of the body so `attach_signature` can emit a self-consistent
54/// signed wire even if the caller later mutates its original buffer.
55#[derive(Clone)]
56pub struct SigningMessage {
57    m: Vec<u8>,
58    expected_pubkey: [u8; 32],
59    body: Vec<u8>,
60}
61
62impl SigningMessage {
63    /// The exact bytes to sign — hand these to the HSM/MPC backend.
64    pub fn as_bytes(&self) -> &[u8] {
65        &self.m
66    }
67    /// The public key the signature must verify under: the fee-payer taken from
68    /// the transaction body, so it cannot disagree with what the chain checks.
69    pub fn expected_pubkey(&self) -> &[u8; 32] {
70        &self.expected_pubkey
71    }
72    /// The transaction body snapshot these bytes were built from.
73    pub fn body(&self) -> &[u8] {
74        &self.body
75    }
76}
77
78/// Build the signing message for a transaction: `M = DST_TXN ‖ SHA-256(body)`,
79/// where `body` is the wire bytes preceding the trailing signature.  The
80/// signature's expected key is the fee-payer taken from the body header (offset
81/// 48), *not* an independent argument — so a caller cannot pair a body owned by
82/// fee-payer A with a signer for key B and produce a locally-"valid" signature
83/// the chain then rejects.  Errors if `body` is shorter than the wire header.
84pub fn build_transaction_signing_message(body: &[u8]) -> Result<SigningMessage, SignError> {
85    if body.len() < TXN_HEADER_SZ {
86        return Err(SignError::Backend(format!(
87            "transaction body {} bytes is shorter than the {}-byte header",
88            body.len(),
89            TXN_HEADER_SZ
90        )));
91    }
92    let mut expected_pubkey = [0u8; 32];
93    expected_pubkey.copy_from_slice(&body[TXN_FEE_PAYER_OFFSET..TXN_FEE_PAYER_OFFSET + 32]);
94    Ok(SigningMessage {
95        m: tn_signature::signing_message(SignatureDomain::Transaction, body),
96        expected_pubkey,
97        body: body.to_vec(),
98    })
99}
100
101/// Verify `sig` (produced by an external signer) against the frozen message under
102/// the strict/canonical policy, then return the complete signed transaction wire
103/// (`body ‖ sig`).  Fails closed.  The returned buffer is freshly assembled from
104/// the body snapshot, so a later mutation of the caller's original body cannot
105/// desync the attached signature.  The trailing 64 bytes are the signature.
106pub fn attach_signature(msg: &SigningMessage, sig: [u8; 64]) -> Result<Vec<u8>, SignError> {
107    tn_signature::verify_message(&msg.m, &sig, &msg.expected_pubkey)
108        .map_err(|_| SignError::InvalidSignature)?;
109    let mut signed = Vec::with_capacity(msg.body.len() + 64);
110    signed.extend_from_slice(&msg.body);
111    signed.extend_from_slice(&sig);
112    Ok(signed)
113}
114
115/// An Ed25519 signer over an opaque `SigningMessage`.  Implementations must sign
116/// `msg.as_bytes()` with standard RFC-8032 Ed25519 and return the raw 64 bytes.
117pub trait Signer {
118    /// The 32-byte public key (== the Thru address) this signer produces.
119    fn public_key(&self) -> [u8; 32];
120    fn sign(&self, msg: &SigningMessage) -> Result<[u8; 64], SignError>;
121}
122
123/// Sign a message with any `Signer`: a fail-fast expected-key check *before* the
124/// (potentially slow) sign, then verify-before-attach.  Returns the complete
125/// signed transaction wire (`body ‖ sig`).
126pub fn sign_with(signer: &dyn Signer, msg: &SigningMessage) -> Result<Vec<u8>, SignError> {
127    let pk = signer.public_key();
128    if pk != *msg.expected_pubkey() {
129        return Err(SignError::KeyMismatch {
130            expected: *msg.expected_pubkey(),
131            got: pk,
132        });
133    }
134    let sig = signer.sign(msg)?;
135    attach_signature(msg, sig)
136}
137
138/// A local, key-holding `Signer` — the control implementation for tests / CLI /
139/// native use.  Holds a raw seed, so it belongs in a key-holding package, never a
140/// key-free / browser-safe core.
141pub struct LocalSigner {
142    signing_key: SigningKey,
143}
144
145impl LocalSigner {
146    pub fn from_seed(seed: [u8; 32]) -> Self {
147        Self {
148            signing_key: SigningKey::from_bytes(&seed),
149        }
150    }
151}
152
153impl Signer for LocalSigner {
154    fn public_key(&self) -> [u8; 32] {
155        self.signing_key.verifying_key().to_bytes()
156    }
157    fn sign(&self, msg: &SigningMessage) -> Result<[u8; 64], SignError> {
158        // standard RFC-8032 over the exact frozen bytes
159        Ok(self.signing_key.sign(msg.as_bytes()).to_bytes())
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    /// A minimal transaction body (>= header length) whose fee-payer field
168    /// (offset 48) holds `pk`.
169    fn body_with_fee_payer(pk: &[u8; 32]) -> Vec<u8> {
170        let mut body = vec![0u8; TXN_HEADER_SZ + 8];
171        body[TXN_FEE_PAYER_OFFSET..TXN_FEE_PAYER_OFFSET + 32].copy_from_slice(pk);
172        body
173    }
174
175    #[test]
176    fn local_signer_round_trip_produces_a_valid_signed_wire() {
177        let s = LocalSigner::from_seed([7u8; 32]);
178        let pk = s.public_key();
179        let body = body_with_fee_payer(&pk);
180        let msg = build_transaction_signing_message(&body).unwrap();
181        assert_eq!(msg.as_bytes().len(), 48, "M = DST_TXN ‖ SHA-256(body)");
182        assert_eq!(msg.expected_pubkey(), &pk, "expected key is the body's fee-payer");
183        let signed = sign_with(&s, &msg).unwrap();
184        // signed = body ‖ sig; the trailing 64 bytes verify as a txn signature.
185        assert_eq!(signed.len(), body.len() + 64);
186        assert_eq!(&signed[..body.len()], &body[..]);
187        let sig: [u8; 64] = signed[body.len()..].try_into().unwrap();
188        assert!(crate::tn_signature::verify_transaction(&body, &sig, &pk).is_ok());
189    }
190
191    #[test]
192    fn key_mismatch_is_fail_fast() {
193        // The body names fee-payer all-2s, but the signer holds a different key.
194        let s = LocalSigner::from_seed([1u8; 32]);
195        let body = body_with_fee_payer(&[2u8; 32]);
196        let msg = build_transaction_signing_message(&body).unwrap();
197        assert!(matches!(sign_with(&s, &msg), Err(SignError::KeyMismatch { .. })));
198    }
199
200    #[test]
201    fn attach_rejects_tampered_signature() {
202        let s = LocalSigner::from_seed([3u8; 32]);
203        let pk = s.public_key();
204        let body = body_with_fee_payer(&pk);
205        let msg = build_transaction_signing_message(&body).unwrap();
206        let mut sig = s.sign(&msg).unwrap();
207        sig[40] ^= 1;
208        assert!(attach_signature(&msg, sig).is_err());
209    }
210
211    #[test]
212    fn short_body_is_rejected() {
213        assert!(build_transaction_signing_message(b"too short").is_err());
214    }
215}