Skip to main content

sidestr_round/
signer.rs

1//! The port a signer's key sits behind, and the in-memory key for a key
2//! file.
3//!
4//! siding uses one 32-byte hex key as Nostr identity, block-sealing key and
5//! taproot spending key (`proposals/level-2.md`: "signer keys are Nostr
6//! keys"). This crate keeps that convention on the wire — the event's author
7//! is the signer, and the same x-only key sits in the `multi_a` leaf — but
8//! never hands the key around. Event signing goes through
9//! `sidestr-nostr`'s sealed [`Signer`] port; block partial signatures and
10//! peg-out input signatures go through [`BlockSigner`], whose requests
11//! ([`PartialRequest`], [`PegoutSignRequest`]) have no public constructor,
12//! so a signer is only ever asked to sign a named thing it can inspect
13//! (ADR-2101: "a generic 'sign this payload' port is a bypass").
14//!
15//! [`LocalKey`] implements both for a key file's text, signing with zero
16//! BIP-340 auxiliary randomness so a signature is a pure function of its
17//! inputs and the key (as `sidestr-core` seals blocks and `sidestr-nostr`
18//! signs events). siding's `schnorr.mjs` draws random aux; both are valid.
19
20use bitcoin::secp256k1::{schnorr::Signature, Keypair, Message, SecretKey, XOnlyPublicKey};
21use bitcoin::Txid;
22use sidestr_core::block::secp;
23use sidestr_nostr::event::{SignRequest, Signer};
24
25use crate::error::{Error, Result};
26
27/// A request to partially sign a block template for the federation's leaf
28/// (`federation.mjs partialSignature`). Built only by [`crate::round::Round`].
29#[derive(Debug, Clone, Copy)]
30pub struct PartialRequest<'a> {
31    chain_id: &'a str,
32    height: u32,
33    template_id: [u8; 32],
34    digest: [u8; 32],
35}
36
37impl<'a> PartialRequest<'a> {
38    pub(crate) fn new(
39        chain_id: &'a str,
40        height: u32,
41        template_id: [u8; 32],
42        digest: [u8; 32],
43    ) -> Self {
44        Self {
45            chain_id,
46            height,
47            template_id,
48            digest,
49        }
50    }
51    /// The chain the template is for.
52    pub fn chain_id(&self) -> &str {
53        self.chain_id
54    }
55    /// The template's height.
56    pub fn height(&self) -> u32 {
57        self.height
58    }
59    /// The template's identity ([`sidestr_core::block::template_id`]): what
60    /// is being authorised, unchanged by sealing.
61    pub fn template_id(&self) -> &[u8; 32] {
62        &self.template_id
63    }
64    /// The tapscript sighash the signature is over.
65    pub fn digest(&self) -> &[u8; 32] {
66        &self.digest
67    }
68}
69
70/// A request to sign one input of a peg-out PSBT for the federation's leaf
71/// (`pegoutround.mjs onProposal`, the wallet's `walletprocesspsbt`). Built
72/// only by [`crate::pegout`].
73#[derive(Debug, Clone, Copy)]
74pub struct PegoutSignRequest<'a> {
75    chain_id: &'a str,
76    burn: &'a str,
77    unsigned_txid: Txid,
78    input: usize,
79    digest: [u8; 32],
80}
81
82impl<'a> PegoutSignRequest<'a> {
83    pub(crate) fn new(
84        chain_id: &'a str,
85        burn: &'a str,
86        unsigned_txid: Txid,
87        input: usize,
88        digest: [u8; 32],
89    ) -> Self {
90        Self {
91            chain_id,
92            burn,
93            unsigned_txid,
94            input,
95            digest,
96        }
97    }
98    /// The chain the burn is on.
99    pub fn chain_id(&self) -> &str {
100        self.chain_id
101    }
102    /// The burn being paid, `<txid>:<vout>`.
103    pub fn burn(&self) -> &str {
104        self.burn
105    }
106    /// The txid of the unsigned parent transaction.
107    pub fn unsigned_txid(&self) -> Txid {
108        self.unsigned_txid
109    }
110    /// Which input.
111    pub fn input(&self) -> usize {
112        self.input
113    }
114    /// The tapscript sighash the signature is over.
115    pub fn digest(&self) -> &[u8; 32] {
116        &self.digest
117    }
118}
119
120/// The block-and-peg custody key behind a port: two named operations, no
121/// generic digest signing.
122pub trait BlockSigner {
123    /// The x-only key that sits in the federation's leaf.
124    fn pubkey(&self) -> XOnlyPublicKey;
125    /// A BIP-340 signature over a block template's tapscript sighash.
126    fn sign_partial(&self, request: &PartialRequest<'_>) -> Result<Signature>;
127    /// A BIP-340 signature over one peg-out input's tapscript sighash.
128    fn sign_pegout_input(&self, request: &PegoutSignRequest<'_>) -> Result<Signature>;
129}
130
131/// What the round needs of a signer: Nostr events and block signatures from
132/// one key, as upstream. Implemented for anything that is both.
133pub trait RoundSigner: Signer + BlockSigner {}
134impl<T: Signer + BlockSigner + ?Sized> RoundSigner for T {}
135
136/// A secp256k1 key held in memory, from a key file's text (32-byte hex,
137/// `sign.mjs loadKey`). Nothing here prints or displays the key.
138pub struct LocalKey {
139    keypair: Keypair,
140}
141
142impl core::fmt::Debug for LocalKey {
143    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
144        f.debug_struct("LocalKey")
145            .field("pubkey", &self.pubkey())
146            .finish_non_exhaustive()
147    }
148}
149
150impl LocalKey {
151    /// From a secret key.
152    pub fn new(key: SecretKey) -> Self {
153        Self {
154            keypair: Keypair::from_secret_key(secp(), &key),
155        }
156    }
157    /// From 32 bytes.
158    pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self> {
159        Ok(Self::new(
160            SecretKey::from_slice(bytes).map_err(|e| Error::Key(e.to_string()))?,
161        ))
162    }
163    /// From a key file's text: 64 hex characters, surrounding whitespace
164    /// ignored (`sign.mjs loadKey`).
165    pub fn from_hex(text: &str) -> Result<Self> {
166        Ok(Self::new(sidestr_core::block::key_from_hex(text)?))
167    }
168    /// The x-only public key as 64 lowercase hex characters.
169    pub fn pubkey_hex(&self) -> String {
170        hex::encode(self.pubkey().serialize())
171    }
172    fn sign_digest(&self, digest: &[u8; 32]) -> Signature {
173        secp().sign_schnorr_with_aux_rand(&Message::from_digest(*digest), &self.keypair, &[0u8; 32])
174    }
175}
176
177impl Signer for LocalKey {
178    fn pubkey_hex(&self) -> sidestr_nostr::Result<String> {
179        Ok(LocalKey::pubkey_hex(self))
180    }
181    fn sign(&self, request: &SignRequest<'_>) -> sidestr_nostr::Result<[u8; 64]> {
182        Ok(*self.sign_digest(request.id()).as_ref())
183    }
184}
185
186impl BlockSigner for LocalKey {
187    fn pubkey(&self) -> XOnlyPublicKey {
188        self.keypair.x_only_public_key().0
189    }
190    fn sign_partial(&self, request: &PartialRequest<'_>) -> Result<Signature> {
191        Ok(self.sign_digest(request.digest()))
192    }
193    fn sign_pegout_input(&self, request: &PegoutSignRequest<'_>) -> Result<Signature> {
194        Ok(self.sign_digest(request.digest()))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn a_local_key_is_deterministic_and_never_displays_itself() {
204        let k = LocalKey::from_hex(&format!(" {} \n", "07".repeat(32))).unwrap();
205        assert_eq!(k.pubkey_hex().len(), 64);
206        assert!(!format!("{k:?}").contains(&"07".repeat(32)));
207        let r = PartialRequest::new("sidestr:t", 1, [1u8; 32], [2u8; 32]);
208        assert_eq!(k.sign_partial(&r).unwrap(), k.sign_partial(&r).unwrap());
209        assert!(LocalKey::from_hex("zz").is_err());
210        assert!(LocalKey::from_bytes(&[0u8; 32]).is_err());
211    }
212}