Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 2s
CI Pipeline / Clippy Lints (push) Failing after 1s
CI Pipeline / Rust Unit Tests (push) Failing after 2s
CI Pipeline / Security Audit (push) Failing after 2s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 3s
CI Pipeline / Build .deb & Release (push) Has been skipped
- health_poller: persist agent_version from HealthData.version - health_poller: call /system/info to update os_family, os_name, arch - enrollment: set os_family and arch from os_details during approval - enrollment: build os_name from os+os_version when name field absent - COALESCE in UPDATE preserves existing values when new data unavailable - version bump 0.1.7 -> 0.1.8
153 lines
5.0 KiB
Rust
Executable File
153 lines
5.0 KiB
Rust
Executable File
//! JWT issuance and validation using EdDSA / Ed25519.
|
|
//!
|
|
//! - Access tokens: 15-minute TTL, signed with Ed25519 private key
|
|
//! - Key rotation: 90-day cycle with 24-hour overlap window
|
|
//! - The web process holds the signing key; worker holds only the public key
|
|
|
|
use chrono::{Duration, Utc};
|
|
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
/// JWT algorithm — EdDSA with Ed25519 curve.
|
|
const JWT_ALGORITHM: Algorithm = Algorithm::EdDSA;
|
|
|
|
/// Default access token TTL in seconds.
|
|
pub const DEFAULT_ACCESS_TTL_SECS: i64 = 900; // 15 minutes
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum JwtError {
|
|
#[error("Failed to encode JWT: {0}")]
|
|
Encode(String),
|
|
#[error("Failed to decode JWT: {0}")]
|
|
Decode(String),
|
|
#[error("Token is expired")]
|
|
Expired,
|
|
#[error("Token has invalid claims")]
|
|
InvalidClaims,
|
|
#[error("Failed to load signing key: {0}")]
|
|
KeyLoad(String),
|
|
}
|
|
|
|
/// Standard JWT claims for access tokens.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AccessClaims {
|
|
/// Subject: user ID (UUID)
|
|
pub sub: String,
|
|
/// Issued at (Unix timestamp)
|
|
pub iat: i64,
|
|
/// Expiry (Unix timestamp)
|
|
pub exp: i64,
|
|
/// JWT ID (unique per token)
|
|
pub jti: String,
|
|
/// User role: "admin" or "operator"
|
|
pub role: String,
|
|
/// Username (for display / logging)
|
|
pub username: String,
|
|
}
|
|
|
|
impl AccessClaims {
|
|
/// Create new claims for the given user.
|
|
pub fn new(user_id: Uuid, username: &str, role: &str, ttl_secs: i64) -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
sub: user_id.to_string(),
|
|
iat: now.timestamp(),
|
|
exp: (now + Duration::seconds(ttl_secs)).timestamp(),
|
|
jti: Uuid::new_v4().to_string(),
|
|
role: role.to_string(),
|
|
username: username.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Check if the token is expired (redundant with validation but useful for explicit checks).
|
|
pub fn is_expired(&self) -> bool {
|
|
Utc::now().timestamp() > self.exp
|
|
}
|
|
|
|
/// Return the user UUID parsed from the `sub` field.
|
|
pub fn user_id(&self) -> Result<Uuid, JwtError> {
|
|
Uuid::parse_str(&self.sub).map_err(|_| JwtError::InvalidClaims)
|
|
}
|
|
}
|
|
|
|
/// Issue an access token signed with the Ed25519 private key PEM.
|
|
pub fn issue_access_token(
|
|
user_id: Uuid,
|
|
username: &str,
|
|
role: &str,
|
|
ttl_secs: i64,
|
|
signing_key_pem: &str,
|
|
) -> Result<String, JwtError> {
|
|
let claims = AccessClaims::new(user_id, username, role, ttl_secs);
|
|
|
|
let key = EncodingKey::from_ed_pem(signing_key_pem.as_bytes())
|
|
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
|
|
|
|
let header = Header::new(JWT_ALGORITHM);
|
|
|
|
encode(&header, &claims, &key).map_err(|e| JwtError::Encode(e.to_string()))
|
|
}
|
|
|
|
/// Validate and decode an access token using the Ed25519 public key PEM.
|
|
pub fn validate_access_token(token: &str, verify_key_pem: &str) -> Result<AccessClaims, JwtError> {
|
|
let key = DecodingKey::from_ed_pem(verify_key_pem.as_bytes())
|
|
.map_err(|e| JwtError::KeyLoad(e.to_string()))?;
|
|
|
|
let mut validation = Validation::new(JWT_ALGORITHM);
|
|
validation.validate_exp = true;
|
|
validation.leeway = 5; // 5-second clock skew tolerance
|
|
|
|
decode::<AccessClaims>(token, &key, &validation)
|
|
.map(|data| data.claims)
|
|
.map_err(|e| {
|
|
if e.kind() == &jsonwebtoken::errors::ErrorKind::ExpiredSignature {
|
|
JwtError::Expired
|
|
} else {
|
|
JwtError::Decode(e.to_string())
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Load the Ed25519 signing key from a PEM file path.
|
|
pub fn load_signing_key(path: &str) -> Result<String, JwtError> {
|
|
std::fs::read_to_string(path).map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
|
|
}
|
|
|
|
/// Load the Ed25519 verification (public) key from a PEM file path.
|
|
pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
|
std::fs::read_to_string(path).map_err(|e| JwtError::KeyLoad(format!("Cannot read {path}: {e}")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(dead_code)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Test keys generated with:
|
|
// openssl genpkey -algorithm ed25519 -out signing.pem
|
|
// openssl pkey -in signing.pem -pubout -out verify.pem
|
|
const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY-----
|
|
MC4CAQAwBQYDK2VwBCIEIHNzPc3LkpODUVFr8GjVPm4M2yiKrXsZ/1uJQ/tQMjNb
|
|
-----END PRIVATE KEY-----
|
|
";
|
|
|
|
const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEA8nRzpCYzZ1xFKNJDGt9wuXdq7kKS/ck9PfLJu/r3VEw=
|
|
-----END PUBLIC KEY-----
|
|
";
|
|
|
|
// Note: real tests require valid key pairs; these are placeholders.
|
|
// Integration tests in the test suite use generated keys.
|
|
#[test]
|
|
fn claims_construction() {
|
|
let user_id = Uuid::new_v4();
|
|
let claims = AccessClaims::new(user_id, "admin", "admin", 900);
|
|
assert_eq!(claims.sub, user_id.to_string());
|
|
assert_eq!(claims.role, "admin");
|
|
assert!(!claims.is_expired());
|
|
assert_eq!(claims.user_id().unwrap(), user_id);
|
|
}
|
|
}
|