Skip to main content

reifydb_auth/method/
solana.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 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_type::{Result, error::Error};
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		// Validate that the public key is valid base58 and decodes to 32 bytes
35		let bytes = bs58_decode(public_key).into_vec().map_err(|e| {
36			Error::from(AuthError::InvalidPublicKey {
37				reason: e.to_string(),
38			})
39		})?;
40
41		if bytes.len() != 32 {
42			return Err(Error::from(AuthError::InvalidPublicKey {
43				reason: format!("expected 32 bytes, got {}", bytes.len()),
44			}));
45		}
46
47		Ok(HashMap::from([("public_key".into(), public_key.clone())]))
48	}
49
50	fn authenticate(
51		&self,
52		stored: &HashMap<String, String>,
53		credentials: &HashMap<String, String>,
54	) -> Result<AuthStep> {
55		let public_key_b58 =
56			stored.get("public_key").ok_or_else(|| Error::from(AuthError::MissingPublicKey))?;
57
58		// Step 2: Verify signature (credentials contain "signature" + "signed_message" merged from challenge)
59		if let Some(signature_b58) = credentials.get("signature") {
60			let signed_message = credentials.get("signed_message").ok_or_else(|| {
61				Error::from(AuthError::InvalidSignature {
62					reason: "missing signed_message".to_string(),
63				})
64			})?;
65
66			// Decode public key
67			let pk_bytes: [u8; 32] = bs58_decode(public_key_b58)
68				.into_vec()
69				.map_err(|e| {
70					Error::from(AuthError::InvalidPublicKey {
71						reason: e.to_string(),
72					})
73				})?
74				.try_into()
75				.map_err(|_| {
76					Error::from(AuthError::InvalidPublicKey {
77						reason: "expected 32 bytes".to_string(),
78					})
79				})?;
80
81			let verifying_key = VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
82				Error::from(AuthError::InvalidPublicKey {
83					reason: e.to_string(),
84				})
85			})?;
86
87			// Decode signature
88			let sig_bytes: [u8; 64] = bs58_decode(signature_b58)
89				.into_vec()
90				.map_err(|e| {
91					Error::from(AuthError::InvalidSignature {
92						reason: e.to_string(),
93					})
94				})?
95				.try_into()
96				.map_err(|_| {
97					Error::from(AuthError::InvalidSignature {
98						reason: "expected 64 bytes".to_string(),
99					})
100				})?;
101
102			let signature = Signature::from_bytes(&sig_bytes);
103
104			// Verify
105			match verifying_key.verify(signed_message.as_bytes(), &signature) {
106				Ok(()) => return Ok(AuthStep::Authenticated),
107				Err(_) => return Ok(AuthStep::Failed),
108			}
109		}
110
111		// Step 1: Generate challenge - build SIWS message with nonce
112		let nonce_bytes = Rng::Os.bytes_32();
113		let nonce: String = nonce_bytes.iter().map(|b| format!("{:02x}", b)).collect();
114
115		// Get optional domain and statement from credentials (caller can provide context)
116		let domain = credentials.get("domain").cloned().unwrap_or_else(|| "reifydb".to_string());
117		let statement =
118			credentials.get("statement").cloned().unwrap_or_else(|| "Sign in to ReifyDB".to_string());
119
120		let issued_at =
121			credentials.get("issued_at").cloned().unwrap_or_else(|| self.clock.now_secs().to_string());
122
123		// Build SIWS-standard message
124		let message = format!(
125			"{domain} wants you to sign in with your Solana account:\n\
126			 {address}\n\
127			 \n\
128			 {statement}\n\
129			 \n\
130			 Nonce: {nonce}\n\
131			 Issued At: {issued_at}",
132			domain = domain,
133			address = public_key_b58,
134			statement = statement,
135			nonce = nonce,
136			issued_at = issued_at,
137		);
138
139		Ok(AuthStep::Challenge {
140			payload: HashMap::from([("message".into(), message), ("nonce".into(), nonce)]),
141		})
142	}
143}
144
145#[cfg(test)]
146mod tests {
147	use bs58::encode as bs58_encode;
148	use ed25519_dalek::{Signer, SigningKey};
149	use reifydb_runtime::context::clock::MockClock;
150
151	use super::*;
152
153	fn test_provider() -> SolanaProvider {
154		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
155		SolanaProvider::new(Clock::Mock(mock))
156	}
157
158	fn test_keypair() -> (SigningKey, String) {
159		let secret: [u8; 32] = [
160			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,
161			27, 28, 29, 30, 31, 32,
162		];
163		let signing_key = SigningKey::from_bytes(&secret);
164		let public_key = signing_key.verifying_key();
165		let public_key_b58 = bs58_encode(public_key.as_bytes()).into_string();
166		(signing_key, public_key_b58)
167	}
168
169	#[test]
170	fn test_create_stores_public_key() {
171		let provider = test_provider();
172		let (_, public_key_b58) = test_keypair();
173		let config = HashMap::from([("public_key".to_string(), public_key_b58.clone())]);
174
175		let stored = provider.create(&Rng::default(), &config).unwrap();
176		assert_eq!(stored.get("public_key").unwrap(), &public_key_b58);
177	}
178
179	#[test]
180	fn test_create_requires_public_key() {
181		let provider = test_provider();
182		assert!(provider.create(&Rng::default(), &HashMap::new()).is_err());
183	}
184
185	#[test]
186	fn test_create_rejects_invalid_public_key() {
187		let provider = test_provider();
188		let config = HashMap::from([("public_key".to_string(), "not-valid-base58!!!".to_string())]);
189		assert!(provider.create(&Rng::default(), &config).is_err());
190	}
191
192	#[test]
193	fn test_create_rejects_wrong_length_key() {
194		let provider = test_provider();
195		// Only 16 bytes
196		let short_key = bs58_encode(&[0u8; 16]).into_string();
197		let config = HashMap::from([("public_key".to_string(), short_key)]);
198		assert!(provider.create(&Rng::default(), &config).is_err());
199	}
200
201	#[test]
202	fn test_challenge_response_flow() {
203		let provider = test_provider();
204		let (signing_key, public_key_b58) = test_keypair();
205		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
206
207		// Step 1: Get challenge
208		let step1 = provider.authenticate(&stored, &HashMap::new()).unwrap();
209		let challenge_data = match step1 {
210			AuthStep::Challenge {
211				payload,
212			} => payload,
213			other => panic!("expected Challenge, got {:?}", other),
214		};
215
216		assert!(challenge_data.contains_key("message"));
217		assert!(challenge_data.contains_key("nonce"));
218
219		let message = challenge_data.get("message").unwrap();
220		assert!(message.contains("wants you to sign in with your Solana account"));
221		assert!(message.contains("Nonce:"));
222		assert!(message.contains("Issued At: 1700000000"));
223
224		// Step 2: Sign the message and verify
225		let signature = signing_key.sign(message.as_bytes());
226		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
227
228		let credentials = HashMap::from([
229			("signature".to_string(), signature_b58),
230			("signed_message".to_string(), message.clone()),
231		]);
232
233		let step2 = provider.authenticate(&stored, &credentials).unwrap();
234		assert_eq!(step2, AuthStep::Authenticated);
235	}
236
237	#[test]
238	fn test_invalid_signature_fails() {
239		let provider = test_provider();
240		let (_, public_key_b58) = test_keypair();
241		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);
242
243		// Use a different key to sign
244		let wrong_key = SigningKey::from_bytes(&[99u8; 32]);
245		let signature = wrong_key.sign(b"some message");
246		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();
247
248		let credentials = HashMap::from([
249			("signature".to_string(), signature_b58),
250			("signed_message".to_string(), "some message".to_string()),
251		]);
252
253		let step = provider.authenticate(&stored, &credentials).unwrap();
254		assert_eq!(step, AuthStep::Failed);
255	}
256}