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 |