From 432e8d35af5c845a7e145c00736976ad99448f24 Mon Sep 17 00:00:00 2001 From: DevITWay | Pavel Volkov Date: Wed, 25 Mar 2026 01:56:43 +0300 Subject: [PATCH] security: migrate token hashing from SHA256 to Argon2id (#55) * docs: add DCO, governance model, roles, vulnerability credit policy * security: migrate token hashing from SHA256 to Argon2id - Replace unsalted SHA256 with Argon2id (salted) for API token hashing - Fix TOCTOU race: replace exists()+read() with read()+match on error - Set chmod 600 on token files and 700 on token storage directory - Auto-migrate legacy SHA256 tokens to Argon2id on first verification - Add regression tests: argon2 format, legacy migration, file permissions --- CONTRIBUTING.md | 28 +++++ Cargo.lock | 66 ++++++++++-- SECURITY.md | 4 +- nora-registry/Cargo.toml | 1 + nora-registry/src/tokens.rs | 207 ++++++++++++++++++++++++++++-------- 5 files changed, 254 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d548ac9..befb4d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,34 @@ Thank you for your interest in contributing to NORA! +## Developer Certificate of Origin (DCO) + +By submitting a pull request, you agree to the [Developer Certificate of Origin](https://developercertificate.org/). +Your contribution will be licensed under the [MIT License](LICENSE). + +You confirm that you have the right to submit the code and that it does not violate any third-party rights. + +## Project Governance + +NORA uses a **Benevolent Dictator** governance model: + +- **Maintainer:** [@devitway](https://github.com/devitway) — final decisions on features, releases, and architecture +- **Contributors:** anyone who submits issues, PRs, or docs improvements +- **Decision process:** proposals via GitHub Issues → discussion → maintainer decision +- **Release authority:** maintainer only + +### Roles and Responsibilities + +| Role | Person | Responsibilities | +|------|--------|-----------------| +| Maintainer | @devitway | Code review, releases, roadmap, security response | +| Contributor | anyone | Issues, PRs, documentation, testing | +| Dependabot | automated | Dependency updates | + +### Continuity + +The GitHub organization [getnora-io](https://github.com/getnora-io) has multiple admin accounts to ensure project continuity. Source code is MIT-licensed, enabling anyone to fork and continue the project. + ## Getting Started 1. Fork the repository diff --git a/Cargo.lock b/Cargo.lock index 6d97e93..27897a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -79,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -97,6 +97,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -188,6 +200,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bcrypt" version = "0.19.0" @@ -207,6 +225,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -475,7 +502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1279,6 +1306,7 @@ dependencies = [ name = "nora-registry" version = "0.2.35" dependencies = [ + "argon2", "async-trait", "axum", "base64", @@ -1320,7 +1348,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1377,6 +1405,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1585,7 +1624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1595,7 +1634,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1767,7 +1815,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2056,7 +2104,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2729,7 +2777,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/SECURITY.md b/SECURITY.md index 33278a2..f7cd113 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,4 +50,6 @@ When deploying NORA: ## Acknowledgments -We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities. +We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities in our release notes and CHANGELOG, unless the reporter requests anonymity. + +If you have previously reported a vulnerability and would like to be credited, please let us know. diff --git a/nora-registry/Cargo.toml b/nora-registry/Cargo.toml index 70e1738..c12fee6 100644 --- a/nora-registry/Cargo.toml +++ b/nora-registry/Cargo.toml @@ -49,6 +49,7 @@ tower_governor = "0.8" governor = "0.10" parking_lot = "0.12" zeroize = { version = "1.8", features = ["derive"] } +argon2 = { version = "0.5", features = ["std", "rand"] } tower-http = { version = "0.6", features = ["set-header"] } [dev-dependencies] diff --git a/nora-registry/src/tokens.rs b/nora-registry/src/tokens.rs index bee4b12..b6d126e 100644 --- a/nora-registry/src/tokens.rs +++ b/nora-registry/src/tokens.rs @@ -1,9 +1,14 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; @@ -66,8 +71,12 @@ pub struct TokenStore { impl TokenStore { /// Create a new token store pub fn new(storage_path: &Path) -> Self { - // Ensure directory exists + // Ensure directory exists with restricted permissions let _ = fs::create_dir_all(storage_path); + #[cfg(unix)] + { + let _ = fs::set_permissions(storage_path, fs::Permissions::from_mode(0o700)); + } Self { storage_path: storage_path.to_path_buf(), } @@ -87,7 +96,9 @@ impl TokenStore { TOKEN_PREFIX, Uuid::new_v4().to_string().replace("-", "") ); - let token_hash = hash_token(&raw_token); + let token_hash = hash_token_argon2(&raw_token)?; + // Use SHA256 of token as filename (deterministic, for lookup) + let file_id = sha256_hex(&raw_token); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -97,7 +108,7 @@ impl TokenStore { let expires_at = now + (ttl_days * 24 * 60 * 60); let info = TokenInfo { - token_hash: token_hash.clone(), + token_hash, user: user.to_string(), created_at: now, expires_at, @@ -106,13 +117,12 @@ impl TokenStore { role, }; - // Save to file - let file_path = self - .storage_path - .join(format!("{}.json", &token_hash[..16])); + // Save to file with restricted permissions + let file_path = self.storage_path.join(format!("{}.json", &file_id[..16])); let json = serde_json::to_string_pretty(&info).map_err(|e| TokenError::Storage(e.to_string()))?; - fs::write(&file_path, json).map_err(|e| TokenError::Storage(e.to_string()))?; + fs::write(&file_path, &json).map_err(|e| TokenError::Storage(e.to_string()))?; + set_file_permissions_600(&file_path); Ok(raw_token) } @@ -123,22 +133,43 @@ impl TokenStore { return Err(TokenError::InvalidFormat); } - let token_hash = hash_token(token); - let file_path = self - .storage_path - .join(format!("{}.json", &token_hash[..16])); + let file_id = sha256_hex(token); + let file_path = self.storage_path.join(format!("{}.json", &file_id[..16])); - if !file_path.exists() { - return Err(TokenError::NotFound); - } + // TOCTOU fix: read directly, handle NotFound from IO error + let content = match fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(TokenError::NotFound); + } + Err(e) => return Err(TokenError::Storage(e.to_string())), + }; - let content = - fs::read_to_string(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?; let mut info: TokenInfo = serde_json::from_str(&content).map_err(|e| TokenError::Storage(e.to_string()))?; - // Verify hash matches - if info.token_hash != token_hash { + // Verify hash: try Argon2id first, fall back to legacy SHA256 + let hash_valid = if info.token_hash.starts_with("$argon2") { + verify_token_argon2(token, &info.token_hash) + } else { + // Legacy SHA256 hash (no salt) — verify and migrate + let legacy_hash = sha256_hex(token); + if info.token_hash == legacy_hash { + // Migrate to Argon2id + if let Ok(new_hash) = hash_token_argon2(token) { + info.token_hash = new_hash; + if let Ok(json) = serde_json::to_string_pretty(&info) { + let _ = fs::write(&file_path, &json); + set_file_permissions_600(&file_path); + } + } + true + } else { + false + } + }; + + if !hash_valid { return Err(TokenError::NotFound); } @@ -155,7 +186,8 @@ impl TokenStore { // Update last_used info.last_used = Some(now); if let Ok(json) = serde_json::to_string_pretty(&info) { - let _ = fs::write(&file_path, json); + let _ = fs::write(&file_path, &json); + set_file_permissions_600(&file_path); } Ok((info.user, info.role)) @@ -185,13 +217,12 @@ impl TokenStore { pub fn revoke_token(&self, hash_prefix: &str) -> Result<(), TokenError> { let file_path = self.storage_path.join(format!("{}.json", hash_prefix)); - if !file_path.exists() { - return Err(TokenError::NotFound); + // TOCTOU fix: try remove directly + match fs::remove_file(&file_path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenError::NotFound), + Err(e) => Err(TokenError::Storage(e.to_string())), } - - fs::remove_file(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?; - - Ok(()) } /// Revoke all tokens for a user @@ -214,13 +245,41 @@ impl TokenStore { } } -/// Hash a token using SHA256 -fn hash_token(token: &str) -> String { +/// Hash a token using Argon2id with random salt +fn hash_token_argon2(token: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + argon2 + .hash_password(token.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| TokenError::Storage(format!("hash error: {e}"))) +} + +/// Verify a token against an Argon2id hash +fn verify_token_argon2(token: &str, hash: &str) -> bool { + match PasswordHash::new(hash) { + Ok(parsed) => Argon2::default() + .verify_password(token.as_bytes(), &parsed) + .is_ok(), + Err(_) => false, + } +} + +/// SHA256 hex digest (used for file naming and legacy hash verification) +fn sha256_hex(input: &str) -> String { let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); + hasher.update(input.as_bytes()); format!("{:x}", hasher.finalize()) } +/// Set file permissions to 600 (owner read/write only) +fn set_file_permissions_600(path: &Path) { + #[cfg(unix)] + { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } +} + #[derive(Debug, Error)] pub enum TokenError { #[error("Invalid token format")] @@ -254,6 +313,19 @@ mod tests { assert_eq!(token.len(), 4 + 32); // prefix + uuid without dashes } + #[test] + fn test_token_hash_is_argon2() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store + .create_token("testuser", 30, None, Role::Write) + .unwrap(); + + let tokens = store.list_tokens("testuser"); + assert!(tokens[0].token_hash.starts_with("$argon2")); + } + #[test] fn test_verify_valid_token() { let temp_dir = TempDir::new().unwrap(); @@ -291,24 +363,80 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let store = TokenStore::new(temp_dir.path()); - // Create token and manually set it as expired let token = store .create_token("testuser", 1, None, Role::Write) .unwrap(); - let token_hash = hash_token(&token); - let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16])); + let file_id = sha256_hex(&token); + let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16])); - // Read and modify the token to be expired let content = std::fs::read_to_string(&file_path).unwrap(); let mut info: TokenInfo = serde_json::from_str(&content).unwrap(); - info.expires_at = 0; // Set to epoch (definitely expired) + info.expires_at = 0; std::fs::write(&file_path, serde_json::to_string(&info).unwrap()).unwrap(); - // Token should now be expired let result = store.verify_token(&token); assert!(matches!(result, Err(TokenError::Expired))); } + #[test] + fn test_legacy_sha256_migration() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + // Simulate a legacy token with SHA256 hash + let raw_token = "nra_00112233445566778899aabbccddeeff"; + let legacy_hash = sha256_hex(raw_token); + let file_id = sha256_hex(raw_token); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let info = TokenInfo { + token_hash: legacy_hash.clone(), + user: "legacyuser".to_string(), + created_at: now, + expires_at: now + 86400, + last_used: None, + description: None, + role: Role::Read, + }; + + let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16])); + fs::write(&file_path, serde_json::to_string_pretty(&info).unwrap()).unwrap(); + + // Verify should work with legacy hash + let (user, role) = store.verify_token(raw_token).unwrap(); + assert_eq!(user, "legacyuser"); + assert_eq!(role, Role::Read); + + // After verification, hash should be migrated to Argon2id + let content = fs::read_to_string(&file_path).unwrap(); + let updated: TokenInfo = serde_json::from_str(&content).unwrap(); + assert!(updated.token_hash.starts_with("$argon2")); + } + + #[test] + fn test_file_permissions() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store + .create_token("testuser", 30, None, Role::Write) + .unwrap(); + + let file_id = sha256_hex(&token); + let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16])); + + #[cfg(unix)] + { + let metadata = fs::metadata(&file_path).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + } + #[test] fn test_list_tokens() { let temp_dir = TempDir::new().unwrap(); @@ -336,16 +464,13 @@ mod tests { let token = store .create_token("testuser", 30, None, Role::Write) .unwrap(); - let token_hash = hash_token(&token); - let hash_prefix = &token_hash[..16]; + let file_id = sha256_hex(&token); + let hash_prefix = &file_id[..16]; - // Verify token works assert!(store.verify_token(&token).is_ok()); - // Revoke store.revoke_token(hash_prefix).unwrap(); - // Verify token no longer works let result = store.verify_token(&token); assert!(matches!(result, Err(TokenError::NotFound))); } @@ -384,10 +509,8 @@ mod tests { .create_token("testuser", 30, None, Role::Write) .unwrap(); - // First verification store.verify_token(&token).unwrap(); - // Check last_used is set let tokens = store.list_tokens("testuser"); assert!(tokens[0].last_used.is_some()); }