Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions service/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
58 changes: 32 additions & 26 deletions service/src/identity/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,50 +50,47 @@ async fn signup(
Extension(repo): Extension<Arc<dyn AccountRepo>>,
Json(req): Json<SignupRequest>,
) -> 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
Expand Down
1 change: 1 addition & 0 deletions service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ pub mod config;
pub mod db;
pub mod graphql;
pub mod identity;
pub mod validation;
70 changes: 70 additions & 0 deletions service/src/validation.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading