Skip to main content

reifydb_auth/method/
solana.rs

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