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, TryIntoAccountId};
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 TryIntoAccountId,
106        public_key: impl AsRef<str>,
107    ) -> Result<Self, Error> {
108        let network = network.as_ref();
109        let account_id: AccountId = account_id.try_into_account_id()?;
110        let public_key_str = public_key.as_ref();
111
112        // Parse public key for validation
113        let public_key: PublicKey = public_key_str.parse()?;
114
115        // Construct keyring entry using near-cli-rs format
116        // Service: "near-{network}-{account_id}"
117        // Username: "{account_id}:{public_key}"
118        let service_name = format!("near-{}-{}", network, account_id);
119        let username = format!("{}:{}", account_id, public_key_str);
120
121        let entry = keyring::Entry::new(&service_name, &username).map_err(|e| {
122            Error::KeyStore(KeyStoreError::Platform(format!(
123                "Failed to access keyring: {}. On Linux, ensure a Secret Service daemon \
124                 (gnome-keyring, kwallet) is running.",
125                e
126            )))
127        })?;
128
129        let password = entry.get_password().map_err(|e| match e {
130            keyring::Error::NoEntry => {
131                Error::KeyStore(KeyStoreError::KeyNotFound(account_id.clone()))
132            }
133            _ => Error::KeyStore(KeyStoreError::Platform(format!(
134                "Failed to read from keyring: {}",
135                e
136            ))),
137        })?;
138
139        // Parse the stored JSON credential
140        let secret_key = parse_keyring_credential(&password, &account_id, &public_key)?;
141
142        // Create the inner signer
143        let inner = InMemorySigner::from_secret_key(account_id, secret_key)?;
144
145        // Verify the public key matches
146        if inner.public_key() != &public_key {
147            return Err(Error::KeyStore(KeyStoreError::InvalidFormat(format!(
148                "Public key mismatch: stored key has {}, but requested {}",
149                inner.public_key(),
150                public_key
151            ))));
152        }
153
154        Ok(Self { inner })
155    }
156
157    /// Get the public key.
158    pub fn public_key(&self) -> &PublicKey {
159        self.inner.public_key()
160    }
161
162    /// Unwrap into the underlying [`InMemorySigner`].
163    pub fn into_inner(self) -> InMemorySigner {
164        self.inner
165    }
166}
167
168impl std::fmt::Debug for KeyringSigner {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("KeyringSigner")
171            .field("account_id", self.inner.account_id())
172            .field("public_key", self.inner.public_key())
173            .finish()
174    }
175}
176
177impl Signer for KeyringSigner {
178    fn account_id(&self) -> &AccountId {
179        self.inner.account_id()
180    }
181
182    fn key(&self) -> SigningKey {
183        self.inner.key()
184    }
185}
186
187// ============================================================================
188// Credential Parsing
189// ============================================================================
190
191/// Parse a keyring credential JSON into a SecretKey.
192///
193/// Supports two formats used by near-cli-rs:
194///
195/// 1. Full format (from seed phrase import):
196/// ```json
197/// {
198///   "seed_phrase_hd_path": "m/44'/397'/0'",
199///   "master_seed_phrase": "word1 word2 ...",
200///   "implicit_account_id": "...",
201///   "public_key": "ed25519:...",
202///   "private_key": "ed25519:..."
203/// }
204/// ```
205///
206/// 2. Simple format (from private key import):
207/// ```json
208/// {
209///   "public_key": "ed25519:...",
210///   "private_key": "ed25519:..."
211/// }
212/// ```
213fn parse_keyring_credential(
214    json_str: &str,
215    account_id: &AccountId,
216    _public_key: &PublicKey,
217) -> Result<SecretKey, Error> {
218    // Try to parse as JSON
219    let value: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
220        Error::KeyStore(KeyStoreError::InvalidFormat(format!(
221            "Invalid JSON in keyring credential for {}: {}",
222            account_id, e
223        )))
224    })?;
225
226    // Extract private_key field (works for both formats)
227    let private_key_str = value
228        .get("private_key")
229        .and_then(|v| v.as_str())
230        .ok_or_else(|| {
231            Error::KeyStore(KeyStoreError::InvalidFormat(format!(
232                "Missing 'private_key' field in keyring credential for {}",
233                account_id
234            )))
235        })?;
236
237    // Parse the secret key
238    let secret_key: SecretKey = private_key_str
239        .parse()
240        .map_err(|e: ParseKeyError| Error::KeyStore(KeyStoreError::InvalidKey(e)))?;
241
242    Ok(secret_key)
243}
244
245// ============================================================================
246// Tests
247// ============================================================================
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_parse_full_format() {
255        let json = r#"{
256            "seed_phrase_hd_path": "m/44'/397'/0'",
257            "master_seed_phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
258            "implicit_account_id": "c4f5941e81e071c2fd1dae2e71fd3d859d462484391d9a90bf219211dcbb320f",
259            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
260            "private_key": "ed25519:3D4YudUahN1nawWogh8pAKSj92sUNMdbZGjn7kERKzYoTy8tnFQuwoGUC51DowKqorvkr2pytJSnwuSbsNVfqygr"
261        }"#;
262
263        let account_id: AccountId = "alice.testnet".parse().unwrap();
264        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
265            .parse()
266            .unwrap();
267
268        let secret_key = parse_keyring_credential(json, &account_id, &public_key).unwrap();
269
270        // Verify the key was parsed correctly
271        assert!(secret_key.to_string().starts_with("ed25519:"));
272    }
273
274    #[test]
275    fn test_parse_simple_format() {
276        let json = r#"{
277            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
278            "private_key": "ed25519:3D4YudUahN1nawWogh8pAKSj92sUNMdbZGjn7kERKzYoTy8tnFQuwoGUC51DowKqorvkr2pytJSnwuSbsNVfqygr"
279        }"#;
280
281        let account_id: AccountId = "alice.testnet".parse().unwrap();
282        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
283            .parse()
284            .unwrap();
285
286        let secret_key = parse_keyring_credential(json, &account_id, &public_key).unwrap();
287
288        // Verify the key was parsed correctly
289        assert!(secret_key.to_string().starts_with("ed25519:"));
290    }
291
292    #[test]
293    fn test_parse_missing_private_key() {
294        let json = r#"{
295            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
296        }"#;
297
298        let account_id: AccountId = "alice.testnet".parse().unwrap();
299        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
300            .parse()
301            .unwrap();
302
303        let result = parse_keyring_credential(json, &account_id, &public_key);
304        assert!(result.is_err());
305
306        let err = result.unwrap_err();
307        assert!(err.to_string().contains("Missing 'private_key' field"));
308    }
309
310    #[test]
311    fn test_parse_invalid_json() {
312        let json = "not valid json";
313
314        let account_id: AccountId = "alice.testnet".parse().unwrap();
315        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
316            .parse()
317            .unwrap();
318
319        let result = parse_keyring_credential(json, &account_id, &public_key);
320        assert!(result.is_err());
321
322        let err = result.unwrap_err();
323        assert!(err.to_string().contains("Invalid JSON"));
324    }
325
326    #[test]
327    fn test_parse_invalid_key_format() {
328        let json = r#"{
329            "public_key": "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847",
330            "private_key": "not-a-valid-key"
331        }"#;
332
333        let account_id: AccountId = "alice.testnet".parse().unwrap();
334        let public_key: PublicKey = "ed25519:DcA2MzgpJbrUATQLLceocVckhhAqrkingax4oJ9kZ847"
335            .parse()
336            .unwrap();
337
338        let result = parse_keyring_credential(json, &account_id, &public_key);
339        assert!(result.is_err());
340    }
341}