diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e6b4701..d2813cf 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -5,6 +5,9 @@ ### Ajouté - CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60) +### Modifié +- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente ; les nouveaux PINs utilisent Argon2id (#54) + ## [0.6.7] - 2026-03-29 ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 391185b..1cf6fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Added - CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60) +### Changed +- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently; new PINs use Argon2id (#54) + ## [0.6.7] - 2026-03-29 ### Changed diff --git a/docs/architecture.md b/docs/architecture.md index f1d2ae8..40da47b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,8 +171,8 @@ Chaque hook encapsule la logique d'état via `useReducer` : - `save_profiles` — Sauvegarde de la configuration - `delete_profile_db` — Suppression du fichier de base de données - `get_new_profile_init_sql` — Récupération du schéma consolidé -- `hash_pin` — Hachage Argon2 du PIN -- `verify_pin` — Vérification du PIN +- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`) +- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité) - `repair_migrations` — Réparation des checksums de migration (rusqlite) ## Pages et routing diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 336e9c0..6984c47 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ encoding_rs = "0.8" walkdir = "2" aes-gcm = "0.10" argon2 = "0.5" +subtle = "2" rand = "0.8" jsonwebtoken = "9" machine-uid = "0.5" diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index 91f63dc..fb9a0de 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -1,7 +1,9 @@ +use argon2::{Algorithm, Argon2, Params, Version}; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha384}; use std::fs; +use subtle::ConstantTimeEq; use tauri::Manager; use crate::database; @@ -118,44 +120,103 @@ pub fn get_new_profile_init_sql() -> Result, String> { ]) } -#[tauri::command] -pub fn hash_pin(pin: String) -> Result { - let mut salt = [0u8; 16]; - rand::rngs::OsRng.fill_bytes(&mut salt); - let salt_hex = hex_encode(&salt); +// Argon2id parameters for PIN hashing (same as export_import_commands.rs) +const ARGON2_M_COST: u32 = 65536; // 64 MiB +const ARGON2_T_COST: u32 = 3; +const ARGON2_P_COST: u32 = 1; +const ARGON2_OUTPUT_LEN: usize = 32; +const ARGON2_SALT_LEN: usize = 16; - let mut hasher = Sha256::new(); - hasher.update(salt_hex.as_bytes()); - hasher.update(pin.as_bytes()); - let result = hasher.finalize(); - let hash_hex = hex_encode(&result); - - // Store as "salt:hash" - Ok(format!("{}:{}", salt_hex, hash_hex)) +fn argon2_hash(pin: &str, salt: &[u8]) -> Result, String> { + let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN)) + .map_err(|e| format!("Argon2 params error: {}", e))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut hash = vec![0u8; ARGON2_OUTPUT_LEN]; + argon2 + .hash_password_into(pin.as_bytes(), salt, &mut hash) + .map_err(|e| format!("Argon2 hash error: {}", e))?; + Ok(hash) } #[tauri::command] -pub fn verify_pin(pin: String, stored_hash: String) -> Result { +pub fn hash_pin(pin: String) -> Result { + let mut salt = [0u8; ARGON2_SALT_LEN]; + rand::rngs::OsRng.fill_bytes(&mut salt); + let salt_hex = hex_encode(&salt); + + let hash = argon2_hash(&pin, &salt)?; + let hash_hex = hex_encode(&hash); + + // Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash" + Ok(format!("argon2id:{}:{}", salt_hex, hash_hex)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyPinResult { + pub valid: bool, + /// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed + pub rehashed: Option, +} + +#[tauri::command] +pub fn verify_pin(pin: String, stored_hash: String) -> Result { + // Argon2id format: "argon2id:salt_hex:hash_hex" + if let Some(rest) = stored_hash.strip_prefix("argon2id:") { + let parts: Vec<&str> = rest.split(':').collect(); + if parts.len() != 2 { + return Err("Invalid Argon2id hash format".to_string()); + } + let salt = hex_decode(parts[0])?; + let expected_hash = hex_decode(parts[1])?; + + let computed = argon2_hash(&pin, &salt)?; + + let valid = computed.ct_eq(&expected_hash).into(); + return Ok(VerifyPinResult { valid, rehashed: None }); + } + + // Legacy SHA-256 format: "salt_hex:hash_hex" let parts: Vec<&str> = stored_hash.split(':').collect(); if parts.len() != 2 { return Err("Invalid stored hash format".to_string()); } let salt_hex = parts[0]; - let expected_hash = parts[1]; + let expected_hash = hex_decode(parts[1])?; let mut hasher = Sha256::new(); hasher.update(salt_hex.as_bytes()); hasher.update(pin.as_bytes()); let result = hasher.finalize(); - let computed_hash = hex_encode(&result); - Ok(computed_hash == expected_hash) + let valid: bool = result.as_slice().ct_eq(&expected_hash).into(); + + if valid { + // Re-hash with Argon2id so this legacy PIN is upgraded. + // If rehash fails, still allow login — don't block the user. + let rehashed = hash_pin(pin).ok(); + Ok(VerifyPinResult { valid: true, rehashed }) + } else { + Ok(VerifyPinResult { valid: false, rehashed: None }) + } } fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect() } +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("Invalid hex string length".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .map_err(|e| format!("Invalid hex character: {}", e)) + }) + .collect() +} + /// Repair migration checksums for a profile database. /// Updates stored checksums to match current migration SQL, avoiding re-application /// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords). @@ -217,3 +278,98 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result = hash.split(':').collect(); + assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash"); + assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2); + assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2); + } + + #[test] + fn test_hash_pin_different_salts() { + let h1 = hash_pin("1234".to_string()).unwrap(); + let h2 = hash_pin("1234".to_string()).unwrap(); + assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts"); + } + + #[test] + fn test_verify_argon2id_pin_correct() { + let hash = hash_pin("5678".to_string()).unwrap(); + let result = verify_pin("5678".to_string(), hash).unwrap(); + assert!(result.valid, "Correct PIN should verify"); + assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed"); + } + + #[test] + fn test_verify_argon2id_pin_wrong() { + let hash = hash_pin("5678".to_string()).unwrap(); + let result = verify_pin("0000".to_string(), hash).unwrap(); + assert!(!result.valid, "Wrong PIN should not verify"); + assert!(result.rehashed.is_none()); + } + + #[test] + fn test_verify_legacy_sha256_correct_and_rehash() { + // Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)" + let salt_hex = "abcdef0123456789"; + let mut hasher = Sha256::new(); + hasher.update(salt_hex.as_bytes()); + hasher.update(b"4321"); + let hash_bytes = hasher.finalize(); + let hash_hex = hex_encode(&hash_bytes); + let stored = format!("{}:{}", salt_hex, hash_hex); + + let result = verify_pin("4321".to_string(), stored).unwrap(); + assert!(result.valid, "Correct legacy PIN should verify"); + assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id"); + + // Verify the rehashed value is a valid Argon2id hash + let new_hash = result.rehashed.unwrap(); + assert!(new_hash.starts_with("argon2id:")); + + // Verify the rehashed value works for future verification + let result2 = verify_pin("4321".to_string(), new_hash).unwrap(); + assert!(result2.valid, "Rehashed PIN should verify"); + assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed"); + } + + #[test] + fn test_verify_legacy_sha256_wrong() { + let salt_hex = "abcdef0123456789"; + let mut hasher = Sha256::new(); + hasher.update(salt_hex.as_bytes()); + hasher.update(b"4321"); + let hash_bytes = hasher.finalize(); + let hash_hex = hex_encode(&hash_bytes); + let stored = format!("{}:{}", salt_hex, hash_hex); + + let result = verify_pin("9999".to_string(), stored).unwrap(); + assert!(!result.valid, "Wrong legacy PIN should not verify"); + assert!(result.rehashed.is_none(), "Failed verification should not rehash"); + } + + #[test] + fn test_verify_invalid_format() { + let result = verify_pin("1234".to_string(), "invalid".to_string()); + assert!(result.is_err(), "Single-part hash should fail"); + + let result = verify_pin("1234".to_string(), "argon2id:bad".to_string()); + assert!(result.is_err(), "Argon2id with wrong part count should fail"); + } + + #[test] + fn test_hex_roundtrip() { + let original = vec![0u8, 127, 255, 1, 16]; + let encoded = hex_encode(&original); + let decoded = hex_decode(&encoded).unwrap(); + assert_eq!(original, decoded); + } +} diff --git a/src/components/profile/PinDialog.tsx b/src/components/profile/PinDialog.tsx index b8c0343..9f11126 100644 --- a/src/components/profile/PinDialog.tsx +++ b/src/components/profile/PinDialog.tsx @@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService"; interface Props { profileName: string; storedHash: string; - onSuccess: () => void; + onSuccess: (rehashed?: string | null) => void; onCancel: () => void; } @@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel if (value && filledCount === index + 1) { setChecking(true); try { - const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash); - if (valid) { - onSuccess(); + const result = await verifyPin(pin.replace(/\s/g, ""), storedHash); + if (result.valid) { + onSuccess(result.rehashed); } else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) { setError(true); setDigits(["", "", "", "", "", ""]); @@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel const pin = digits.join(""); if (pin.length >= 4) { setChecking(true); - verifyPin(pin, storedHash).then((valid) => { + verifyPin(pin, storedHash).then((result) => { setChecking(false); - if (valid) { - onSuccess(); + if (result.valid) { + onSuccess(result.rehashed); } else { setError(true); setDigits(["", "", "", "", "", ""]); diff --git a/src/components/profile/ProfileSwitcher.tsx b/src/components/profile/ProfileSwitcher.tsx index 518528c..d92f082 100644 --- a/src/components/profile/ProfileSwitcher.tsx +++ b/src/components/profile/ProfileSwitcher.tsx @@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService"; export default function ProfileSwitcher() { const { t } = useTranslation(); - const { profiles, activeProfile, switchProfile } = useProfile(); + const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); const [open, setOpen] = useState(false); const [pinProfile, setPinProfile] = useState(null); const [showManage, setShowManage] = useState(false); @@ -36,8 +36,15 @@ export default function ProfileSwitcher() { } }; - const handlePinSuccess = () => { + const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfile) { + if (rehashed) { + try { + await updateProfile(pinProfile.id, { pin_hash: rehashed }); + } catch { + // Best-effort rehash: don't block profile switch if persistence fails + } + } switchProfile(pinProfile.id); setPinProfile(null); } diff --git a/src/contexts/ProfileContext.tsx b/src/contexts/ProfileContext.tsx index 8f58a7d..0608e6e 100644 --- a/src/contexts/ProfileContext.tsx +++ b/src/contexts/ProfileContext.tsx @@ -46,7 +46,7 @@ interface ProfileContextValue { error: string | null; switchProfile: (id: string) => Promise; createProfile: (name: string, color: string, pin?: string) => Promise; - updateProfile: (id: string, updates: Partial>) => Promise; + updateProfile: (id: string, updates: Partial>) => Promise; deleteProfile: (id: string) => Promise; setPin: (id: string, pin: string | null) => Promise; connectActiveProfile: () => Promise; @@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) { } }, [state.config]); - const updateProfile = useCallback(async (id: string, updates: Partial>) => { + const updateProfile = useCallback(async (id: string, updates: Partial>) => { if (!state.config) return; const newProfiles = state.config.profiles.map((p) => diff --git a/src/pages/ProfileSelectionPage.tsx b/src/pages/ProfileSelectionPage.tsx index 765911b..b62f7ca 100644 --- a/src/pages/ProfileSelectionPage.tsx +++ b/src/pages/ProfileSelectionPage.tsx @@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal"; export default function ProfileSelectionPage() { const { t } = useTranslation(); - const { profiles, switchProfile } = useProfile(); + const { profiles, switchProfile, updateProfile } = useProfile(); const [pinProfileId, setPinProfileId] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -23,8 +23,15 @@ export default function ProfileSelectionPage() { } }; - const handlePinSuccess = () => { + const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfileId) { + if (rehashed) { + try { + await updateProfile(pinProfileId, { pin_hash: rehashed }); + } catch { + // Best-effort rehash: don't block profile switch if persistence fails + } + } switchProfile(pinProfileId); setPinProfileId(null); } diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 9349c4a..401570d 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -34,6 +34,12 @@ export async function hashPin(pin: string): Promise { return invoke("hash_pin", { pin }); } -export async function verifyPin(pin: string, storedHash: string): Promise { - return invoke("verify_pin", { pin, storedHash }); +export interface VerifyPinResult { + valid: boolean; + /** New Argon2id hash when a legacy SHA-256 PIN was re-hashed on successful verification */ + rehashed: string | null; +} + +export async function verifyPin(pin: string, storedHash: string): Promise { + return invoke("verify_pin", { pin, storedHash }); }