All files / services offlineCredentialStore.ts

97.43% Statements 38/39
88.88% Branches 16/18
100% Functions 8/8
97.36% Lines 37/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153                                                    1x 1x     1x                                     1x 1x 16x         4x 4x           3x 1x   2x 2x 100x   2x       6x                             1x 1x 1x               1x                         8x 8x 1x       7x 7x   1x     6x 1x   5x 1x   4x 1x           3x 3x       3x 1x     2x       1x    
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import argon2 from "react-native-argon2";
import type { MobileUser } from "../types/auth";
 
/**
 * Credential de reconnexion HORS-LIGNE (point D de l'audit offline).
 *
 * Objectif : permettre a un technicien deja connecte une fois EN LIGNE sur une
 * instance de rouvrir une session SANS reseau (typiquement : il s'est
 * deconnecte le soir, et le lendemain matin dans le camion sans signal il veut
 * acceder a ses interventions du jour).
 *
 * Securite (mot de passe) : on ne stocke JAMAIS le mot de passe. On garde un
 * hash **Argon2id** (etat de l'art, gagnant de la Password Hashing
 * Competition), avec les MEMES parametres que la PWA (PasswordHasher.php :
 * memory_cost=65536 / time_cost=4 / threads=1). L'ensemble vit en plus dans
 * SecureStore (Keychain iOS / Keystore Android, chiffre materiel). Les donnees
 * metier (non sensibles : releves CVC) restent en clair dans SQLite.
 *
 * /!\ react-native-argon2 est un module NATIF : tout changement ici exige un
 * REBUILD APK (npm run build:test/prod), il N'EST PAS livrable en OTA.
 *
 * Borne : TTL de 30 jours. Au-dela, reconnexion en ligne exigee.
 */
 
const KEY = "cc_offline_credential";
const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 jours
 
// Parametres Argon2id alignes sur la PWA (coolcare-app PasswordHasher.php).
const ARGON2_OPTIONS = {
  memory: 65536, // 64 Mio (minimum OWASP)
  iterations: 4,
  parallelism: 1,
  hashLength: 32,
  mode: "argon2id" as const,
  saltEncoding: "hex" as const,
};
 
type OfflineCredentialRecord = {
  baseUrl: string;
  email: string; // normalise en minuscules
  salt: string; // hex
  encodedHash: string; // $argon2id$v=19$m=65536,t=4,p=1$...
  user: MobileUser;
  savedAt: number; // epoch ms
};
 
async function randomSaltHex(): Promise<string> {
  const bytes = await Crypto.getRandomBytesAsync(16);
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}
 
async function hashPassword(password: string, saltHex: string): Promise<string> {
  const result = await argon2(password, saltHex, ARGON2_OPTIONS);
  return result.encodedHash;
}
 
// Comparaison a temps constant : evite une fuite par timing sur l'egalite du
// hash (defense en profondeur ; impact reel faible en local).
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) {
    return false;
  }
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0);
  }
  return diff === 0;
}
 
function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}
 
/**
 * Memorise le credential offline apres un login EN LIGNE reussi (on a alors le
 * mot de passe en clair, jamais persiste). Un seul enregistrement a la fois :
 * la derniere connexion ecrase la precedente (coherent avec le modele
 * single-session / owner du cache local).
 */
export async function saveOfflineCredential(
  baseUrl: string,
  email: string,
  password: string,
  user: MobileUser
): Promise<void> {
  const salt = await randomSaltHex();
  const encodedHash = await hashPassword(password, salt);
  const record: OfflineCredentialRecord = {
    baseUrl,
    email: normalizeEmail(email),
    salt,
    encodedHash,
    user,
    savedAt: Date.now(),
  };
  await SecureStore.setItemAsync(KEY, JSON.stringify(record));
}
 
/**
 * Verifie un couple email/mot de passe hors-ligne. Retourne l'utilisateur
 * memorise si tout concorde (meme instance, meme email, hash Argon2id valide,
 * non expire), sinon null. Ne leve jamais (best-effort cote appelant).
 */
export async function verifyOfflineCredential(
  baseUrl: string,
  email: string,
  password: string
): Promise<MobileUser | null> {
  const raw = await SecureStore.getItemAsync(KEY);
  if (!raw) {
    return null;
  }
 
  let rec: OfflineCredentialRecord;
  try {
    rec = JSON.parse(raw) as OfflineCredentialRecord;
  } catch {
    return null;
  }
 
  if (rec.baseUrl !== baseUrl) {
    return null;
  }
  if (rec.email !== normalizeEmail(email)) {
    return null;
  }
  if (!rec.savedAt || Date.now() - rec.savedAt > TTL_MS) {
    return null;
  }
 
  // Recompute avec le MEME sel + memes parametres : Argon2id est deterministe a
  // sel fixe, donc l'encodedHash recalcule doit egaler celui stocke.
  let candidate: string;
  try {
    candidate = await hashPassword(password, rec.salt);
  } catch {
    return null;
  }
  if (!timingSafeEqual(candidate, rec.encodedHash)) {
    return null;
  }
 
  return rec.user;
}
 
export async function clearOfflineCredential(): Promise<void> {
  await SecureStore.deleteItemAsync(KEY);
}