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) <noreply@anthropic.com>
This commit is contained in:
parent
198897cbba
commit
3020a9cfc9
4 changed files with 61 additions and 10 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
- `save_profiles` — Sauvegarde de la configuration
|
- `save_profiles` — Sauvegarde de la configuration
|
||||||
- `delete_profile_db` — Suppression du fichier de base de données
|
- `delete_profile_db` — Suppression du fichier de base de données
|
||||||
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||||
- `hash_pin` — Hachage Argon2 du PIN
|
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
|
||||||
- `verify_pin` — Vérification du PIN
|
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||||
|
|
||||||
## Pages et routing
|
## Pages et routing
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256, Sha384};
|
use sha2::{Digest, Sha256, Sha384};
|
||||||
|
|
@ -118,24 +119,55 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, 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<Vec<u8>, 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]
|
#[tauri::command]
|
||||||
pub fn hash_pin(pin: String) -> Result<String, String> {
|
pub fn hash_pin(pin: String) -> Result<String, String> {
|
||||||
let mut salt = [0u8; 16];
|
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||||
rand::rngs::OsRng.fill_bytes(&mut salt);
|
rand::rngs::OsRng.fill_bytes(&mut salt);
|
||||||
let salt_hex = hex_encode(&salt);
|
let salt_hex = hex_encode(&salt);
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
let hash = argon2_hash(&pin, &salt)?;
|
||||||
hasher.update(salt_hex.as_bytes());
|
let hash_hex = hex_encode(&hash);
|
||||||
hasher.update(pin.as_bytes());
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let hash_hex = hex_encode(&result);
|
|
||||||
|
|
||||||
// Store as "salt:hash"
|
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
|
||||||
Ok(format!("{}:{}", salt_hex, hash_hex))
|
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
|
||||||
|
// 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();
|
let parts: Vec<&str> = stored_hash.split(':').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err("Invalid stored hash format".to_string());
|
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()
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hex_decode(hex: &str) -> Result<Vec<u8>, 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.
|
/// Repair migration checksums for a profile database.
|
||||||
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
/// Updates stored checksums to match current migration SQL, avoiding re-application
|
||||||
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue