Aller au contenu

PD-284 — Agent Developer : seal-secure-storage (C12)

1. Périmètre

Attribut Valeur
Module seal-secure-storage (C12)
Fichier src/seal/secure-storage.ts
Owner agent-security (code contract), agent-developer (implémentation)
Dépendances C1 (seal-types)

2. Responsabilité

Persistance sécurisée des artefacts sensibles liés au scellement urgent (tsa_token_ref, clés de session SSE, tokens d'authentification) via expo-secure-store avec attribut iOS kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Purge obligatoire à logout, deleteAccount et fermeture session SSE. Purge proactive des résidus au démarrage du flux urgent (zero-residuel post-crash).

3. Interfaces exportées

// src/seal/secure-storage.ts

import * as SecureStore from 'expo-secure-store';
import type { SealId } from '../types/seal';

// ---------------------------------------------------------------------------
// Constantes
// ---------------------------------------------------------------------------

/** Préfixe pour toutes les clés seal en SecureStore */
const SEAL_KEY_PREFIX = 'com.probatiovault.seal';

/** Clés d'artefacts sensibles autorisées */
type SealSensitiveKey = 'tsa_token_ref' | 'sse_session_key' | 'auth_token';

/** Options SecureStore — non restaurable via iCloud (INV-284-10) */
const SECURE_STORE_OPTIONS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
};

/**
 * Liste exhaustive des clés sensibles connues.
 * Utilisée par purgeSealArtifacts et purgeStaleSealArtifacts
 * pour itérer sans dépendre d'un index secondaire.
 */
const ALL_SENSITIVE_KEYS: readonly SealSensitiveKey[] = [
  'tsa_token_ref',
  'sse_session_key',
  'auth_token',
] as const;

// ---------------------------------------------------------------------------
// Helpers internes
// ---------------------------------------------------------------------------

/**
 * Construit la clé SecureStore pour un artefact donné.
 * Format : `com.probatiovault.seal.<sealId>.<key>`
 */
function buildStoreKey(sealId: SealId, key: SealSensitiveKey): string {
  return `${SEAL_KEY_PREFIX}.${sealId}.${key}`;
}

// ---------------------------------------------------------------------------
// API publique
// ---------------------------------------------------------------------------

/**
 * Stocke un artefact sensible dans SecureStore.
 *
 * - Utilise kSecAttrAccessibleWhenUnlockedThisDeviceOnly (pas de restore iCloud)
 * - La valeur est une string (conformément à l'API SecureStore)
 *
 * @param sealId - Identifiant du scellement (branded SealId)
 * @param key    - Clé de l'artefact sensible (enum restreinte)
 * @param value  - Valeur à stocker
 *
 * INV-284-10 : artefacts sensibles en SecureStore uniquement
 */
export async function storeSensitiveArtifact(
  sealId: SealId,
  key: SealSensitiveKey,
  value: string,
): Promise<void> {
  const storeKey = buildStoreKey(sealId, key);
  await SecureStore.setItemAsync(storeKey, value, SECURE_STORE_OPTIONS);
}

/**
 * Récupère un artefact sensible depuis SecureStore.
 *
 * Lecture sans stockage en state (transitoire uniquement).
 * Retourne null si l'artefact n'existe pas.
 *
 * @param sealId - Identifiant du scellement
 * @param key    - Clé de l'artefact sensible
 * @returns Valeur ou null
 */
export async function getSensitiveArtifact(
  sealId: SealId,
  key: SealSensitiveKey,
): Promise<string | null> {
  const storeKey = buildStoreKey(sealId, key);
  return SecureStore.getItemAsync(storeKey, SECURE_STORE_OPTIONS);
}

/**
 * Purge tous les artefacts sensibles d'un scellement spécifique.
 *
 * Appelée à la fermeture de session SSE pour ce seal.
 * Itère sur ALL_SENSITIVE_KEYS et supprime chaque entrée.
 * Best-effort : ne throw pas si une clé n'existe pas.
 *
 * @param sealId - Identifiant du scellement à purger
 */
export async function purgeSealArtifacts(sealId: SealId): Promise<void> {
  for (const key of ALL_SENSITIVE_KEYS) {
    const storeKey = buildStoreKey(sealId, key);
    try {
      await SecureStore.deleteItemAsync(storeKey);
    } catch {
      // Best effort — la clé peut ne pas exister
    }
  }
}

/**
 * Purge TOUS les artefacts sensibles de scellement (tous seal_id).
 *
 * Appelée impérativement à :
 * - logout (déconnexion utilisateur)
 * - deleteAccount (suppression de compte)
 *
 * Stratégie : itère sur un registre local des sealId actifs/récents
 * stocké lui-même en SecureStore (clé `com.probatiovault.seal.__registry`).
 * Si le registre est corrompu ou absent, ne fait rien (pas de crash).
 */
export async function purgeAllSealData(): Promise<void> {
  const registryKey = `${SEAL_KEY_PREFIX}.__registry`;

  try {
    const registryRaw = await SecureStore.getItemAsync(
      registryKey,
      SECURE_STORE_OPTIONS,
    );

    if (registryRaw) {
      const sealIds: string[] = JSON.parse(registryRaw);

      for (const sealId of sealIds) {
        await purgeSealArtifacts(sealId as SealId);
      }
    }

    // Supprimer le registre lui-même
    await SecureStore.deleteItemAsync(registryKey);
  } catch {
    // Registre corrompu ou absent — best effort, pas de crash
  }
}

/**
 * Purge proactive des artefacts résiduels (zero-residuel post-crash).
 *
 * Appelée au démarrage de triggerUrgentSeal() (C7), AVANT le POST.
 * Si l'app a crashé (OOM, kill signal) lors d'un précédent scellement,
 * le finally block n'a pas pu exécuter la purge. Cette fonction
 * nettoie les résidus.
 *
 * Learning PD-283/PD-262 : finally ne suffit pas pour garantir
 * zero-residuel. Purge proactive au démarrage du flux.
 *
 * INV-284-10 : aucun artefact sensible ne doit persister
 * après un crash.
 */
export async function purgeStaleSealArtifacts(): Promise<void> {
  // Réutilise purgeAllSealData — même logique de nettoyage complet
  await purgeAllSealData();
}

// ---------------------------------------------------------------------------
// Registre interne des sealId (pour purgeAllSealData)
// ---------------------------------------------------------------------------

/**
 * Enregistre un sealId dans le registre SecureStore.
 *
 * Appelée par l'orchestrateur (C7) après réception du seal_id du POST.
 * Le registre permet à purgeAllSealData de retrouver tous les sealId
 * à purger sans avoir besoin d'un state Zustand (qui pourrait être
 * perdu en cas de crash).
 *
 * @param sealId - Identifiant du scellement à enregistrer
 */
export async function registerSealId(sealId: SealId): Promise<void> {
  const registryKey = `${SEAL_KEY_PREFIX}.__registry`;

  try {
    const registryRaw = await SecureStore.getItemAsync(
      registryKey,
      SECURE_STORE_OPTIONS,
    );

    const sealIds: string[] = registryRaw ? JSON.parse(registryRaw) : [];

    if (!sealIds.includes(sealId)) {
      sealIds.push(sealId);
      await SecureStore.setItemAsync(
        registryKey,
        JSON.stringify(sealIds),
        SECURE_STORE_OPTIONS,
      );
    }
  } catch {
    // Best effort — en cas d'erreur, purgeAllSealData sera no-op
    // pour ce sealId mais purgeSealArtifacts reste appelable directement
  }
}

// ---------------------------------------------------------------------------
// Exports pour tests
// ---------------------------------------------------------------------------

/** @internal — Exposé uniquement pour les tests */
export const __test__ = {
  SEAL_KEY_PREFIX,
  ALL_SENSITIVE_KEYS,
  SECURE_STORE_OPTIONS,
  buildStoreKey,
};

4. Mapping invariants → mécanismes

Invariant Mécanisme Vérifiable
INV-284-10 SECURE_STORE_OPTIONS.keychainAccessible = WHEN_UNLOCKED_THIS_DEVICE_ONLY. Type SealSensitiveKey restreint aux 3 artefacts sensibles. Aucun AsyncStorage ni state Zustand persisté. Review code : aucun import AsyncStorage dans le fichier
INV-284-10 (purge logout) purgeAllSealData() appelée par le hook logout existant Test d'intégration : après appel, aucune clé com.probatiovault.seal.* dans SecureStore
INV-284-10 (purge deleteAccount) purgeAllSealData() appelée par le handler deleteAccount Idem
INV-284-10 (purge SSE close) purgeSealArtifacts(sealId) appelée par le cleanup SSE dans C7/C14 Test unitaire : après appel, les 3 clés du sealId sont absentes
INV-284-10 (zero-residuel) purgeStaleSealArtifacts() appelée au démarrage de triggerUrgentSeal() AVANT le POST Test unitaire : cleanup exécuté avant toute opération réseau
INV-284-11 hash_document, merkle_root, blockchain_tx_hash ne transitent JAMAIS par ce module. Pas de méthode pour les stocker. Type SealSensitiveKey les exclut par construction. Review code : ces chaînes absentes du fichier
Attribut iCloud keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY dans SECURE_STORE_OPTIONS — pas de AFTER_FIRST_UNLOCK ni ALWAYS Test unitaire : vérifier la constante

5. Mapping tests → mécanismes

Test ID Mécanisme Observation
TC-INV-09 storeSensitiveArtifact utilise SecureStore exclusivement. Inspection du mock SecureStore après parcours complet → clés com.probatiovault.seal.<id>.tsa_token_ref présentes. Inspection du mock AsyncStorage → aucune clé seal. Sec
TC-INV-11 Review statique : SealSensitiveKey ne contient pas hash_document, merkle_root, blockchain_tx_hash. Le type TypeScript empêche l'appel storeSensitiveArtifact(id, 'hash_document', ...) à la compilation. Unit
TC-NOM-12 getSensitiveArtifact retourne la valeur transitoire pour affichage expert sans stockage en state Zustand. Unit

6. Décisions architecturales

architectural_decisions:
  - decision: "Registre des sealId en SecureStore (pas en Zustand)"
    rationale: "Un crash app perd le state Zustand. Le registre SecureStore survit aux crashes et permet purgeAllSealData de retrouver tous les sealId à nettoyer."
    alternatives_considered:
      - "Zustand persisté via AsyncStorage (violait INV-284-10)"
      - "Pas de registre (purge manuelle par sealId uniquement)"
    trade_offs:
      - "Pro: zero-residuel garanti même après crash"
      - "Con: une écriture SecureStore additionnelle par scellement"

  - decision: "Type SealSensitiveKey comme union littérale fermée"
    rationale: "Empêche par construction de stocker des artefacts publics (INV-284-11) ou des clés arbitraires dans SecureStore. Tout ajout futur nécessite une modification explicite du type."
    alternatives_considered:
      - "String libre avec validation runtime"
      - "Enum TypeScript"
    trade_offs:
      - "Pro: sécurité par construction (compile-time)"
      - "Con: moins flexible si nouveaux artefacts sensibles"

7. Tests contractuels proposés

TC-INV-09 — Artefacts sensibles en SecureStore uniquement

// TC-INV-09 — src/seal/__tests__/secure-storage.test.ts
import {
  storeSensitiveArtifact,
  getSensitiveArtifact,
  purgeSealArtifacts,
  __test__,
} from '../secure-storage';
import * as SecureStore from 'expo-secure-store';
import type { SealId } from '../../types/seal';

jest.mock('expo-secure-store');

const SEAL_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' as SealId;

describe('seal-secure-storage', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  // TC-INV-09: storeSensitiveArtifact uses SecureStore
  it('stores tsa_token_ref in SecureStore with correct options', async () => {
    await storeSensitiveArtifact(SEAL_ID, 'tsa_token_ref', 'TSA:REF:123');

    expect(SecureStore.setItemAsync).toHaveBeenCalledWith(
      `com.probatiovault.seal.${SEAL_ID}.tsa_token_ref`,
      'TSA:REF:123',
      expect.objectContaining({
        keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      }),
    );
  });

  // TC-INV-09: getSensitiveArtifact reads from SecureStore
  it('retrieves artifact from SecureStore without persisting in state', async () => {
    (SecureStore.getItemAsync as jest.Mock).mockResolvedValue('TSA:REF:123');

    const result = await getSensitiveArtifact(SEAL_ID, 'tsa_token_ref');

    expect(result).toBe('TSA:REF:123');
    expect(SecureStore.getItemAsync).toHaveBeenCalledWith(
      `com.probatiovault.seal.${SEAL_ID}.tsa_token_ref`,
      expect.objectContaining({
        keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      }),
    );
  });

  // TC-INV-09: purgeSealArtifacts deletes all sensitive keys
  it('purges all 3 sensitive keys for a given sealId', async () => {
    await purgeSealArtifacts(SEAL_ID);

    expect(SecureStore.deleteItemAsync).toHaveBeenCalledTimes(3);
    for (const key of __test__.ALL_SENSITIVE_KEYS) {
      expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
        `com.probatiovault.seal.${SEAL_ID}.${key}`,
      );
    }
  });

  // INV-284-10: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  it('uses WHEN_UNLOCKED_THIS_DEVICE_ONLY (no iCloud restore)', () => {
    expect(__test__.SECURE_STORE_OPTIONS.keychainAccessible).toBe(
      SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    );
  });
});

TC-INV-11 — Artefacts publics non stockés ici

// TC-INV-11 — vérification compile-time
// Les lignes suivantes NE COMPILENT PAS — preuve que le type
// SealSensitiveKey exclut les artefacts publics :
//
// storeSensitiveArtifact(sealId, 'hash_document', '...');     // TS error
// storeSensitiveArtifact(sealId, 'merkle_root', '...');       // TS error
// storeSensitiveArtifact(sealId, 'blockchain_tx_hash', '...'); // TS error
//
// Test runtime de sécurité supplémentaire :
it('ALL_SENSITIVE_KEYS does not contain public artifact keys', () => {
  const publicKeys = ['hash_document', 'merkle_root', 'blockchain_tx_hash'];
  for (const pk of publicKeys) {
    expect(__test__.ALL_SENSITIVE_KEYS).not.toContain(pk);
  }
});

Purge proactive (learning PD-283/PD-262)

import { purgeStaleSealArtifacts, registerSealId } from '../secure-storage';
import * as SecureStore from 'expo-secure-store';
import type { SealId } from '../../types/seal';

jest.mock('expo-secure-store');

it('purgeStaleSealArtifacts cleans up residual artifacts from previous crash', async () => {
  const STALE_ID = 'stale-0000-0000-0000-000000000001' as SealId;

  // Simulate a registry with a stale sealId from a crashed session
  (SecureStore.getItemAsync as jest.Mock).mockImplementation((key: string) => {
    if (key === 'com.probatiovault.seal.__registry') {
      return Promise.resolve(JSON.stringify([STALE_ID]));
    }
    return Promise.resolve(null);
  });

  await purgeStaleSealArtifacts();

  // All 3 sensitive keys for the stale sealId should be deleted
  expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
    `com.probatiovault.seal.${STALE_ID}.tsa_token_ref`,
  );
  expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
    `com.probatiovault.seal.${STALE_ID}.sse_session_key`,
  );
  expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
    `com.probatiovault.seal.${STALE_ID}.auth_token`,
  );
  // Registry itself should be deleted
  expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(
    'com.probatiovault.seal.__registry',
  );
});

8. Points d'intégration (hors périmètre C12, signalés)

Composant Action requise Fichier
C7 (orchestrator) Appeler purgeStaleSealArtifacts() au démarrage de triggerUrgentSeal(), AVANT le POST src/seal/orchestrator.ts
C7 (orchestrator) Appeler registerSealId(sealId) après réception du seal_id du POST src/seal/orchestrator.ts
C7 (orchestrator) Appeler storeSensitiveArtifact(sealId, 'tsa_token_ref', value) à la réception de l'événement TSA_SEALED src/seal/orchestrator.ts
C14 (SealDetailScreen) Appeler purgeSealArtifacts(sealId) dans le useEffect cleanup (fermeture session SSE) src/screens/vault/SealDetailScreen.tsx
Auth store (logout) Appeler purgeAllSealData() dans le handler logout src/store/useAuthStore.ts
Auth store (deleteAccount) Appeler purgeAllSealData() dans le handler deleteAccount src/store/useAuthStore.ts

9. Hypothèses

ID Hypothèse Impact si faux
HT-04 expo-secure-store supporte WHEN_UNLOCKED_THIS_DEVICE_ONLY Artefacts restaurés via iCloud → fallback react-native-keychain
expo-secure-store accepte des clés > 100 chars (préfixe + UUID + suffixe ≈ 80 chars) Troncature silencieuse → clés non retrouvables
Le registre sealId tient dans la limite ~2KB de SecureStore (liste JSON de UUIDs, max ~50 scellements simultanés) Overflow → purgeAllSealData incomplet. Mitigation : le registre ne devrait jamais dépasser 5-10 entrées en usage normal.

10. Matrice de couverture

Test-ID Fichier de test
TC-INV-09 src/seal/__tests__/secure-storage.test.ts
TC-INV-11 src/seal/__tests__/secure-storage.test.ts
TC-NOM-12 (partiel) src/seal/__tests__/secure-storage.test.ts — getSensitiveArtifact