mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat: session/JWT + PoW gate (config-driven, strict)
core/session.rs: - strict JWT Validation: HS256 pinned, exp/sub/aud required, leeway 0 - distinct audiences for access vs refresh so neither is accepted as the other - Platform -> Extension|Web|Nostr (dropped telegram/discord + platform_user_id) - refresh-token hash is HMAC-peppered (DB compromise is useless without pepper) - verify_* return literal error messages (no jsonwebtoken Display oracle) core/pow.rs: ALTCHA gate driven by config::PowConfig (feature-gated, real HMAC key required, no fallback); issue/verify/required_for + replay/expiry tests.
This commit is contained in:
+3
-1
@@ -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.
|
||||
|
||||
+154
@@ -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<altcha::Challenge, AppError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
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<Utc>,
|
||||
pub origin: String,
|
||||
pub expires_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<TokenPair, AppError> {
|
||||
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<TokenInfo, AppError> {
|
||||
let data = decode::<AccessTokenClaims>(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::<RefreshTokenClaims>(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::<Sha256>::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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user