use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
pub struct BotAuthConfig {
signing_key: SigningKey,
agent_fqdn: Option<String>,
validity_secs: u64,
}
impl BotAuthConfig {
pub fn from_seed(seed: [u8; 32]) -> Self {
Self {
signing_key: SigningKey::from_bytes(&seed),
agent_fqdn: None,
validity_secs: 300,
}
}
pub fn from_base64_seed(encoded: &str) -> Result<Self, BotAuthError> {
let bytes = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| BotAuthError::InvalidKey("invalid base64url encoding"))?;
let seed: [u8; 32] = bytes
.try_into()
.map_err(|_| BotAuthError::InvalidKey("seed must be exactly 32 bytes"))?;
Ok(Self::from_seed(seed))
}
pub fn with_agent_fqdn(mut self, fqdn: impl Into<String>) -> Self {
self.agent_fqdn = Some(fqdn.into());
self
}
pub fn with_validity_secs(mut self, secs: u64) -> Self {
self.validity_secs = secs;
self
}
pub fn keyid(&self) -> String {
jwk_thumbprint_ed25519(&self.signing_key.verifying_key())
}
pub(crate) fn sign_request(&self, authority: &str) -> Result<BotAuthHeaders, BotAuthError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| BotAuthError::Clock)?
.as_secs();
let expires = now + self.validity_secs;
let keyid = self.keyid();
let nonce = generate_nonce();
let mut covered = String::from("\"@authority\"");
if self.agent_fqdn.is_some() {
covered.push_str(" \"signature-agent\"");
}
let sig_params = format!(
"({covered});created={now};expires={expires};\
keyid=\"{keyid}\";alg=\"ed25519\";nonce=\"{nonce}\";\
tag=\"web-bot-auth\""
);
let mut sig_base = format!("\"@authority\": {authority}\n");
if let Some(ref fqdn) = self.agent_fqdn {
sig_base.push_str(&format!("\"signature-agent\": {fqdn}\n"));
}
sig_base.push_str(&format!("\"@signature-params\": {sig_params}"));
let signature = self.signing_key.sign(sig_base.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(BotAuthHeaders {
signature: format!("sig=:{sig_b64}:"),
signature_input: format!("sig={sig_params}"),
signature_agent: self.agent_fqdn.clone(),
})
}
}
#[derive(Debug)]
pub(crate) struct BotAuthHeaders {
pub signature: String,
pub signature_input: String,
pub signature_agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BotAuthError {
InvalidKey(&'static str),
Clock,
}
impl std::fmt::Display for BotAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BotAuthError::InvalidKey(msg) => write!(f, "invalid bot-auth key: {msg}"),
BotAuthError::Clock => write!(f, "system clock error"),
}
}
}
impl std::error::Error for BotAuthError {}
pub struct BotAuthPublicKey {
pub key_id: String,
pub jwk: serde_json::Value,
}
pub fn derive_bot_auth_public_key(seed: &str) -> Result<BotAuthPublicKey, BotAuthError> {
let config = BotAuthConfig::from_base64_seed(seed)?;
let verifying_key = config.signing_key.verifying_key();
let x = URL_SAFE_NO_PAD.encode(verifying_key.as_bytes());
let key_id = jwk_thumbprint_ed25519(&verifying_key);
let jwk = serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"x": x,
});
Ok(BotAuthPublicKey { key_id, jwk })
}
fn jwk_thumbprint_ed25519(key: &VerifyingKey) -> String {
let x = URL_SAFE_NO_PAD.encode(key.as_bytes());
let jwk_json = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#);
let hash = Sha256::digest(jwk_json.as_bytes());
URL_SAFE_NO_PAD.encode(hash)
}
fn generate_nonce() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::Verifier;
#[test]
fn from_seed_roundtrip() {
let seed = [1u8; 32];
let config = BotAuthConfig::from_seed(seed);
let keyid = config.keyid();
assert!(!keyid.is_empty());
}
#[test]
fn from_base64_seed() {
let seed = [2u8; 32];
let encoded = URL_SAFE_NO_PAD.encode(seed);
let config = BotAuthConfig::from_base64_seed(&encoded).unwrap();
assert_eq!(config.keyid(), BotAuthConfig::from_seed(seed).keyid());
}
#[test]
fn from_base64_seed_invalid() {
assert!(BotAuthConfig::from_base64_seed("!!!invalid!!!").is_err());
let short = URL_SAFE_NO_PAD.encode([0u8; 16]);
assert!(BotAuthConfig::from_base64_seed(&short).is_err());
}
#[test]
fn sign_request_produces_valid_headers() {
let config = BotAuthConfig::from_seed([3u8; 32]);
let headers = config.sign_request("example.com").unwrap();
assert!(headers.signature.starts_with("sig=:"));
assert!(headers.signature.ends_with(':'));
assert!(headers.signature_input.starts_with("sig=("));
assert!(headers.signature_input.contains("tag=\"web-bot-auth\""));
assert!(headers.signature_input.contains("alg=\"ed25519\""));
assert!(headers.signature_input.contains("keyid="));
assert!(headers.signature_input.contains("nonce="));
assert!(headers.signature_agent.is_none());
}
#[test]
fn sign_request_with_agent_fqdn() {
let config = BotAuthConfig::from_seed([4u8; 32]).with_agent_fqdn("bot.example.com");
let headers = config.sign_request("example.com").unwrap();
assert_eq!(headers.signature_agent.as_deref(), Some("bot.example.com"));
assert!(headers.signature_input.contains("\"signature-agent\""));
}
#[test]
fn signature_is_verifiable() {
let seed = [5u8; 32];
let config = BotAuthConfig::from_seed(seed);
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let headers = config.sign_request("verify.example.com").unwrap();
let sig_params = headers.signature_input.strip_prefix("sig=").unwrap();
let sig_base =
format!("\"@authority\": verify.example.com\n\"@signature-params\": {sig_params}");
let sig_b64 = headers
.signature
.strip_prefix("sig=:")
.unwrap()
.strip_suffix(':')
.unwrap();
let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).unwrap();
let signature = ed25519_dalek::Signature::from_slice(&sig_bytes).unwrap();
assert!(
verifying_key
.verify(sig_base.as_bytes(), &signature)
.is_ok()
);
}
#[test]
fn jwk_thumbprint_deterministic() {
let key = SigningKey::from_bytes(&[6u8; 32]).verifying_key();
let t1 = jwk_thumbprint_ed25519(&key);
let t2 = jwk_thumbprint_ed25519(&key);
assert_eq!(t1, t2);
assert!(!t1.is_empty());
}
#[test]
fn validity_secs_respected() {
let config = BotAuthConfig::from_seed([7u8; 32]).with_validity_secs(600);
let headers = config.sign_request("example.com").unwrap();
let input = &headers.signature_input;
let created: u64 = input
.split("created=")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap()
.parse()
.unwrap();
let expires: u64 = input
.split("expires=")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap()
.parse()
.unwrap();
assert_eq!(expires - created, 600);
}
#[test]
fn derive_public_key() {
let seed = [8u8; 32];
let encoded = URL_SAFE_NO_PAD.encode(seed);
let pubkey = derive_bot_auth_public_key(&encoded).unwrap();
assert!(!pubkey.key_id.is_empty());
assert_eq!(pubkey.jwk["kty"], "OKP");
assert_eq!(pubkey.jwk["crv"], "Ed25519");
assert!(pubkey.jwk["x"].is_string());
}
#[test]
fn derive_public_key_matches_config_keyid() {
let seed = [9u8; 32];
let encoded = URL_SAFE_NO_PAD.encode(seed);
let pubkey = derive_bot_auth_public_key(&encoded).unwrap();
let config = BotAuthConfig::from_seed(seed);
assert_eq!(pubkey.key_id, config.keyid());
}
}