use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "schnorr-sso")]
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[cfg(feature = "schnorr-sso")]
use dashmap::DashMap;
#[derive(Debug, Error)]
pub enum SchnorrError {
#[error("schnorr SSO backend not implemented")]
Unimplemented,
#[error("invalid signature: {0}")]
InvalidSignature(String),
#[error("challenge: {0}")]
Challenge(String),
#[error("no account for npub: {0}")]
UnknownNpub(String),
#[error("rng: {0}")]
Rng(String),
#[error("parse: {0}")]
Parse(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchnorrChallenge {
pub user_id: String,
pub token: String,
pub created_at: u64,
}
pub type Challenge = SchnorrChallenge;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchnorrAssertion {
pub user_id: String,
pub pubkey: String,
pub verified_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedChallenge {
pub token: String,
pub pubkey: String,
pub signature: String,
}
#[async_trait]
pub trait SchnorrSso: Send + Sync + 'static {
async fn issue_challenge(&self, user_id: &str) -> Result<SchnorrChallenge, SchnorrError>;
async fn verify_response(
&self,
user_id: &str,
pubkey_hex: &str,
signature_hex: &str,
) -> Result<SchnorrAssertion, SchnorrError>;
}
pub trait SchnorrBackend: SchnorrSso {}
impl<T: SchnorrSso> SchnorrBackend for T {}
#[doc(hidden)]
pub struct SchnorrTodo;
#[doc(hidden)]
pub type NullSchnorrBackend = SchnorrTodo;
#[async_trait]
impl SchnorrSso for SchnorrTodo {
async fn issue_challenge(&self, _user_id: &str) -> Result<SchnorrChallenge, SchnorrError> {
Err(SchnorrError::Unimplemented)
}
async fn verify_response(
&self,
_user_id: &str,
_pubkey_hex: &str,
_signature_hex: &str,
) -> Result<SchnorrAssertion, SchnorrError> {
Err(SchnorrError::Unimplemented)
}
}
#[cfg(feature = "schnorr-sso")]
pub struct Nip07SchnorrSso {
challenges: DashMap<String, (SchnorrChallenge, Instant)>,
ttl: Duration,
}
#[cfg(feature = "schnorr-sso")]
impl Default for Nip07SchnorrSso {
fn default() -> Self {
Self::new(Duration::from_secs(5 * 60))
}
}
#[cfg(feature = "schnorr-sso")]
impl Nip07SchnorrSso {
pub fn new(ttl: Duration) -> Self {
Self {
challenges: DashMap::new(),
ttl,
}
}
pub fn canonical_digest(token: &str, user_id: &str, pubkey_hex: &str) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(token.as_bytes());
h.update(user_id.as_bytes());
h.update(pubkey_hex.as_bytes());
h.finalize().into()
}
}
#[cfg(feature = "schnorr-sso")]
#[async_trait]
impl SchnorrSso for Nip07SchnorrSso {
async fn issue_challenge(&self, user_id: &str) -> Result<SchnorrChallenge, SchnorrError> {
use rand::RngCore;
let mut buf = [0u8; 32];
rand::thread_rng()
.try_fill_bytes(&mut buf)
.map_err(|e| SchnorrError::Rng(e.to_string()))?;
let token = hex::encode(buf);
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let challenge = SchnorrChallenge {
user_id: user_id.to_string(),
token,
created_at,
};
self.challenges
.insert(user_id.to_string(), (challenge.clone(), Instant::now()));
Ok(challenge)
}
async fn verify_response(
&self,
user_id: &str,
pubkey_hex: &str,
signature_hex: &str,
) -> Result<SchnorrAssertion, SchnorrError> {
use k256::schnorr::{signature::Verifier, Signature, VerifyingKey};
let (_, (challenge, issued_at)) = self
.challenges
.remove(user_id)
.ok_or_else(|| SchnorrError::Challenge("no active challenge for user".into()))?;
if issued_at.elapsed() > self.ttl {
return Err(SchnorrError::Challenge("expired".into()));
}
let pub_bytes =
hex::decode(pubkey_hex).map_err(|e| SchnorrError::Parse(format!("pubkey: {e}")))?;
if pub_bytes.len() != 32 {
return Err(SchnorrError::Parse(format!(
"pubkey must be 32 bytes, got {}",
pub_bytes.len()
)));
}
let sig_bytes = hex::decode(signature_hex)
.map_err(|e| SchnorrError::Parse(format!("signature: {e}")))?;
if sig_bytes.len() != 64 {
return Err(SchnorrError::Parse(format!(
"signature must be 64 bytes, got {}",
sig_bytes.len()
)));
}
let vk = VerifyingKey::from_bytes(&pub_bytes)
.map_err(|e| SchnorrError::Parse(format!("pubkey parse: {e}")))?;
let sig = Signature::try_from(sig_bytes.as_slice())
.map_err(|e| SchnorrError::Parse(format!("signature parse: {e}")))?;
let digest = Self::canonical_digest(&challenge.token, user_id, pubkey_hex);
vk.verify(&digest, &sig)
.map_err(|e| SchnorrError::InvalidSignature(e.to_string()))?;
let verified_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Ok(SchnorrAssertion {
user_id: user_id.to_string(),
pubkey: pubkey_hex.to_string(),
verified_at,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn todo_backend_is_callable_and_returns_unimplemented() {
let backend = SchnorrTodo;
assert!(matches!(
backend.issue_challenge("alice").await.unwrap_err(),
SchnorrError::Unimplemented
));
assert!(matches!(
backend
.verify_response("alice", "pub", "sig")
.await
.unwrap_err(),
SchnorrError::Unimplemented
));
}
}