reifydb-auth 0.9.1

Authentication and authorization module for ReifyDB
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 ReifyDB

use std::collections::HashMap;

use bs58::decode as bs58_decode;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use reifydb_core::interface::auth::{AuthStep, AuthenticationProvider};
use reifydb_runtime::context::{clock::Clock, rng::Rng};
use reifydb_value::{Result, error::Error, reifydb_assertions};

use crate::error::SolanaError;

pub const SIGNED_MESSAGE_MISMATCH: &str = "signed message does not match the issued challenge";

pub struct SolanaProvider {
	clock: Clock,
	rng: Rng,
}

impl SolanaProvider {
	pub fn new(clock: Clock, rng: Rng) -> Self {
		Self {
			clock,
			rng,
		}
	}
}

impl AuthenticationProvider for SolanaProvider {
	fn method(&self) -> &str {
		"solana"
	}

	fn create(&self, _rng: &Rng, config: &HashMap<String, String>) -> Result<HashMap<String, String>> {
		let public_key = config.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;

		let bytes = bs58_decode(public_key).into_vec().map_err(|e| {
			Error::from(SolanaError::InvalidPublicKey {
				reason: e.to_string(),
			})
		})?;

		if bytes.len() != 32 {
			return Err(Error::from(SolanaError::InvalidPublicKey {
				reason: format!("expected 32 bytes, got {}", bytes.len()),
			}));
		}

		Ok(HashMap::from([("public_key".into(), public_key.clone())]))
	}

	fn authenticate(
		&self,
		stored: &HashMap<String, String>,
		credentials: &HashMap<String, String>,
	) -> Result<AuthStep> {
		let public_key_b58 =
			stored.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;

		Ok(self.issue_signin_challenge(public_key_b58, credentials))
	}

	fn verify_challenge(
		&self,
		stored: &HashMap<String, String>,
		challenge: &HashMap<String, String>,
		credentials: &HashMap<String, String>,
	) -> Result<AuthStep> {
		let public_key_b58 =
			stored.get("public_key").ok_or_else(|| Error::from(SolanaError::MissingPublicKey))?;
		let message =
			challenge.get("message").ok_or_else(|| Error::from(SolanaError::MissingChallengeMessage))?;

		let Some(signature_b58) = credentials.get("signature") else {
			return Ok(AuthStep::Failed);
		};

		if let Some(echoed) = credentials.get("signed_message")
			&& echoed != message
		{
			return Ok(AuthStep::Rejected {
				reason: SIGNED_MESSAGE_MISMATCH.to_string(),
			});
		}

		self.verify_signature(public_key_b58, signature_b58, message)
	}
}

impl SolanaProvider {
	#[inline]
	fn verify_signature(&self, public_key_b58: &str, signature_b58: &str, message: &str) -> Result<AuthStep> {
		let pk_bytes: [u8; 32] = bs58_decode(public_key_b58)
			.into_vec()
			.map_err(|e| {
				Error::from(SolanaError::InvalidPublicKey {
					reason: e.to_string(),
				})
			})?
			.try_into()
			.map_err(|_| {
				Error::from(SolanaError::InvalidPublicKey {
					reason: "expected 32 bytes".to_string(),
				})
			})?;

		let verifying_key = VerifyingKey::from_bytes(&pk_bytes).map_err(|e| {
			Error::from(SolanaError::InvalidPublicKey {
				reason: e.to_string(),
			})
		})?;

		let sig_bytes: [u8; 64] = bs58_decode(signature_b58)
			.into_vec()
			.map_err(|e| {
				Error::from(SolanaError::InvalidSignature {
					reason: e.to_string(),
				})
			})?
			.try_into()
			.map_err(|_| {
				Error::from(SolanaError::InvalidSignature {
					reason: "expected 64 bytes".to_string(),
				})
			})?;

		let signature = Signature::from_bytes(&sig_bytes);

		match verifying_key.verify(message.as_bytes(), &signature) {
			Ok(()) => Ok(AuthStep::Authenticated),
			Err(_) => Ok(AuthStep::Failed),
		}
	}

	#[inline]
	fn issue_signin_challenge(&self, public_key_b58: &str, credentials: &HashMap<String, String>) -> AuthStep {
		let nonce_bytes = self.rng.bytes_32();
		let nonce: String = nonce_bytes.iter().map(|b| format!("{:02x}", b)).collect();

		reifydb_assertions! {
			assert!(
				nonce.len() == 64,
				"sign-in nonce must be 64 hex chars (32 bytes of entropy); a shorter nonce weakens \
				 challenge-replay resistance because an attacker can brute-force or precompute it \
				 (got {} chars)",
				nonce.len()
			);
		}

		let domain = credentials.get("domain").cloned().unwrap_or_else(|| "reifydb".to_string());
		let statement =
			credentials.get("statement").cloned().unwrap_or_else(|| "Sign in to ReifyDB".to_string());

		let issued_at = credentials
			.get("issued_at")
			.cloned()
			.unwrap_or_else(|| (self.clock.now().to_secs()).to_string());

		let message = format!(
			"{domain} wants you to sign in with your Solana account:\n\
			 {address}\n\
			 \n\
			 {statement}\n\
			 \n\
			 Nonce: {nonce}\n\
			 Issued At: {issued_at}",
			domain = domain,
			address = public_key_b58,
			statement = statement,
			nonce = nonce,
			issued_at = issued_at,
		);

		AuthStep::Challenge {
			payload: HashMap::from([("message".into(), message), ("nonce".into(), nonce)]),
		}
	}
}

#[cfg(test)]
mod tests {
	use bs58::encode as bs58_encode;
	use ed25519_dalek::{Signer, SigningKey};
	use reifydb_runtime::context::clock::MockClock;

	use super::*;

	fn test_provider() -> SolanaProvider {
		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
		SolanaProvider::new(Clock::Mock(mock), Rng::default())
	}

	fn seeded_provider(seed: u64) -> SolanaProvider {
		let mock = MockClock::from_millis(1_700_000_000_000); // fixed timestamp
		SolanaProvider::new(Clock::Mock(mock), Rng::seeded(seed))
	}

	fn issued_nonce(provider: &SolanaProvider) -> String {
		let (_, public_key_b58) = test_keypair();
		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);

		match provider.authenticate(&stored, &HashMap::new()).unwrap() {
			AuthStep::Challenge {
				payload,
			} => payload.get("nonce").expect("challenge must carry a nonce").clone(),
			other => panic!("expected Challenge, got {:?}", other),
		}
	}

	fn test_keypair() -> (SigningKey, String) {
		let secret: [u8; 32] = [
			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,
			27, 28, 29, 30, 31, 32,
		];
		let signing_key = SigningKey::from_bytes(&secret);
		let public_key = signing_key.verifying_key();
		let public_key_b58 = bs58_encode(public_key.as_bytes()).into_string();
		(signing_key, public_key_b58)
	}

	#[test]
	fn test_create_stores_public_key() {
		let provider = test_provider();
		let (_, public_key_b58) = test_keypair();
		let config = HashMap::from([("public_key".to_string(), public_key_b58.clone())]);

		let stored = provider.create(&Rng::default(), &config).unwrap();
		assert_eq!(stored.get("public_key").unwrap(), &public_key_b58);
	}

	#[test]
	fn test_create_requires_public_key() {
		let provider = test_provider();
		assert!(provider.create(&Rng::default(), &HashMap::new()).is_err());
	}

	#[test]
	fn test_create_rejects_invalid_public_key() {
		let provider = test_provider();
		let config = HashMap::from([("public_key".to_string(), "not-valid-base58!!!".to_string())]);
		assert!(provider.create(&Rng::default(), &config).is_err());
	}

	#[test]
	fn test_create_rejects_wrong_length_key() {
		let provider = test_provider();
		// 16 bytes decodes fine as base58; only the length check can reject it.
		let short_key = bs58_encode(&[0u8; 16]).into_string();
		let config = HashMap::from([("public_key".to_string(), short_key)]);
		assert!(provider.create(&Rng::default(), &config).is_err());
	}

	#[test]
	fn test_challenge_response_flow() {
		let provider = test_provider();
		let (signing_key, public_key_b58) = test_keypair();
		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);

		let step1 = provider.authenticate(&stored, &HashMap::new()).unwrap();
		let challenge_data = match step1 {
			AuthStep::Challenge {
				payload,
			} => payload,
			other => panic!("expected Challenge, got {:?}", other),
		};

		assert!(challenge_data.contains_key("message"));
		assert!(challenge_data.contains_key("nonce"));

		let message = challenge_data.get("message").unwrap();
		assert!(message.contains("wants you to sign in with your Solana account"));
		assert!(message.contains("Nonce:"));
		assert!(message.contains("Issued At: 1700000000"));

		let signature = signing_key.sign(message.as_bytes());
		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();

		let credentials = HashMap::from([
			("signature".to_string(), signature_b58),
			("signed_message".to_string(), message.clone()),
		]);

		let step2 = provider.verify_challenge(&stored, &challenge_data, &credentials).unwrap();
		assert_eq!(step2, AuthStep::Authenticated);
	}

	#[test]
	fn test_invalid_signature_fails() {
		let provider = test_provider();
		let (_, public_key_b58) = test_keypair();
		let stored = HashMap::from([("public_key".to_string(), public_key_b58)]);

		let wrong_key = SigningKey::from_bytes(&[99u8; 32]);
		let signature = wrong_key.sign(b"some message");
		let signature_b58 = bs58_encode(signature.to_bytes()).into_string();

		let challenge = HashMap::from([("message".to_string(), "some message".to_string())]);
		let credentials = HashMap::from([
			("signature".to_string(), signature_b58),
			("signed_message".to_string(), "some message".to_string()),
		]);

		let step = provider.verify_challenge(&stored, &challenge, &credentials).unwrap();
		assert_eq!(step, AuthStep::Failed);
	}

	#[test]
	fn test_nonce_is_reproducible_from_the_injected_seed() {
		// The nonce must come from the injected Rng, never Rng::Os, or a seeded replay cannot reproduce it.
		let first = issued_nonce(&seeded_provider(42));
		let second = issued_nonce(&seeded_provider(42));

		assert_eq!(first, second, "same seed must yield the same nonce; the provider is bypassing its Rng");
	}

	#[test]
	fn test_nonce_differs_across_seeds() {
		// Guards the reproducibility test: a hardcoded constant nonce would satisfy it but fail here.
		let first = issued_nonce(&seeded_provider(42));
		let second = issued_nonce(&seeded_provider(43));

		assert_ne!(first, second, "distinct seeds must yield distinct nonces; entropy collapsed to a constant");
	}

	#[test]
	fn test_nonce_keeps_full_entropy_under_a_seed() {
		// Pins 32 bytes rendered as 64 hex chars so routing through the injected Rng cannot shrink entropy.
		let nonce = issued_nonce(&seeded_provider(42));

		assert_eq!(nonce.len(), 64, "nonce must stay 32 bytes of entropy rendered as 64 hex chars");
		assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()), "nonce must be hex; got {nonce}");
	}
}