From cea16c24aecf0ac7d7960b2e44f579c4c5708410 Mon Sep 17 00:00:00 2001 From: escouade-bot Date: Thu, 9 Apr 2026 00:02:49 -0400 Subject: [PATCH 1/4] fix: migrate PIN hashing from SHA-256 to Argon2id (#54) Replace SHA-256 with Argon2id (m=64MiB, t=3, p=1) for PIN hashing. Existing SHA-256 hashes are verified transparently via format detection (argon2id: prefix). New PINs are always hashed with Argon2id. Addresses CWE-916: Use of Password Hash With Insufficient Computational Effort. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.fr.md | 3 ++ CHANGELOG.md | 3 ++ docs/architecture.md | 4 +- src-tauri/src/commands/profile_commands.rs | 61 +++++++++++++++++++--- 4 files changed, 61 insertions(+), 10 deletions(-) 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/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index 91f63dc..0661ab6 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -1,3 +1,4 @@ +use argon2::{Algorithm, Argon2, Params, Version}; use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha384}; @@ -118,24 +119,55 @@ pub fn get_new_profile_init_sql() -> Result, String> { ]) } +// 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; + +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 hash_pin(pin: String) -> Result { - let mut salt = [0u8; 16]; + let mut salt = [0u8; ARGON2_SALT_LEN]; rand::rngs::OsRng.fill_bytes(&mut salt); let salt_hex = hex_encode(&salt); - 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); + let hash = argon2_hash(&pin, &salt)?; + let hash_hex = hex_encode(&hash); - // Store as "salt:hash" - Ok(format!("{}:{}", salt_hex, hash_hex)) + // Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash" + Ok(format!("argon2id:{}:{}", salt_hex, hash_hex)) } #[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 = parts[1]; + + let computed = argon2_hash(&pin, &salt)?; + let computed_hex = hex_encode(&computed); + + return Ok(computed_hex == expected_hash); + } + + // 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()); @@ -156,6 +188,19 @@ 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). -- 2.45.2 From 34626711eb8f5e50ad9b0dd266ff1409785f401a Mon Sep 17 00:00:00 2001 From: escouade-bot Date: Thu, 9 Apr 2026 02:05:11 -0400 Subject: [PATCH 2/4] fix: address reviewer feedback (#54) - Add automatic re-hashing of legacy SHA-256 PINs to Argon2id on successful verification, returning new hash to frontend for persistence - Use constant-time comparison (subtle::ConstantTimeEq) for both Argon2id and legacy SHA-256 hash verification - Add unit tests for hash_pin, verify_pin (Argon2id and legacy paths), re-hashing flow, error cases, and hex encoding roundtrip - Update frontend to handle VerifyPinResult struct and save rehashed PIN hash via profile update Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/profile_commands.rs | 124 +++++++++++++++++++-- src/components/profile/PinDialog.tsx | 14 +-- src/components/profile/ProfileSwitcher.tsx | 7 +- src/contexts/ProfileContext.tsx | 4 +- src/pages/ProfileSelectionPage.tsx | 7 +- src/services/profileService.ts | 10 +- 7 files changed, 145 insertions(+), 22 deletions(-) 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 0661ab6..28e8507 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -3,6 +3,7 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha384}; use std::fs; +use subtle::ConstantTimeEq; use tauri::Manager; use crate::database; @@ -150,8 +151,15 @@ pub fn hash_pin(pin: String) -> Result { 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 { +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(); @@ -159,12 +167,12 @@ pub fn verify_pin(pin: String, stored_hash: String) -> Result { return Err("Invalid Argon2id hash format".to_string()); } let salt = hex_decode(parts[0])?; - let expected_hash = parts[1]; + let expected_hash = hex_decode(parts[1])?; let computed = argon2_hash(&pin, &salt)?; - let computed_hex = hex_encode(&computed); - return Ok(computed_hex == expected_hash); + let valid = computed.ct_eq(&expected_hash).into(); + return Ok(VerifyPinResult { valid, rehashed: None }); } // Legacy SHA-256 format: "salt_hex:hash_hex" @@ -173,15 +181,22 @@ pub fn verify_pin(pin: String, stored_hash: String) -> Result { 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 + let new_hash = hash_pin(pin)?; + Ok(VerifyPinResult { valid: true, rehashed: Some(new_hash) }) + } else { + Ok(VerifyPinResult { valid: false, rehashed: None }) + } } fn hex_encode(bytes: &[u8]) -> String { @@ -262,3 +277,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..1c0ae74 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,11 @@ export default function ProfileSwitcher() { } }; - const handlePinSuccess = () => { + const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfile) { + if (rehashed) { + await updateProfile(pinProfile.id, { pin_hash: rehashed }); + } 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..d76cbf6 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,11 @@ export default function ProfileSelectionPage() { } }; - const handlePinSuccess = () => { + const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfileId) { + if (rehashed) { + await updateProfile(pinProfileId, { pin_hash: rehashed }); + } 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 }); } -- 2.45.2 From 2f610bf10adf62664cdec72060bc8fbce22717e2 Mon Sep 17 00:00:00 2001 From: escouade-bot Date: Thu, 9 Apr 2026 06:02:15 -0400 Subject: [PATCH 3/4] fix: make legacy PIN rehash non-blocking in verify_pin (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hash_pin(pin)? with hash_pin(pin).ok() so that a rehash failure does not propagate as an error. The user can now switch profiles even if the Argon2id re-hashing step fails — the PIN is still correctly verified, and the legacy hash remains until the next successful login. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/profile_commands.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index 28e8507..fb9a0de 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -191,9 +191,10 @@ pub fn verify_pin(pin: String, stored_hash: String) -> Result Date: Thu, 9 Apr 2026 08:01:08 -0400 Subject: [PATCH 4/4] fix: wrap rehash updateProfile in try/catch for best-effort (#54) Both handlePinSuccess handlers (ProfileSwitcher and ProfileSelectionPage) now catch updateProfile errors so that a failed rehash persistence does not block switchProfile. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/profile/ProfileSwitcher.tsx | 6 +++++- src/pages/ProfileSelectionPage.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/profile/ProfileSwitcher.tsx b/src/components/profile/ProfileSwitcher.tsx index 1c0ae74..d92f082 100644 --- a/src/components/profile/ProfileSwitcher.tsx +++ b/src/components/profile/ProfileSwitcher.tsx @@ -39,7 +39,11 @@ export default function ProfileSwitcher() { const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfile) { if (rehashed) { - await updateProfile(pinProfile.id, { pin_hash: 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/pages/ProfileSelectionPage.tsx b/src/pages/ProfileSelectionPage.tsx index d76cbf6..b62f7ca 100644 --- a/src/pages/ProfileSelectionPage.tsx +++ b/src/pages/ProfileSelectionPage.tsx @@ -26,7 +26,11 @@ export default function ProfileSelectionPage() { const handlePinSuccess = async (rehashed?: string | null) => { if (pinProfileId) { if (rehashed) { - await updateProfile(pinProfileId, { pin_hash: rehashed }); + try { + await updateProfile(pinProfileId, { pin_hash: rehashed }); + } catch { + // Best-effort rehash: don't block profile switch if persistence fails + } } switchProfile(pinProfileId); setPinProfileId(null); -- 2.45.2