Skip to main content

reifydb_auth/method/
solana.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4//! Solana wallet authentication provider.
5//!
6//! Implements a challenge-response flow compatible with Sign In With Solana (SIWS):
7//! 1. Client initiates auth → provider returns a challenge with a SIWS message to sign
8//! 2. Client signs the message with their wallet → provider verifies the ed25519 signature
9
10use std::collections::HashMap;
11
12use bs58::decode as bs58_decode;
13use ed25519_dalek::{Signature, Verifier, VerifyingKey};
14use reifydb_core::interface::auth::{AuthStep, AuthenticationProvider};
15use reifydb_runtime::context::{clock::Clock, rng::Rng};
16use reifydb_type::{Result, error::Error};
17
18use crate::error::AuthError;
19
20pub struct SolanaProvider {
21	clock: Clock,
22}
23
24impl SolanaProvider {
25	pub fn new(clock: Clock) -> Self {
26		Self {
27			clock,
28		}
29	}
30}
31
32impl AuthenticationProvider for SolanaProvider {
33	fn method(&self) -> &str {
34		"solana"
35	}
36
37	fn create(&self, _rng: &Rng, config: &HashMap<String, String>) -> Result<HashMap<String, String>> {
38		let public_key = config.get("public_key").ok_or_else(|| Error::from(AuthError::MissingPublicKey))?;
39
40		// Validate that the public key is valid base58 and decodes to 32 bytes
41		let bytes = bs58_decode(public_key).into_vec().map_err(|e| {
42			Error::from(AuthError::InvalidPublicKey {
43				reason: e.to_string(),
44			})
45		})?;
46
47		if bytes.len() != 32 {
48			return Err(Error::from(AuthError::InvalidPublicKey {
49				reason: format!("expected 32 bytes, got {}", bytes.len()),
50			}));
51		}
52
53		Ok(HashMap::from([("public_key".into(), public_key.clone())]))
54	}
55
56	fn authenticate(
57		&self,
58		stored: &HashMap<String, String>,
59		credentials: &HashMap<String, String>,
60	) -> Result<AuthStep> {
61		let public_key_b58 =
62			stored.get("public_key").ok_or_else(|| Error::from(AuthError::MissingPublicKey))?;
63
64		// Step 2: Verify signature (credentials contain "signature" + "signed_message" merged from challenge)
65		if let Some(signature_b58) = credentials.get("signature") {
66			let signed_message = credentials.get("signed_message").ok_or_else(|| {
67				Error::from(AuthError::InvalidSignature {
68					reason: "missing signed_message".to_string(),
69				})
70			})?;
71
72			// Decode public key
73			let pk_bytes: [u8; 32] = bs58_decode(public_key_b58)
74				.into_vec()
75				.map_err(|e| {
76					Error::from(AuthError::InvalidPublicKey {
77						reason: e.to_string(),
78					})
79				})?
80				.try_into()
81				.map_err(|_| {
82					Error::from(AuthError::InvalidPublicKey {
83						reason: "expected 32 bytes".to_string(),
84					})
85				})?;
86
87			let verifying_key = VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
88				Error::from(AuthError::InvalidPublicKey {
89					reason: e.to_string(),
90				})
91			})?;
92
93			// Decode signature
94			let sig_bytes: [u8; 64] = bs58_decode(signature_b58)
95				.into_vec()
96				.map_err(|e| {
97					Error::from(AuthError::InvalidSignature {
98						reason: e.to_string(),
99					})
100				})?
101				.try_into()
102				.map_err(|_| {
103					Error::from(AuthError::InvalidSignature {
104						reason: "expected 64 bytes".to_string(),
105					})
106				})?;
107
108			let signature = Signature::from_bytes(&sig_bytes);
109
110			// Verify
111			match verifying_key.verify(signed_message.as_bytes(), &signature) {
112				Ok(()) => return Ok(AuthStep::Authenticated),
113				Err(_) => return Ok(AuthStep::Failed),
114			}
115		}
116
117		// Step 1: Generate challenge — build SIWS message with nonce
118		let nonce_bytes = Rng::Os.bytes_32();
119		let nonce: String = nonce_bytes.iter().map(|b| format!("{:02x}", b)).collect();
120
121		// Get optional domain and statement from credentials (caller can provide context)
122		let domain = credentials.get("domain").cloned().unwrap_or_else(|| "reifydb".to_string());
123		let statement =
124			credentials.get("statement").cloned().unwrap_or_else(|| "Sign in to ReifyDB".to_string());
125
126		let issued_at =
127			credentials.get("issued_at").cloned().unwrap_or_else(|| self.clock.now_secs().to_string());
128
129		// Build SIWS-standard message
130		let message = format!(
131			"{domain} wants you to sign in with your Solana account:\n\
132			 {address}\n\
133			 \n\
134			 {statement}\n\
135			 \n\
136			 Nonce: {nonce}\n\
137			 Issued At: {issued_at}",
138			domain = domain,
139			address = public_key_b58,
140			statement = statement,
141			nonce = nonce,
142			issued_at = issued_at,
143		);
144
145		Ok(AuthStep::Challenge {
146			payload: HashMap::from([("message".into(), message), ("nonce".into(), nonce)]),
147		})
148	}
149}
150
151#[cfg(test)]
152mod tests {
153	use bs58::encode as bs58_encode;
154	use ed25519_dalek::{Signer, SigningKey};
155	use reifydb_runtime::context::clock::MockClock;
156
157	use super::*;
158
159	fn test_provider() -> SolanaProvider {
160		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
161		SolanaProvider::new(Clock::Mock(mock))
162	}
163
164	fn test_keypair() -> (SigningKey, String) {
165		let secret: [u8; 32] = [
166			1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
167			27, 28, 29, 30, 31, 32,
168		];
169		let signing_key = SigningKey::from_bytes(&secret);
170		let public_key = signing_key.verifying_key();
171		let public_key_b58 = bs58_encode(public_key.as_bytes()).into_string();
172		(signing_key, public_key_b58)
173	}
174
175	#[test]
176	fn test_create_stores_public_key() {
177		let provider = test_provider();
178		let (_, public_key_b58) = test_keypair();
179		let config = HashMap::from([("public_key".to_string(), public_key_b58.clone())]);
180
181		let stored = provider.create(&Rng::default(), &config).unwrap();
182		assert_eq!(stored.get("public_key").unwrap(), &public_key_b58);
183	}
184
185	#[test]
186	fn test_create_requires_public_key() {
187		let provider = test_provider();
188		assert!(provider.create(&Rng::default(), &HashMap::new()).is_err());
189	}
190
191	#[test]
192	fn test_create_rejects_invalid_public_key() {
193		let provider = test_provider();
194		let config = HashMap::from([("public_key".to_string(), "not-valid-base58!!!".to_string())]);
195		assert!(provider.create(&Rng::default(), &config).is_err());
196	}
197
198	#[test]
199	fn test_create_rejects_wrong_length_key() {
200		let provider = test_provider();
201		// Only 16 bytes
202		let short_key = bs58_encode(&[0u8; 16]).into_string();
203		let config = HashMap::from([("public_key".to_string(), short_key)]);
204		assert!(provider.create(&Rng::default(), &config).is_err());
205	}
206
207	#[test]
208	fn test_challenge_response_flow() {
209		let provider = test_provider();
210		let (signing_key, public_key_b58) = test_keypair();
211		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
212
213		// Step 1: Get challenge
214		let step1 = provider.authenticate(&stored, &HashMap::new()).unwrap();
215		let challenge_data = match step1 {
216			AuthStep::Challenge {
217				payload,
218			} => payload,
219			other => panic!("expected Challenge, got {:?}", other),
220		};
221
222		assert!(challenge_data.contains_key("message"));
223		assert!(challenge_data.contains_key("nonce"));
224
225		let message = challenge_data.get("message").unwrap();
226		assert!(message.contains("wants you to sign in with your Solana account"));
227		assert!(message.contains("Nonce:"));
228		assert!(message.contains("Issued At: 1700000000"));
229
230		// Step 2: Sign the message and verify
231		let signature = signing_key.sign(message.as_bytes());
232		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
233
234		let credentials = HashMap::from([
235			("signature".to_string(), signature_b58),
236			("signed_message".to_string(), message.clone()),
237		]);
238
239		let step2 = provider.authenticate(&stored, &credentials).unwrap();
240		assert_eq!(step2, AuthStep::Authenticated);
241	}
242
243	#[test]
244	fn test_invalid_signature_fails() {
245		let provider = test_provider();
246		let (_, public_key_b58) = test_keypair();
247		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
248
249		// Use a different key to sign
250		let wrong_key = SigningKey::from_bytes(&[99u8; 32]);
251		let signature = wrong_key.sign(b"some message");
252		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
253
254		let credentials = HashMap::from([
255			("signature".to_string(), signature_b58),
256			("signed_message".to_string(), "some message".to_string()),
257		]);
258
259		let step = provider.authenticate(&stored, &credentials).unwrap();
260		assert_eq!(step, AuthStep::Failed);
261	}
262}