From b311fa9bce9405b99a1ede8b92a6cb0ea680a957 Mon Sep 17 00:00:00 2001 From: Isaac Cook Date: Wed, 17 Dec 2025 13:58:36 -0600 Subject: [PATCH] Add validator crate for HTTP request validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the `validator` crate for declarative request validation: - Add validation module with custom Ed25519 pubkey validator - Refactor SignupRequest to use derive macros - Replace manual validation with #[validate] attributes Example usage: ```rust #[derive(Validate)] struct SignupRequest { #[validate(length(min = 1, max = 64))] username: String, #[validate(custom(function = "validate_base64url_ed25519_pubkey"))] root_pubkey: String, } ``` Closes #153 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- service/Cargo.lock | 53 ++++++++++++++++++++++++ service/Cargo.toml | 3 +- service/src/identity/http/mod.rs | 58 ++++++++++++++------------ service/src/lib.rs | 1 + service/src/validation.rs | 70 ++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 service/src/validation.rs diff --git a/service/Cargo.lock b/service/Cargo.lock index f9c18bad..e6230fda 100644 --- a/service/Cargo.lock +++ b/service/Cargo.lock @@ -1713,6 +1713,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -2761,6 +2783,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "validator", ] [[package]] @@ -3128,6 +3151,36 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling 0.20.11", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/service/Cargo.toml b/service/Cargo.toml index ca32bfad..9b5a5de6 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -34,6 +34,7 @@ async-trait = "0.1" thiserror = "2.0" anyhow = "1.0" serde-aux = "4.7.0" +validator = { version = "0.20", features = ["derive"] } # Cryptography sha2 = "0.10" @@ -52,7 +53,7 @@ path = "src/bin/export_schema.rs" # cargo-machete false positives (used via derive macros) [package.metadata.cargo-machete] -ignored = ["serde", "thiserror", "async-trait"] +ignored = ["serde", "thiserror", "async-trait", "validator"] [lints] [lints.clippy] diff --git a/service/src/identity/http/mod.rs b/service/src/identity/http/mod.rs index 0ed5ed85..12213a3f 100644 --- a/service/src/identity/http/mod.rs +++ b/service/src/identity/http/mod.rs @@ -5,17 +5,26 @@ use std::sync::Arc; use axum::{ extract::Extension, http::StatusCode, response::IntoResponse, routing::post, Json, Router, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use validator::Validate; -use super::crypto::{decode_base64url, derive_kid}; +use super::crypto::derive_kid; use super::repo::{AccountRepo, AccountRepoError}; +use crate::validation::validate_base64url_ed25519_pubkey; /// Signup request payload -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Validate)] pub struct SignupRequest { + #[validate(length(min = 1, max = 64, message = "Username must be 1-64 characters"))] pub username: String, - pub root_pubkey: String, // base64url encoded + + #[validate(custom( + function = "validate_base64url_ed25519_pubkey", + message = "Invalid public key: must be base64url-encoded 32-byte Ed25519 key" + ))] + pub root_pubkey: String, } /// Signup response @@ -41,50 +50,47 @@ async fn signup( Extension(repo): Extension>, Json(req): Json, ) -> impl IntoResponse { - // Validate username - let username = req.username.trim(); - if username.is_empty() { + // Validate request using validator crate + if let Err(errors) = req.validate() { + // Extract first validation error message + let message = errors + .field_errors() + .values() + .flat_map(|v| v.iter()) + .find_map(|e| e.message.as_ref()) + .map_or_else(|| "Validation failed".to_string(), ToString::to_string); + return ( StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Username cannot be empty".to_string(), - }), + Json(ErrorResponse { error: message }), ) .into_response(); } - if username.len() > 64 { + // Trim username (validation ensures it's not empty after trim would be nice, + // but we can handle that by checking the trimmed value) + let username = req.username.trim(); + if username.is_empty() { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { - error: "Username too long".to_string(), + error: "Username cannot be empty".to_string(), }), ) .into_response(); } - // Decode and validate public key - let Ok(pubkey_bytes) = decode_base64url(&req.root_pubkey) else { + // Derive KID from public key (already validated by validator) + let Ok(pubkey_bytes) = URL_SAFE_NO_PAD.decode(&req.root_pubkey) else { + // This should never happen since validation already passed return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { - error: "Invalid base64url encoding for root_pubkey".to_string(), + error: "Invalid public key encoding".to_string(), }), ) .into_response(); }; - - if pubkey_bytes.len() != 32 { - return ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "root_pubkey must be 32 bytes (Ed25519)".to_string(), - }), - ) - .into_response(); - } - - // Derive KID from public key let root_kid = derive_kid(&pubkey_bytes); // Create account via repository diff --git a/service/src/lib.rs b/service/src/lib.rs index fcb742fb..59be9dbf 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -12,3 +12,4 @@ pub mod config; pub mod db; pub mod graphql; pub mod identity; +pub mod validation; diff --git a/service/src/validation.rs b/service/src/validation.rs new file mode 100644 index 00000000..f7c80f64 --- /dev/null +++ b/service/src/validation.rs @@ -0,0 +1,70 @@ +//! Request validation utilities using the validator crate. +//! +//! This module provides custom validators and helpers for validating +//! HTTP request payloads. +//! +//! # Usage +//! +//! ```ignore +//! use validator::Validate; +//! use crate::validation::validate_base64url_ed25519_pubkey; +//! +//! #[derive(Validate)] +//! struct SignupRequest { +//! #[validate(length(min = 1, max = 64))] +//! username: String, +//! +//! #[validate(custom(function = "validate_base64url_ed25519_pubkey"))] +//! root_pubkey: String, +//! } +//! ``` + +use validator::ValidationError; + +/// Validates that a string is a valid base64url-encoded Ed25519 public key (32 bytes). +/// +/// # Errors +/// +/// Returns a `ValidationError` if: +/// - The string is not valid base64url encoding +/// - The decoded bytes are not exactly 32 bytes (Ed25519 key size) +pub fn validate_base64url_ed25519_pubkey(value: &str) -> Result<(), ValidationError> { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| ValidationError::new("invalid_base64url"))?; + + if bytes.len() != 32 { + return Err(ValidationError::new("invalid_pubkey_length")); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_pubkey() { + // 32 bytes encoded as base64url (no padding) + let valid = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + assert!(validate_base64url_ed25519_pubkey(valid).is_ok()); + } + + #[test] + fn test_invalid_base64() { + let invalid = "not-valid-base64!!!"; + let err = validate_base64url_ed25519_pubkey(invalid).unwrap_err(); + assert_eq!(err.code.as_ref(), "invalid_base64url"); + } + + #[test] + fn test_wrong_length() { + // 16 bytes instead of 32 + let short = "AAAAAAAAAAAAAAAAAAAAAA"; + let err = validate_base64url_ed25519_pubkey(short).unwrap_err(); + assert_eq!(err.code.as_ref(), "invalid_pubkey_length"); + } +}