diff --git a/src/core/mod.rs b/src/core/mod.rs index 173d73d..7b3f598 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,5 +4,7 @@ //! this layer straightforward to test in isolation. pub mod crypto; +pub mod pow; +pub mod session; -// session, pow, address, and grin_confirmation are wired in as they land. +// address and grin_confirmation are wired in as they land. diff --git a/src/core/pow.rs b/src/core/pow.rs new file mode 100644 index 0000000..1646a88 --- /dev/null +++ b/src/core/pow.rs @@ -0,0 +1,154 @@ +//! Proof-of-work signup gate (ALTCHA v2, PBKDF2/SHA-256). +//! +//! Makes mass account creation *expensive* rather than perfectly Sybil-proof: +//! a legitimate signup pays ~1-2s of client CPU; the server verifies in <2ms. +//! Feature-gated via config (`FEATURE_POW`); the HMAC key is required when +//! enabled (config validation rejects a missing/placeholder key — there is no +//! source-visible fallback). Wire protocol matches the wallet's `altcha-lib`. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use altcha::{ + create_challenge, verify_solution, CreateChallengeOptions, Payload, VerifySolutionOptions, +}; + +use crate::config::PowConfig; +use crate::error::AppError; + +/// Challenge TTL: long enough for a slow phone, short enough to bound an +/// attacker's pre-solve window before the HMAC key rotates. +const CHALLENGE_TTL_SECONDS: u64 = 600; + +/// Whether a registration from this BTC pubkey hash must present a valid PoW +/// solution: when the global gate is on, or this pubkey is individually opted in. +pub fn required_for(cfg: &PowConfig, pubkey_hash: &str) -> bool { + cfg.required || cfg.required_for_pubkeys.iter().any(|p| p == pubkey_hash) +} + +/// Issue a fresh challenge. The HMAC signature embeds an expiry, so the gate is +/// stateless: no issued-challenge store is needed for expiry. +pub fn issue_challenge(cfg: &PowConfig) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let opts = CreateChallengeOptions { + algorithm: "PBKDF2/SHA-256".to_string(), + cost: cfg.cost as u32, + expires_at: Some(now + CHALLENGE_TTL_SECONDS), + hmac_signature_secret: Some(cfg.hmac_key.clone()), + ..Default::default() + }; + create_challenge(opts) + .map_err(|e| AppError::Internal(format!("PoW challenge creation failed: {}", e))) +} + +/// Verify a client-submitted solution. `Ok(())` only on a valid, unexpired, +/// correctly-signed solution. +pub fn verify_payload(cfg: &PowConfig, payload: &Payload) -> Result<(), AppError> { + let opts = + VerifySolutionOptions::new(&payload.challenge, &payload.solution, cfg.hmac_key.as_str()); + let result = verify_solution(opts) + .map_err(|e| AppError::ValidationError(format!("PoW verify failed: {}", e)))?; + if !result.verified { + let reason = if result.expired { + "challenge expired (re-fetch the challenge)" + } else if result.invalid_signature.unwrap_or(false) { + "invalid signature" + } else if result.invalid_solution.unwrap_or(false) { + "wrong solution" + } else { + "rejected" + }; + return Err(AppError::ValidationError(format!( + "PoW solution rejected ({})", + reason + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use altcha::{solve_challenge, SolveChallengeOptions}; + + fn test_cfg() -> PowConfig { + PowConfig { + enabled: true, + hmac_key: "test-hmac-key-32-bytes-of-secret!".to_string(), + required: true, + cost: 100, // tiny so tests stay fast + required_for_pubkeys: vec![], + } + } + + fn solve(cfg: &PowConfig) -> Payload { + let challenge = issue_challenge(cfg).expect("issue"); + let solution = solve_challenge(SolveChallengeOptions::new(&challenge)) + .expect("solve") + .expect("solution found"); + Payload { + challenge, + solution, + } + } + + #[test] + fn issue_solve_verify_roundtrips() { + let cfg = test_cfg(); + verify_payload(&cfg, &solve(&cfg)).expect("valid solution verifies"); + } + + #[test] + fn tampered_signature_rejected() { + let cfg = test_cfg(); + let mut p = solve(&cfg); + p.challenge.signature = Some("ff".repeat(32)); + assert!(verify_payload(&cfg, &p).is_err()); + } + + #[test] + fn wrong_secret_rejected() { + let cfg = test_cfg(); + let p = solve(&cfg); + let attacker = PowConfig { + hmac_key: "a-completely-different-secret-key!".to_string(), + ..test_cfg() + }; + assert!(verify_payload(&attacker, &p).is_err()); + } + + #[test] + fn solution_not_replayable_across_challenges() { + let cfg = test_cfg(); + let a = solve(&cfg); + let b_challenge = issue_challenge(&cfg).expect("issue b"); + let crossed = Payload { + challenge: b_challenge, + solution: a.solution, + }; + assert!(verify_payload(&cfg, &crossed).is_err()); + } + + #[test] + fn missing_signature_rejected() { + let cfg = test_cfg(); + let mut p = solve(&cfg); + p.challenge.signature = None; + assert!(verify_payload(&cfg, &p).is_err()); + } + + #[test] + fn required_for_respects_global_and_per_pubkey() { + let mut cfg = PowConfig { + required: false, + required_for_pubkeys: vec!["abcd1234".to_string()], + ..test_cfg() + }; + assert!(required_for(&cfg, "abcd1234")); + assert!(!required_for(&cfg, "deadbeef")); + cfg.required = true; + assert!(required_for(&cfg, "deadbeef")); + } +} diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..24c1ff6 --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,323 @@ +//! Session and JWT management. +//! +//! Access and refresh tokens are HS256 JWTs. Verification is strict: the +//! algorithm is pinned to HS256 (no `alg` confusion), `exp`/`sub`/`aud` are +//! required, clock leeway is zero, and access vs refresh tokens carry distinct +//! audiences so one can never be presented as the other. Refresh tokens are +//! also tracked in the database (by a peppered hash) for rotation/revocation. + +use chrono::{Duration, Utc}; +use hmac::{Hmac, Mac}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use uuid::Uuid; + +use crate::error::AppError; + +/// Audience for access tokens. Distinct from the refresh audience so a refresh +/// token can never be accepted on an access-protected route, and vice versa. +const ACCESS_AUD: &str = "smirk:access"; +/// Audience for refresh tokens. +const REFRESH_AUD: &str = "smirk:refresh"; + +/// JWT claims for access tokens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessTokenClaims { + /// Subject (user ID). + pub sub: String, + /// Audience (token type). + pub aud: String, + pub iat: i64, + pub exp: i64, + /// Client kind: extension | web | nostr. + pub platform: String, +} + +/// JWT claims for refresh tokens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshTokenClaims { + /// Subject (user ID). + pub sub: String, + /// Audience (token type). + pub aud: String, + /// Session ID (for revocation). + pub sid: String, + pub iat: i64, + pub exp: i64, +} + +/// Decoded access-token information. +#[derive(Debug, Clone)] +pub struct TokenInfo { + pub user_id: Uuid, + pub platform: String, +} + +/// Session token pair. +#[derive(Debug, Clone, Serialize)] +pub struct TokenPair { + pub access_token: String, + pub refresh_token: String, + pub expires_in: i64, +} + +/// Client kind a session was created from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + Extension, + Web, + Nostr, +} + +impl Platform { + pub fn as_str(&self) -> &'static str { + match self { + Platform::Extension => "extension", + Platform::Web => "web", + Platform::Nostr => "nostr", + } + } +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for Platform { + type Err = AppError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "extension" => Ok(Platform::Extension), + "web" => Ok(Platform::Web), + "nostr" => Ok(Platform::Nostr), + _ => Err(AppError::ValidationError(format!( + "Invalid platform: {}", + s + ))), + } + } +} + +/// Challenge for website ("Sign in with your wallet") auth. +#[derive(Debug, Clone)] +pub struct WebChallenge { + pub nonce: String, + pub created_at: chrono::DateTime, + pub origin: String, + pub expires_at: chrono::DateTime, +} + +impl WebChallenge { + pub fn new(origin: String) -> Self { + let now = Utc::now(); + let nonce_bytes: [u8; 32] = rand::random(); + Self { + nonce: hex::encode(nonce_bytes), + created_at: now, + origin, + expires_at: now + Duration::minutes(5), + } + } + + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + pub fn message(&self) -> String { + format!( + "Smirk Authentication Challenge\nNonce: {}\nOrigin: {}\nTimestamp: {}", + self.nonce, + self.origin, + self.created_at.timestamp() + ) + } +} + +/// Creates and verifies session JWTs. +#[derive(Clone)] +pub struct SessionManager { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + access_validation: Validation, + refresh_validation: Validation, + access_token_expiry_hours: i64, + refresh_token_expiry_days: i64, +} + +fn strict_validation(audience: &str) -> Validation { + let mut v = Validation::new(Algorithm::HS256); + v.leeway = 0; + v.validate_exp = true; + v.set_required_spec_claims(&["exp", "sub", "aud"]); + v.set_audience(&[audience]); + v +} + +impl SessionManager { + /// `jwt_secret` must be >= 32 bytes (enforced by config validation). + pub fn new(jwt_secret: &str, access_token_expiry_hours: u64) -> Self { + Self { + encoding_key: EncodingKey::from_secret(jwt_secret.as_bytes()), + decoding_key: DecodingKey::from_secret(jwt_secret.as_bytes()), + access_validation: strict_validation(ACCESS_AUD), + refresh_validation: strict_validation(REFRESH_AUD), + access_token_expiry_hours: access_token_expiry_hours as i64, + refresh_token_expiry_days: 30, + } + } + + /// Mint an access + refresh token pair. `session_id` ties the refresh token + /// to a revocable DB session row. + pub fn create_token_pair( + &self, + user_id: Uuid, + platform: Platform, + session_id: Uuid, + ) -> Result { + let now = Utc::now(); + let access_exp = now + Duration::hours(self.access_token_expiry_hours); + let refresh_exp = now + Duration::days(self.refresh_token_expiry_days); + let header = Header::new(Algorithm::HS256); + + let access_claims = AccessTokenClaims { + sub: user_id.to_string(), + aud: ACCESS_AUD.to_string(), + iat: now.timestamp(), + exp: access_exp.timestamp(), + platform: platform.to_string(), + }; + let access_token = encode(&header, &access_claims, &self.encoding_key) + .map_err(|e| AppError::Internal(format!("Failed to create access token: {}", e)))?; + + let refresh_claims = RefreshTokenClaims { + sub: user_id.to_string(), + aud: REFRESH_AUD.to_string(), + sid: session_id.to_string(), + iat: now.timestamp(), + exp: refresh_exp.timestamp(), + }; + let refresh_token = encode(&header, &refresh_claims, &self.encoding_key) + .map_err(|e| AppError::Internal(format!("Failed to create refresh token: {}", e)))?; + + Ok(TokenPair { + access_token, + refresh_token, + expires_in: self.access_token_expiry_hours * 3600, + }) + } + + /// Verify an access token. The error message is a literal (never the inner + /// `jsonwebtoken` Display) so it cannot become an auth oracle. + pub fn verify_access_token(&self, token: &str) -> Result { + let data = decode::(token, &self.decoding_key, &self.access_validation) + .map_err(|_| AppError::AuthError("Invalid or expired token".into()))?; + let user_id = Uuid::parse_str(&data.claims.sub) + .map_err(|_| AppError::AuthError("Invalid or expired token".into()))?; + Ok(TokenInfo { + user_id, + platform: data.claims.platform, + }) + } + + /// Verify a refresh token, returning `(user_id, session_id)`. + pub fn verify_refresh_token(&self, token: &str) -> Result<(Uuid, Uuid), AppError> { + let data = + decode::(token, &self.decoding_key, &self.refresh_validation) + .map_err(|_| AppError::AuthError("Invalid or expired token".into()))?; + let user_id = Uuid::parse_str(&data.claims.sub) + .map_err(|_| AppError::AuthError("Invalid or expired token".into()))?; + let session_id = Uuid::parse_str(&data.claims.sid) + .map_err(|_| AppError::AuthError("Invalid or expired token".into()))?; + Ok((user_id, session_id)) + } + + pub fn refresh_token_expiry(&self) -> Duration { + Duration::days(self.refresh_token_expiry_days) + } +} + +/// Peppered hash of a refresh token for storage. HMAC-SHA256 with the server +/// pepper makes a stolen DB row non-reversible and non-correlatable without the +/// pepper. We store the hash, never the token. +pub fn hash_refresh_token(token: &str, pepper: &str) -> String { + let mut mac = + Hmac::::new_from_slice(pepper.as_bytes()).expect("HMAC accepts any key length"); + mac.update(token.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "test-secret-key-at-least-32-bytes-long!!"; + + #[test] + fn create_and_verify_access_and_refresh() { + let m = SessionManager::new(SECRET, 24); + let uid = Uuid::new_v4(); + let sid = Uuid::new_v4(); + let pair = m.create_token_pair(uid, Platform::Extension, sid).unwrap(); + + let info = m.verify_access_token(&pair.access_token).unwrap(); + assert_eq!(info.user_id, uid); + assert_eq!(info.platform, "extension"); + + let (u, s) = m.verify_refresh_token(&pair.refresh_token).unwrap(); + assert_eq!(u, uid); + assert_eq!(s, sid); + } + + #[test] + fn access_token_is_not_accepted_as_refresh() { + let m = SessionManager::new(SECRET, 24); + let pair = m + .create_token_pair(Uuid::new_v4(), Platform::Web, Uuid::new_v4()) + .unwrap(); + // Distinct audiences: an access token must fail refresh validation. + assert!(m.verify_refresh_token(&pair.access_token).is_err()); + } + + #[test] + fn refresh_token_is_not_accepted_as_access() { + let m = SessionManager::new(SECRET, 24); + let pair = m + .create_token_pair(Uuid::new_v4(), Platform::Nostr, Uuid::new_v4()) + .unwrap(); + assert!(m.verify_access_token(&pair.refresh_token).is_err()); + } + + #[test] + fn tampered_access_token_rejected() { + let m = SessionManager::new(SECRET, 24); + let pair = m + .create_token_pair(Uuid::new_v4(), Platform::Extension, Uuid::new_v4()) + .unwrap(); + let mut t = pair.access_token; + t.pop(); + t.push(if t.ends_with('a') { 'b' } else { 'a' }); + assert!(m.verify_access_token(&t).is_err()); + } + + #[test] + fn peppered_hash_is_deterministic_and_keyed() { + let a = hash_refresh_token("token", "pepper-A"); + assert_eq!(a, hash_refresh_token("token", "pepper-A")); + assert_ne!(a, "token"); + // Different pepper -> different hash (DB compromise without pepper is useless). + assert_ne!(a, hash_refresh_token("token", "pepper-B")); + } + + #[test] + fn platform_roundtrips() { + use std::str::FromStr; + for p in [Platform::Extension, Platform::Web, Platform::Nostr] { + assert_eq!(Platform::from_str(p.as_str()).unwrap(), p); + } + assert!(Platform::from_str("telegram").is_err()); + } +}