Skip to main content

near_kit/client/
keyring_signer.rs

1//! Keyring-based signer using system credential storage.
2//!
3//! This module provides [`KeyringSigner`], which loads keys from the operating system's
4//! native credential storage (macOS Keychain, Windows Credential Manager, or Linux
5//! Secret Service).
6//!
7//! # Compatibility
8//!
9//! `KeyringSigner` is fully compatible with keys stored by `near-cli-rs`. If you've
10//! imported keys using `near-cli-rs`, you can use them directly with `near-kit-rs`.
11//!
12//! # Platform Support
13//!
14//! - **macOS**: Uses Keychain (works out of the box)
15//! - **Windows**: Uses Credential Manager (works out of the box)
16//! - **Linux**: Uses Secret Service D-Bus API (requires `gnome-keyring`, `kwallet`, or similar)
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use near_kit::{KeyringSigner, Near};
22//!
23//! # async fn example() -> Result<(), near_kit::Error> {
24//! // Load a key stored by near-cli-rs
25//! let signer = KeyringSigner::new(
26//!     "testnet",
27//!     "alice.testnet",
28//!     "ed25519:6fWy..."
29//! )?;
30//!
31//! let near = Near::testnet().signer(signer).build();
32//! near.transfer("bob.testnet", "1 NEAR").await?;
33//! # Ok(())
34//! # }
35//! ```
36//!
37//! # Importing Keys
38//!
39//! Use `near-cli-rs` to import keys to the system keyring:
40//!
41//! ```bash
42//! near account import-account using-seed-phrase "your seed phrase here" \
43//!   --seed-phrase-hd-path "m/44'/397'/0'" \
44//!   network-config testnet
45//! ```
46
47use crate::client::signer::{InMemorySigner, Signer, SigningKey};
48use crate::error::{Error, KeyStoreError, ParseKeyError};
49use crate::types::{AccountId, PublicKey, SecretKey};
50
51/// Signer that loads keys from the system keyring.
52///
53/// Compatible with keys stored by `near-cli-rs`.
54///
55/// # Construction
56///
57/// Unlike [`FileSigner`](crate::FileSigner), `KeyringSigner` requires the public key
58/// because keyring entries are keyed by `{account_id}:{public_key}`.
59///
60/// ```rust,no_run
61/// use near_kit::KeyringSigner;
62///
63/// let signer = KeyringSigner::new(
64///     "testnet",
65///     "alice.testnet",
66///     "ed25519:6fWy..."
67/// )?;
68/// # Ok::<(), near_kit::Error>(())
69/// ```
70#[derive(Clone)]
71pub struct KeyringSigner {
72    inner: InMemorySigner,
73}
74
75impl KeyringSigner {
76    /// Load a key from the system keyring.
77    ///
78    /// # Arguments
79    ///
80    /// * `network` - Network name (e.g., "testnet", "mainnet")
81    /// * `account_id` - The NEAR account ID
82    /// * `public_key` - The public key to look up (e.g., "ed25519:...")
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if:
87    /// - The keyring is not available (e.g., no Secret Service on Linux)
88    /// - The key is not found in the keyring
89    /// - The stored credential has an invalid format
90    ///
91    /// # Example
92    ///
93    /// ```rust,no_run
94    /// use near_kit::KeyringSigner;
95    ///
96    /// let signer = KeyringSigner::new(
97    ///     "testnet",
98    ///     "alice.testnet",
99    ///     "ed25519:6fWy..."
100    /// )?;
101    /// # Ok::<(), near_kit::Error>(())
102    /// ```
103    pub fn new(
104        network: impl AsRef<str>,
105        account_id: impl AsRef<str>,
106        public_key: impl AsRef<str>,
107    ) -> Result<Self, Error> {
108        let network = network.as_ref();
109        let account_id_str = account_id.as_ref();
110        let public_key_str = public_key.as_ref();
111
112        // Parse account ID and public key for validation
113        let account_id: AccountId = account_id_str.parse()?;
114        let public_key: PublicKey = public_key_str.parse()?;
115
116        // Construct keyring entry using near-cli-rs format
117        // Service: "near-{network}-{account_id}"
118        // Username: "{account_id}:{public_key}"
119        let service_name = format!("near-{}-{}", network, account_id_str);
120        let username = format!("{}:{}", account_id_str, public_key_str);
121
122        let entry = keyring::Entry::new(&service_name, &username).map_err(|e| {
123            Error::KeyStore(KeyStoreError::Platform(format!(
124                "Failed to access keyring: {}. On Linux, ensure a Secret Service daemon \
125                 (gnome-keyring, kwallet) is running.",
126                e
127            )))
128        })?;
129
130        let password = entry.get_password().map_err(|e| match e {
131            keyring::Error::NoEntry => {
132                Error::KeyStore(KeyStoreError::KeyNotFound(account_id.clone()))
133            }
134            _ => Error::KeyStore(KeyStoreError::Platform(format!(
135                "Failed to read from keyring: {}",
136                e
137            ))),
138        })?;
139
140        // Parse the stored JSON credential
141        let secret_key = parse_keyring_credential(&password, &account_id, &public_key)?;
142
143        // Create the inner signer
144        let inner = InMemorySigner::from_secret_key(account_id, secret_key);
145
146        // Verify the public key matches
147        if inner.public_key() != &public_key {
148            return Err(Error::KeyStore(KeyStoreError::InvalidFormat(format!(
149                "Public key mismatch: stored key has {}, but requested {}",
150                inner.public_key(),
151                public_key
152            ))));
153        }
154
155        Ok(Self { inner })
156    }
157
158    /// Get the public key.
159    pub fn public_key(&self) -> &PublicKey {
160        self.inner.public_key()
161    }
162
163    /// Unwrap into the underlying [`InMemorySigner`].
164    pub fn into_inner(self) -> InMemorySigner {
165        self.inner
166    }
167}
168
169impl std::fmt::Debug for KeyringSigner {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("KeyringSigner")
172            .field("account_id", self.inner.account_id())
173            .field("public_key", self.inner.public_key())
174            .finish()
175    }
176}
177
178impl Signer for KeyringSigner {
179    fn account_id(&self) -> &AccountId {
180        self.inner.account_id()
181    }
182
183    fn key(&self) -> SigningKey {
184        self.inner.key()
185    }
186}
187
188// ============================================================================
189// Credential Parsing
190// ============================================================================
191
192/// Parse a keyring credential JSON into a SecretKey.
193///
194/// Supports two formats used by near-cli-rs:
195///
196/// 1. Full format (from seed phrase import):
197/// ```json
198/// {
199///   "seed_phrase_hd_path": "m/44'/397'/0'",
200///   "master_seed_phrase": "word1 word2 ...",
201///   "implicit_account_id": "...",
202///   "public_key": "ed25519:...",
203///   "private_key": "ed25519:..."
204/// }
205/// ```
206///
207/// 2. Simple format (from private key import):
208/// ```json
209/// {
210///   "public_key": "ed25519:...",
211///   "private_key": "ed25519:..."
212/// }
213/// ```
214fn parse_keyring_credential(
215    json_str: &str,
216    account_id: &AccountId,
217    _public_key: &PublicKey,
218) -> Result<SecretKey, Error> {
219    // Try to parse as JSON
220    let value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
221        Error::KeyStore(KeyStoreError::InvalidFormat(format!(
222            "Invalid JSON in keyring credential for {}: {}",
223            account_id, e
224        )))
225    })?;
226
227    // Extract private_key field (works for both formats)
228    let private_key_str = value
229        .get("private_key")
230        .and_then(|v| v.as_str())
231        .ok_or_else(|| {
232            Error::KeyStore(KeyStoreError::InvalidFormat(format!(
233                "Missing 'private_key' field in keyring credential for {}",
234                account_id
235            )))
236        })?;
237
238    // Parse the secret key
239    let secret_key: SecretKey = private_key_str
240        .parse()
241        .map_err(|e: ParseKeyError| Error::KeyStore(KeyStoreError::InvalidKey(e)))?;
242
243    Ok(secret_key)
244}
245
246// ============================================================================
247// Tests
248// ============================================================================
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_parse_full_format() {
256        let json = r#"{
257            "seed_phrase_hd_path": "m/44'/397'/0'",
258            "master_seed_phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
259            "implicit_account_id": "c4f5941e81e071c2fd1dae2e71fd3d859d462484391d9a90bf219211dcbb320f",
260            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
261            "private_key": "ed25519:3D4YudUahN1nawWogh8pAKSj92sUNMdbZGjn7kERKzYoTy8tnFQuwoGUC51DowKqorvkr2pytJSnwuSbsNVfqygr"
262        }"#;
263
264        let account_id: AccountId = "alice.testnet".parse().unwrap();
265        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
266            .parse()
267            .unwrap();
268
269        let secret_key = parse_keyring_credential(json, &account_id, &public_key).unwrap();
270
271        // Verify the key was parsed correctly
272        assert!(secret_key.to_string().starts_with("ed25519:"));
273    }
274
275    #[test]
276    fn test_parse_simple_format() {
277        let json = r#"{
278            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
279            "private_key": "ed25519:3D4YudUahN1nawWogh8pAKSj92sUNMdbZGjn7kERKzYoTy8tnFQuwoGUC51DowKqorvkr2pytJSnwuSbsNVfqygr"
280        }"#;
281
282        let account_id: AccountId = "alice.testnet".parse().unwrap();
283        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
284            .parse()
285            .unwrap();
286
287        let secret_key = parse_keyring_credential(json, &account_id, &public_key).unwrap();
288
289        // Verify the key was parsed correctly
290        assert!(secret_key.to_string().starts_with("ed25519:"));
291    }
292
293    #[test]
294    fn test_parse_missing_private_key() {
295        let json = r#"{
296            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
297        }"#;
298
299        let account_id: AccountId = "alice.testnet".parse().unwrap();
300        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
301            .parse()
302            .unwrap();
303
304        let result = parse_keyring_credential(json, &account_id, &public_key);
305        assert!(result.is_err());
306
307        let err = result.unwrap_err();
308        assert!(err.to_string().contains("Missing 'private_key' field"));
309    }
310
311    #[test]
312    fn test_parse_invalid_json() {
313        let json = "not valid json";
314
315        let account_id: AccountId = "alice.testnet".parse().unwrap();
316        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
317            .parse()
318            .unwrap();
319
320        let result = parse_keyring_credential(json, &account_id, &public_key);
321        assert!(result.is_err());
322
323        let err = result.unwrap_err();
324        assert!(err.to_string().contains("Invalid JSON"));
325    }
326
327    #[test]
328    fn test_parse_invalid_key_format() {
329        let json = r#"{
330            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
331            "private_key": "not-a-valid-key"
332        }"#;
333
334        let account_id: AccountId = "alice.testnet".parse().unwrap();
335        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
336            .parse()
337            .unwrap();
338
339        let result = parse_keyring_credential(json, &account_id, &public_key);
340        assert!(result.is_err());
341    }
342}