Skip to main content

tai_core/
signer.rs

1//! The `Signer` abstraction and built-in implementations.
2//!
3//! Tai supports four signing modes; each is a struct that implements
4//! [`Signer`]. The CLI / SDK / agent runtime picks one at boot and never
5//! touches the others.
6//!
7//! | Mode | Status | Notes |
8//! |---|---|---|
9//! | [`Ed25519FileSigner`] | implemented | local Ed25519 key on disk |
10//! | [`SuiKeystoreSigner`] | stub (v1.1) | inherits from `~/.sui/sui_config/sui.keystore` |
11//! | [`TurnkeySigner`] | stub (v1.1) | MPC + policy engine via HTTPS |
12//! | [`TeeSigner`] | stub (v1.1) | TEE-attested signing (Phala Cloud + Mysten Nautilus) |
13//!
14//! The Sui signature wire format (97 bytes total):
15//!
16//! ```text
17//! [scheme: 1B] [signature: 64B] [public_key: 32B]
18//! ```
19//!
20//! Scheme tags: `0x00 = Ed25519`, `0x01 = Secp256k1`, `0x02 = Secp256r1`.
21//!
22//! The signed digest is `blake2b_256(intent || bcs(tx_data))`, where `intent`
23//! is the 3-byte Sui intent prefix `[0, 0, 0]` (scope = TransactionData,
24//! version = V0, app = Sui) — see `transaction_digest` in `client.rs`. It is
25//! NOT an ASCII `"TransactionData::"` prefix. Callers pass in the digest; the
26//! [`Signer`] need not be aware of how it was constructed.
27
28use crate::error::TaiError;
29use crate::ids::{SuiAddress, SUI_ADDR_LEN};
30use async_trait::async_trait;
31use blake2::digest::consts::U32;
32use blake2::{Blake2b, Digest};
33use ed25519_dalek::{Signature as EdSignature, Signer as EdSigner, SigningKey, VerifyingKey};
34use std::path::Path;
35
36/// Sui signature-scheme byte for Ed25519.
37pub const SCHEME_ED25519: u8 = 0x00;
38
39/// A Sui signature in wire format: `scheme (1) || sig (64) || pubkey (32)`.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct SuiSignature {
42    /// Full 97-byte wire-format signature.
43    pub bytes: Vec<u8>,
44}
45
46impl SuiSignature {
47    /// Construct from an Ed25519 signature + public key.
48    pub fn from_ed25519(sig: EdSignature, pubkey: VerifyingKey) -> Self {
49        let mut out = Vec::with_capacity(97);
50        out.push(SCHEME_ED25519);
51        out.extend_from_slice(&sig.to_bytes());
52        out.extend_from_slice(pubkey.as_bytes());
53        SuiSignature { bytes: out }
54    }
55
56    /// Base64 encoding used in Sui JSON-RPC payloads.
57    pub fn to_base64(&self) -> String {
58        use base64ct::{Base64, Encoding};
59        Base64::encode_string(&self.bytes)
60    }
61}
62
63/// Async signer abstraction. Implementations transform a 32-byte digest
64/// (already blake2b-hashed by the caller) into a [`SuiSignature`] ready
65/// to attach to a Sui JSON-RPC `executeTransactionBlock` request.
66#[async_trait]
67pub trait Signer: Send + Sync {
68    /// The Sui address this signer authorizes for.
69    fn address(&self) -> SuiAddress;
70
71    /// Sign a 32-byte transaction digest.
72    async fn sign(&self, digest: &[u8; 32]) -> Result<SuiSignature, TaiError>;
73}
74
75// ============================================================================
76//  Ed25519FileSigner — fully implemented
77// ============================================================================
78
79/// Signs with an Ed25519 key loaded from a file on disk.
80///
81/// Two file formats are accepted:
82///
83/// 1. **Raw 32-byte seed** — first 32 bytes of the file, any trailing bytes
84///    ignored. Useful for ad-hoc test keys.
85/// 2. **Hex-encoded 32-byte seed** — UTF-8 string, optionally `0x`-prefixed,
86///    64 hex characters.
87pub struct Ed25519FileSigner {
88    key: SigningKey,
89    pubkey: VerifyingKey,
90    address: SuiAddress,
91}
92
93impl Ed25519FileSigner {
94    /// Construct from a raw 32-byte seed.
95    pub fn from_seed(seed: [u8; 32]) -> Self {
96        let key = SigningKey::from_bytes(&seed);
97        let pubkey = key.verifying_key();
98        let address = address_from_ed25519_pubkey(&pubkey);
99        Ed25519FileSigner {
100            key,
101            pubkey,
102            address,
103        }
104    }
105
106    /// Load a key from a file. Accepts raw 32-byte seed OR a hex-encoded
107    /// seed in UTF-8.
108    ///
109    /// On Unix, warns to stderr if the file is readable by group or others
110    /// (mode bits not in `0o600`). The load still proceeds — refusing
111    /// outright would be hostile to ad-hoc testing — but the warning gives
112    /// the user a chance to fix it before signing anything that costs
113    /// money.
114    pub async fn load_from_file(path: impl AsRef<Path>) -> Result<Self, TaiError> {
115        let path_ref = path.as_ref();
116        check_key_file_permissions(path_ref).await;
117        let raw = tokio::fs::read(path_ref).await?;
118        if raw.is_empty() {
119            return Err(TaiError::Signer(format!(
120                "key file is empty: {} — place a 32-byte seed (raw or hex) there",
121                path_ref.display()
122            )));
123        }
124        let seed = parse_seed_bytes(&raw)?;
125        Ok(Self::from_seed(seed))
126    }
127
128    /// Expose the public key (e.g., for diagnostics).
129    pub fn public_key(&self) -> &VerifyingKey {
130        &self.pubkey
131    }
132}
133
134#[async_trait]
135impl Signer for Ed25519FileSigner {
136    fn address(&self) -> SuiAddress {
137        self.address
138    }
139
140    async fn sign(&self, digest: &[u8; 32]) -> Result<SuiSignature, TaiError> {
141        let sig = self.key.sign(digest);
142        Ok(SuiSignature::from_ed25519(sig, self.pubkey))
143    }
144}
145
146/// Derive a Sui address from an Ed25519 public key.
147///
148/// Sui's rule: `address = blake2b_256(scheme_tag (1B) || pubkey (32B))`,
149/// taking the full 32-byte hash output.
150pub fn address_from_ed25519_pubkey(pk: &VerifyingKey) -> SuiAddress {
151    let mut hasher = Blake2b::<U32>::new();
152    hasher.update([SCHEME_ED25519]);
153    hasher.update(pk.as_bytes());
154    let out = hasher.finalize();
155    let mut bytes = [0u8; SUI_ADDR_LEN];
156    bytes.copy_from_slice(&out);
157    SuiAddress::from_bytes(bytes)
158}
159
160/// Write a 32-byte Ed25519 seed to disk in hex form, with permissions
161/// restricted to the owner (`0o600`) on Unix.
162///
163/// Creates parent directories if missing. Returns `Err` if the file already
164/// exists (callers should pass an unused path).
165pub async fn save_seed_to_file(seed: &[u8; 32], path: impl AsRef<Path>) -> Result<(), TaiError> {
166    let path_ref = path.as_ref();
167    if path_ref.exists() {
168        return Err(TaiError::Signer(format!(
169            "refusing to overwrite existing key file at {}",
170            path_ref.display()
171        )));
172    }
173    if let Some(parent) = path_ref.parent() {
174        tokio::fs::create_dir_all(parent).await?;
175    }
176    let hex_str = hex::encode(seed);
177    tokio::fs::write(path_ref, &hex_str).await?;
178    set_owner_only_perms(path_ref).await;
179    Ok(())
180}
181
182#[cfg(unix)]
183async fn check_key_file_permissions(path: &Path) {
184    use std::os::unix::fs::PermissionsExt;
185    let Ok(meta) = tokio::fs::metadata(path).await else {
186        return;
187    };
188    let mode = meta.permissions().mode() & 0o777;
189    if mode & 0o077 != 0 {
190        eprintln!(
191            "[tai] warning: key file {} has mode {:o} — group/world bits set. \
192             Recommended: chmod 600 {}",
193            path.display(),
194            mode,
195            path.display(),
196        );
197    }
198}
199
200#[cfg(not(unix))]
201async fn check_key_file_permissions(_path: &Path) {
202    // No-op on non-Unix; Windows ACLs are out of scope for v1.
203}
204
205#[cfg(unix)]
206async fn set_owner_only_perms(path: &Path) {
207    use std::os::unix::fs::PermissionsExt;
208    let perms = std::fs::Permissions::from_mode(0o600);
209    let _ = tokio::fs::set_permissions(path, perms).await;
210}
211
212#[cfg(not(unix))]
213async fn set_owner_only_perms(_path: &Path) {
214    // No-op on non-Unix; Windows ACLs are out of scope for v1.
215}
216
217fn parse_seed_bytes(raw: &[u8]) -> Result<[u8; 32], TaiError> {
218    // Heuristic: if the bytes look like printable hex (with optional 0x prefix
219    // and whitespace), parse as hex; otherwise interpret the first 32 bytes
220    // as a raw seed.
221    let looks_hex = !raw.is_empty()
222        && raw.iter().all(|b| {
223            matches!(
224                b,
225                b'0'..=b'9' | b'a'..=b'f' | b'A'..=b'F' | b'x' | b'\n' | b'\r' | b' ' | b'\t'
226            )
227        });
228
229    if looks_hex {
230        let s = std::str::from_utf8(raw)
231            .map_err(|e| TaiError::Signer(format!("key file not utf8: {e}")))?
232            .trim();
233        let s = s.strip_prefix("0x").unwrap_or(s);
234        let bytes = hex::decode(s)?;
235        if bytes.len() != 32 {
236            return Err(TaiError::Signer(format!(
237                "expected 32-byte seed, got {} bytes",
238                bytes.len()
239            )));
240        }
241        let mut out = [0u8; 32];
242        out.copy_from_slice(&bytes);
243        return Ok(out);
244    }
245
246    if raw.len() < 32 {
247        return Err(TaiError::Signer(format!(
248            "key file too short: {} bytes (need at least 32)",
249            raw.len()
250        )));
251    }
252    let mut out = [0u8; 32];
253    out.copy_from_slice(&raw[..32]);
254    Ok(out)
255}
256
257// ============================================================================
258//  Stubs for v1.1
259// ============================================================================
260
261/// Inherits the active key from `~/.sui/sui_config/sui.keystore`.
262///
263/// Not yet implemented — landing in a future release. Methods return
264/// `TaiError::Signer` instead of panicking so this type is safe to
265/// construct from external callers.
266pub struct SuiKeystoreSigner;
267
268const SUI_KEYSTORE_UNIMPL: &str =
269    "SuiKeystoreSigner is not implemented in this version; use Ed25519FileSigner";
270
271#[async_trait]
272impl Signer for SuiKeystoreSigner {
273    fn address(&self) -> SuiAddress {
274        // address() is sync and infallible by trait signature. We return a
275        // zero address as a benign sentinel; the next `sign()` call will
276        // fail cleanly with a TaiError::Signer.
277        SuiAddress::from_bytes([0u8; SUI_ADDR_LEN])
278    }
279    async fn sign(&self, _digest: &[u8; 32]) -> Result<SuiSignature, TaiError> {
280        Err(TaiError::Signer(SUI_KEYSTORE_UNIMPL.into()))
281    }
282}
283
284/// Signs via Turnkey's MPC API + policy engine.
285///
286/// Not yet implemented — landing in a future release.
287pub struct TurnkeySigner;
288
289const TURNKEY_UNIMPL: &str =
290    "TurnkeySigner is not implemented in this version; use Ed25519FileSigner";
291
292#[async_trait]
293impl Signer for TurnkeySigner {
294    fn address(&self) -> SuiAddress {
295        SuiAddress::from_bytes([0u8; SUI_ADDR_LEN])
296    }
297    async fn sign(&self, _digest: &[u8; 32]) -> Result<SuiSignature, TaiError> {
298        Err(TaiError::Signer(TURNKEY_UNIMPL.into()))
299    }
300}
301
302/// Signs via a TEE-attested endpoint (Phala Cloud + Mysten Nautilus).
303///
304/// Not yet implemented — landing in a future release.
305pub struct TeeSigner;
306
307const TEE_UNIMPL: &str = "TeeSigner is not implemented in this version; use Ed25519FileSigner";
308
309#[async_trait]
310impl Signer for TeeSigner {
311    fn address(&self) -> SuiAddress {
312        SuiAddress::from_bytes([0u8; SUI_ADDR_LEN])
313    }
314    async fn sign(&self, _digest: &[u8; 32]) -> Result<SuiSignature, TaiError> {
315        Err(TaiError::Signer(TEE_UNIMPL.into()))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn ed25519_signer_derives_a_well_formed_address() {
325        let signer = Ed25519FileSigner::from_seed([7u8; 32]);
326        let addr = signer.address();
327        let s = addr.to_string();
328        // Address is 0x + 64 hex chars, all from the hex alphabet.
329        assert!(s.starts_with("0x"));
330        assert_eq!(s.len(), 66);
331    }
332
333    #[tokio::test]
334    async fn ed25519_signer_produces_97_byte_wire_signature() {
335        let signer = Ed25519FileSigner::from_seed([7u8; 32]);
336        let digest = [0u8; 32];
337        let sig = signer.sign(&digest).await.unwrap();
338        assert_eq!(sig.bytes.len(), 97);
339        assert_eq!(sig.bytes[0], SCHEME_ED25519);
340    }
341
342    #[test]
343    fn deterministic_address_for_known_seed() {
344        // Sanity check: same seed -> same address every time.
345        let a = Ed25519FileSigner::from_seed([42u8; 32]).address();
346        let b = Ed25519FileSigner::from_seed([42u8; 32]).address();
347        assert_eq!(a, b);
348    }
349}