1use 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); 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); 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 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 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 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 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}