fix: migrate PIN hashing from SHA-256 to Argon2id (#54) #55

Merged
maximus merged 4 commits from fix/simpl-resultat-54-argon2id-pin into main 2026-04-14 12:49:06 +00:00
10 changed files with 217 additions and 34 deletions

View file

@ -5,6 +5,9 @@
### Ajouté ### 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) - 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 ## [0.6.7] - 2026-03-29
### Modifié ### Modifié

View file

@ -5,6 +5,9 @@
### Added ### 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) - 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 ## [0.6.7] - 2026-03-29
### Changed ### Changed

View file

@ -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

View file

@ -34,6 +34,7 @@ encoding_rs = "0.8"
walkdir = "2" walkdir = "2"
aes-gcm = "0.10" aes-gcm = "0.10"
argon2 = "0.5" argon2 = "0.5"
subtle = "2"
rand = "0.8" rand = "0.8"
jsonwebtoken = "9" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"

View file

@ -1,7 +1,9 @@
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};
use std::fs; use std::fs;
use subtle::ConstantTimeEq;
use tauri::Manager; use tauri::Manager;
use crate::database; use crate::database;
@ -118,44 +120,103 @@ pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
]) ])
} }
#[tauri::command] // Argon2id parameters for PIN hashing (same as export_import_commands.rs)
pub fn hash_pin(pin: String) -> Result<String, String> { const ARGON2_M_COST: u32 = 65536; // 64 MiB
let mut salt = [0u8; 16]; const ARGON2_T_COST: u32 = 3;
rand::rngs::OsRng.fill_bytes(&mut salt); const ARGON2_P_COST: u32 = 1;
let salt_hex = hex_encode(&salt); const ARGON2_OUTPUT_LEN: usize = 32;
const ARGON2_SALT_LEN: usize = 16;
let mut hasher = Sha256::new(); fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
hasher.update(salt_hex.as_bytes()); let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
hasher.update(pin.as_bytes()); .map_err(|e| format!("Argon2 params error: {}", e))?;
let result = hasher.finalize(); let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let hash_hex = hex_encode(&result); let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
argon2
// Store as "salt:hash" .hash_password_into(pin.as_bytes(), salt, &mut hash)
Ok(format!("{}:{}", salt_hex, hash_hex)) .map_err(|e| format!("Argon2 hash error: {}", e))?;
Ok(hash)
} }
#[tauri::command] #[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> { pub fn hash_pin(pin: String) -> Result<String, String> {
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<String>,
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, 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 = 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(); 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());
} }
let salt_hex = parts[0]; let salt_hex = parts[0];
let expected_hash = parts[1]; let expected_hash = hex_decode(parts[1])?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes()); hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes()); hasher.update(pin.as_bytes());
let result = hasher.finalize(); 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 { 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).
@ -217,3 +278,98 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
Ok(repaired) Ok(repaired)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_pin_produces_argon2id_format() {
let hash = hash_pin("1234".to_string()).unwrap();
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
let parts: Vec<&str> = 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);
}
}

View file

@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
interface Props { interface Props {
profileName: string; profileName: string;
storedHash: string; storedHash: string;
onSuccess: () => void; onSuccess: (rehashed?: string | null) => void;
onCancel: () => void; onCancel: () => void;
} }
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
if (value && filledCount === index + 1) { if (value && filledCount === index + 1) {
setChecking(true); setChecking(true);
try { try {
const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash); const result = await verifyPin(pin.replace(/\s/g, ""), storedHash);
if (valid) { if (result.valid) {
onSuccess(); onSuccess(result.rehashed);
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) { } else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
const pin = digits.join(""); const pin = digits.join("");
if (pin.length >= 4) { if (pin.length >= 4) {
setChecking(true); setChecking(true);
verifyPin(pin, storedHash).then((valid) => { verifyPin(pin, storedHash).then((result) => {
setChecking(false); setChecking(false);
if (valid) { if (result.valid) {
onSuccess(); onSuccess(result.rehashed);
} else { } else {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);

View file

@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() { export default function ProfileSwitcher() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, activeProfile, switchProfile } = useProfile(); const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null); const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
@ -36,8 +36,15 @@ export default function ProfileSwitcher() {
} }
}; };
const handlePinSuccess = () => { const handlePinSuccess = async (rehashed?: string | null) => {
if (pinProfile) { 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); switchProfile(pinProfile.id);
setPinProfile(null); setPinProfile(null);
} }

View file

@ -46,7 +46,7 @@ interface ProfileContextValue {
error: string | null; error: string | null;
switchProfile: (id: string) => Promise<void>; switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, color: string, pin?: string) => Promise<void>; createProfile: (name: string, color: string, pin?: string) => Promise<void>;
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>; updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>;
deleteProfile: (id: string) => Promise<void>; deleteProfile: (id: string) => Promise<void>;
setPin: (id: string, pin: string | null) => Promise<void>; setPin: (id: string, pin: string | null) => Promise<void>;
connectActiveProfile: () => Promise<void>; connectActiveProfile: () => Promise<void>;
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
} }
}, [state.config]); }, [state.config]);
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => { const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => {
if (!state.config) return; if (!state.config) return;
const newProfiles = state.config.profiles.map((p) => const newProfiles = state.config.profiles.map((p) =>

View file

@ -8,7 +8,7 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
export default function ProfileSelectionPage() { export default function ProfileSelectionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, switchProfile } = useProfile(); const { profiles, switchProfile, updateProfile } = useProfile();
const [pinProfileId, setPinProfileId] = useState<string | null>(null); const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -23,8 +23,15 @@ export default function ProfileSelectionPage() {
} }
}; };
const handlePinSuccess = () => { const handlePinSuccess = async (rehashed?: string | null) => {
if (pinProfileId) { 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); switchProfile(pinProfileId);
setPinProfileId(null); setPinProfileId(null);
} }

View file

@ -34,6 +34,12 @@ export async function hashPin(pin: string): Promise<string> {
return invoke<string>("hash_pin", { pin }); return invoke<string>("hash_pin", { pin });
} }
export async function verifyPin(pin: string, storedHash: string): Promise<boolean> { export interface VerifyPinResult {
return invoke<boolean>("verify_pin", { pin, storedHash }); 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<VerifyPinResult> {
return invoke<VerifyPinResult>("verify_pin", { pin, storedHash });
} }