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::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); 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 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 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 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 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}