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 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); // fixed timestamp
176		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		// 16 bytes decodes fine as base58; only the length check can reject it.
217		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}