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::SolanaError;
13
14pub const SIGNED_MESSAGE_MISMATCH: &str = "signed message does not match the issued challenge";
15
16pub struct SolanaProvider {
17	clock: Clock,
18	rng: Rng,
19}
20
21impl SolanaProvider {
22	pub fn new(clock: Clock, rng: Rng) -> Self {
23		Self {
24			clock,
25			rng,
26		}
27	}
28}
29
30impl AuthenticationProvider for SolanaProvider {
31	fn method(&self) -> &str {
32		"solana"
33	}
34
35	fn create(&self, _rng: &Rng, config: &HashMap<String, String>) -> Result<HashMap<String, String>> {
36		let public_key = config.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;
37
38		let bytes = bs58_decode(public_key).into_vec().map_err(|e| {
39			Error::from(SolanaError::InvalidPublicKey {
40				reason: e.to_string(),
41			})
42		})?;
43
44		if bytes.len() != 32 {
45			return Err(Error::from(SolanaError::InvalidPublicKey {
46				reason: format!("expected 32 bytes, got {}", bytes.len()),
47			}));
48		}
49
50		Ok(HashMap::from([("public_key".into(), public_key.clone())]))
51	}
52
53	fn authenticate(
54		&self,
55		stored: &HashMap<String, String>,
56		credentials: &HashMap<String, String>,
57	) -> Result<AuthStep> {
58		let public_key_b58 =
59			stored.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;
60
61		Ok(self.issue_signin_challenge(public_key_b58, credentials))
62	}
63
64	fn verify_challenge(
65		&self,
66		stored: &HashMap<String, String>,
67		challenge: &HashMap<String, String>,
68		credentials: &HashMap<String, String>,
69	) -> Result<AuthStep> {
70		let public_key_b58 =
71			stored.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;
72		let message =
73			challenge.get("message").ok_or_else(|| Error::from(SolanaError::MissingChallengeMessage))?;
74
75		let Some(signature_b58) = credentials.get("signature") else {
76			return Ok(AuthStep::Failed);
77		};
78
79		if let Some(echoed) = credentials.get("signed_message")
80			&& echoed != message
81		{
82			return Ok(AuthStep::Rejected {
83				reason: SIGNED_MESSAGE_MISMATCH.to_string(),
84			});
85		}
86
87		self.verify_signature(public_key_b58, signature_b58, message)
88	}
89}
90
91impl SolanaProvider {
92	#[inline]
93	fn verify_signature(&self, public_key_b58: &str, signature_b58: &str, message: &str) -> Result<AuthStep> {
94		let pk_bytes: [u8; 32] = bs58_decode(public_key_b58)
95			.into_vec()
96			.map_err(|e| {
97				Error::from(SolanaError::InvalidPublicKey {
98					reason: e.to_string(),
99				})
100			})?
101			.try_into()
102			.map_err(|_| {
103				Error::from(SolanaError::InvalidPublicKey {
104					reason: "expected 32 bytes".to_string(),
105				})
106			})?;
107
108		let verifying_key = VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
109			Error::from(SolanaError::InvalidPublicKey {
110				reason: e.to_string(),
111			})
112		})?;
113
114		let sig_bytes: [u8; 64] = bs58_decode(signature_b58)
115			.into_vec()
116			.map_err(|e| {
117				Error::from(SolanaError::InvalidSignature {
118					reason: e.to_string(),
119				})
120			})?
121			.try_into()
122			.map_err(|_| {
123				Error::from(SolanaError::InvalidSignature {
124					reason: "expected 64 bytes".to_string(),
125				})
126			})?;
127
128		let signature = Signature::from_bytes(&sig_bytes);
129
130		match verifying_key.verify(message.as_bytes(), &signature) {
131			Ok(()) => Ok(AuthStep::Authenticated),
132			Err(_) => Ok(AuthStep::Failed),
133		}
134	}
135
136	#[inline]
137	fn issue_signin_challenge(&self, public_key_b58: &str, credentials: &HashMap<String, String>) -> AuthStep {
138		let nonce_bytes = self.rng.bytes_32();
139		let nonce: String = nonce_bytes.iter().map(|b| format!("{:02x}", b)).collect();
140
141		reifydb_assertions! {
142			assert!(
143				nonce.len() == 64,
144				"sign-in nonce must be 64 hex chars (32 bytes of entropy); a shorter nonce weakens \
145				 challenge-replay resistance because an attacker can brute-force or precompute it \
146				 (got {} chars)",
147				nonce.len()
148			);
149		}
150
151		let domain = credentials.get("domain").cloned().unwrap_or_else(|| "reifydb".to_string());
152		let statement =
153			credentials.get("statement").cloned().unwrap_or_else(|| "Sign in to ReifyDB".to_string());
154
155		let issued_at = credentials
156			.get("issued_at")
157			.cloned()
158			.unwrap_or_else(|| (self.clock.now().to_secs()).to_string());
159
160		let message = format!(
161			"{domain} wants you to sign in with your Solana account:\n\
162			 {address}\n\
163			 \n\
164			 {statement}\n\
165			 \n\
166			 Nonce: {nonce}\n\
167			 Issued At: {issued_at}",
168			domain = domain,
169			address = public_key_b58,
170			statement = statement,
171			nonce = nonce,
172			issued_at = issued_at,
173		);
174
175		AuthStep::Challenge {
176			payload: HashMap::from([("message".into(), message), ("nonce".into(), nonce)]),
177		}
178	}
179}
180
181#[cfg(test)]
182mod tests {
183	use bs58::encode as bs58_encode;
184	use ed25519_dalek::{Signer, SigningKey};
185	use reifydb_runtime::context::clock::MockClock;
186
187	use super::*;
188
189	fn test_provider() -> SolanaProvider {
190		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
191		SolanaProvider::new(Clock::Mock(mock), Rng::default())
192	}
193
194	fn seeded_provider(seed: u64) -> SolanaProvider {
195		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
196		SolanaProvider::new(Clock::Mock(mock), Rng::seeded(seed))
197	}
198
199	fn issued_nonce(provider: &SolanaProvider) -> String {
200		let (_, public_key_b58) = test_keypair();
201		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
202
203		match provider.authenticate(&stored, &HashMap::new()).unwrap() {
204			AuthStep::Challenge {
205				payload,
206			} => payload.get("nonce").expect("challenge must carry a nonce").clone(),
207			other => panic!("expected Challenge, got {:?}", other),
208		}
209	}
210
211	fn test_keypair() -> (SigningKey, String) {
212		let secret: [u8; 32] = [
213			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,
214			27, 28, 29, 30, 31, 32,
215		];
216		let signing_key = SigningKey::from_bytes(&secret);
217		let public_key = signing_key.verifying_key();
218		let public_key_b58 = bs58_encode(public_key.as_bytes()).into_string();
219		(signing_key, public_key_b58)
220	}
221
222	#[test]
223	fn test_create_stores_public_key() {
224		let provider = test_provider();
225		let (_, public_key_b58) = test_keypair();
226		let config = HashMap::from([("public_key".to_string(), public_key_b58.clone())]);
227
228		let stored = provider.create(&Rng::default(), &config).unwrap();
229		assert_eq!(stored.get("public_key").unwrap(), &public_key_b58);
230	}
231
232	#[test]
233	fn test_create_requires_public_key() {
234		let provider = test_provider();
235		assert!(provider.create(&Rng::default(), &HashMap::new()).is_err());
236	}
237
238	#[test]
239	fn test_create_rejects_invalid_public_key() {
240		let provider = test_provider();
241		let config = HashMap::from([("public_key".to_string(), "not-valid-base58!!!".to_string())]);
242		assert!(provider.create(&Rng::default(), &config).is_err());
243	}
244
245	#[test]
246	fn test_create_rejects_wrong_length_key() {
247		let provider = test_provider();
248		// 16 bytes decodes fine as base58; only the length check can reject it.
249		let short_key = bs58_encode(&[0u8; 16]).into_string();
250		let config = HashMap::from([("public_key".to_string(), short_key)]);
251		assert!(provider.create(&Rng::default(), &config).is_err());
252	}
253
254	#[test]
255	fn test_challenge_response_flow() {
256		let provider = test_provider();
257		let (signing_key, public_key_b58) = test_keypair();
258		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
259
260		let step1 = provider.authenticate(&stored, &HashMap::new()).unwrap();
261		let challenge_data = match step1 {
262			AuthStep::Challenge {
263				payload,
264			} => payload,
265			other => panic!("expected Challenge, got {:?}", other),
266		};
267
268		assert!(challenge_data.contains_key("message"));
269		assert!(challenge_data.contains_key("nonce"));
270
271		let message = challenge_data.get("message").unwrap();
272		assert!(message.contains("wants you to sign in with your Solana account"));
273		assert!(message.contains("Nonce:"));
274		assert!(message.contains("Issued At: 1700000000"));
275
276		let signature = signing_key.sign(message.as_bytes());
277		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
278
279		let credentials = HashMap::from([
280			("signature".to_string(), signature_b58),
281			("signed_message".to_string(), message.clone()),
282		]);
283
284		let step2 = provider.verify_challenge(&stored, &challenge_data, &credentials).unwrap();
285		assert_eq!(step2, AuthStep::Authenticated);
286	}
287
288	#[test]
289	fn test_invalid_signature_fails() {
290		let provider = test_provider();
291		let (_, public_key_b58) = test_keypair();
292		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
293
294		let wrong_key = SigningKey::from_bytes(&[99u8; 32]);
295		let signature = wrong_key.sign(b"some message");
296		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
297
298		let challenge = HashMap::from([("message".to_string(), "some message".to_string())]);
299		let credentials = HashMap::from([
300			("signature".to_string(), signature_b58),
301			("signed_message".to_string(), "some message".to_string()),
302		]);
303
304		let step = provider.verify_challenge(&stored, &challenge, &credentials).unwrap();
305		assert_eq!(step, AuthStep::Failed);
306	}
307
308	#[test]
309	fn test_nonce_is_reproducible_from_the_injected_seed() {
310		// The nonce must come from the injected Rng, never Rng::Os, or a seeded replay cannot reproduce it.
311		let first = issued_nonce(&seeded_provider(42));
312		let second = issued_nonce(&seeded_provider(42));
313
314		assert_eq!(first, second, "same seed must yield the same nonce; the provider is bypassing its Rng");
315	}
316
317	#[test]
318	fn test_nonce_differs_across_seeds() {
319		// Guards the reproducibility test: a hardcoded constant nonce would satisfy it but fail here.
320		let first = issued_nonce(&seeded_provider(42));
321		let second = issued_nonce(&seeded_provider(43));
322
323		assert_ne!(first, second, "distinct seeds must yield distinct nonces; entropy collapsed to a constant");
324	}
325
326	#[test]
327	fn test_nonce_keeps_full_entropy_under_a_seed() {
328		// Pins 32 bytes rendered as 64 hex chars so routing through the injected Rng cannot shrink entropy.
329		let nonce = issued_nonce(&seeded_provider(42));
330
331		assert_eq!(nonce.len(), 64, "nonce must stay 32 bytes of entropy rendered as 64 hex chars");
332		assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()), "nonce must be hex; got {nonce}");
333	}
334}