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 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(SolanaError::MissingPublicKey))?;
33
34 let bytes = bs58_decode(public_key).into_vec().map_err(|e| {
35 Error::from(SolanaError::InvalidPublicKey {
36 reason: e.to_string(),
37 })
38 })?;
39
40 if bytes.len() != 32 {
41 return Err(Error::from(SolanaError::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(SolanaError::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(SolanaError::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(SolanaError::InvalidPublicKey {
83 reason: e.to_string(),
84 })
85 })?
86 .try_into()
87 .map_err(|_| {
88 Error::from(SolanaError::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(SolanaError::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(SolanaError::InvalidSignature {
103 reason: e.to_string(),
104 })
105 })?
106 .try_into()
107 .map_err(|_| {
108 Error::from(SolanaError::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 = credentials
141 .get("issued_at")
142 .cloned()
143 .unwrap_or_else(|| (self.clock.now().to_secs()).to_string());
144
145 let message = format!(
146 "{domain} wants you to sign in with your Solana account:\n\
147 {address}\n\
148 \n\
149 {statement}\n\
150 \n\
151 Nonce: {nonce}\n\
152 Issued At: {issued_at}",
153 domain = domain,
154 address = public_key_b58,
155 statement = statement,
156 nonce = nonce,
157 issued_at = issued_at,
158 );
159
160 AuthStep::Challenge {
161 payload: HashMap::from([("message".into(), message), ("nonce".into(), nonce)]),
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use bs58::encode as bs58_encode;
169 use ed25519_dalek::{Signer, SigningKey};
170 use reifydb_runtime::context::clock::MockClock;
171
172 use super::*;
173
174 fn test_provider() -> SolanaProvider {
175 let mock = MockClock::from_millis(1_700_000_000_000); SolanaProvider::new(Clock::Mock(mock))
177 }
178
179 fn test_keypair() -> (SigningKey, String) {
180 let secret: [u8; 32] = [
181 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,
182 27, 28, 29, 30, 31, 32,
183 ];
184 let signing_key = SigningKey::from_bytes(&secret);
185 let public_key = signing_key.verifying_key();
186 let public_key_b58 = bs58_encode(public_key.as_bytes()).into_string();
187 (signing_key, public_key_b58)
188 }
189
190 #[test]
191 fn test_create_stores_public_key() {
192 let provider = test_provider();
193 let (_, public_key_b58) = test_keypair();
194 let config = HashMap::from([("public_key".to_string(), public_key_b58.clone())]);
195
196 let stored = provider.create(&Rng::default(), &config).unwrap();
197 assert_eq!(stored.get("public_key").unwrap(), &public_key_b58);
198 }
199
200 #[test]
201 fn test_create_requires_public_key() {
202 let provider = test_provider();
203 assert!(provider.create(&Rng::default(), &HashMap::new()).is_err());
204 }
205
206 #[test]
207 fn test_create_rejects_invalid_public_key() {
208 let provider = test_provider();
209 let config = HashMap::from([("public_key".to_string(), "not-valid-base58!!!".to_string())]);
210 assert!(provider.create(&Rng::default(), &config).is_err());
211 }
212
213 #[test]
214 fn test_create_rejects_wrong_length_key() {
215 let provider = test_provider();
216 let short_key = bs58_encode(&[0u8; 16]).into_string();
218 let config = HashMap::from([("public_key".to_string(), short_key)]);
219 assert!(provider.create(&Rng::default(), &config).is_err());
220 }
221
222 #[test]
223 fn test_challenge_response_flow() {
224 let provider = test_provider();
225 let (signing_key, public_key_b58) = test_keypair();
226 let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
227
228 let step1 = provider.authenticate(&stored, &HashMap::new()).unwrap();
229 let challenge_data = match step1 {
230 AuthStep::Challenge {
231 payload,
232 } => payload,
233 other => panic!("expected Challenge, got {:?}", other),
234 };
235
236 assert!(challenge_data.contains_key("message"));
237 assert!(challenge_data.contains_key("nonce"));
238
239 let message = challenge_data.get("message").unwrap();
240 assert!(message.contains("wants you to sign in with your Solana account"));
241 assert!(message.contains("Nonce:"));
242 assert!(message.contains("Issued At: 1700000000"));
243
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 let wrong_key = SigningKey::from_bytes(&[99u8; 32]);
263 let signature = wrong_key.sign(b"some message");
264 let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
265
266 let credentials = HashMap::from([
267 ("signature".to_string(), signature_b58),
268 ("signed_message".to_string(), "some message".to_string()),
269 ]);
270
271 let step = provider.authenticate(&stored, &credentials).unwrap();
272 assert_eq!(step, AuthStep::Failed);
273 }
274}