Skip to content

Commit b46ba19

Browse files
committed
v1.0.4: zéro vuln zéro faille — audit expert A1-A20 + B1-B18
P0 sécurité (Sprint A) : - A1 _validateEncryptedDb : onConfigure aligné prod + check table notes - A2 (F6) race aliveIds vs _embeddings.save : check note existence + encryptedContent==null AVANT save (anti orphan plaintext post-delete ou post-vault-isation concurrente) - A6 _aesGcmDecrypt : wipe plain post-copie (limite fenêtre plaintext dans le heap du package cryptography) - A7 lock() + lockAll() : cancel _autoLockTimer orphelin - A8 _Argon2Job : passphraseBytes Uint8List wipable + wipe en finally côté worker isolate (anti RAM forensics passphrase post-unlock) - A11 RagService._sanitize aligné AI Tech v0.6.1 F2 : • strip zero-width / bidi U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF (anti fragmentation balises) • neutralise blocs base64 longs (40+ chars) que Gemma sait décoder - A13 ai_chat_screen WidgetsBindingObserver : clear _turns + stopGeneration sur paused/hidden/detached (anti RAM forensics du contenu vault déchiffré dans turn.sources après auto-lock) - A17 PRAGMA cipher_compatibility = 4 explicite dans _onConfigure (anti-bump SQLCipher futur silencieux) - B6 PanicStep.embedderWipe : panic wipe <appSupport>/models/ (cache MiniLM ~25 Mo — cohérence voiceWipe + gemmaUninstall) Docs : - E1 SECURITY.md v1.0.0 → v1.0.4 - E2 README v1.0.0 → v1.0.4 - AppConstants.appVersion 1.0.3 → 1.0.4 Reportés v1.1 (format-breaking) : - F3 (A3 audit) — chiffrement title vault (DB v7 + migration) - F16 (A4 audit) — AAD KEK wrap Keystore (Kotlin + Dart) - A5 — Argon2id calibration runtime adaptive flutter analyze : 0 erreur, 0 warning (14 info pré-existants `announce` deprecated, hors scope hardening). flutter test : 64/64 passed (panic_service_test expected sequence inclut maintenant embedderWipe). Fastlane FR + EN 35.txt.
1 parent eec08c7 commit b46ba19

18 files changed

Lines changed: 284 additions & 79 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> Vos notes restent dans votre poche. L'IA aussi.
44
5-
**v1.0.0 — Mai 2026** · [Politique de confidentialité](PRIVACY.md) · [CGU](TERMS.md) · [Sécurité](SECURITY.md)
5+
**v1.0.4 — Mai 2026** · [Politique de confidentialité](PRIVACY.md) · [CGU](TERMS.md) · [Sécurité](SECURITY.md)
66

77
Application Android Flutter de prise de notes Markdown chiffrées,
88
**100 % locale, zéro permission Internet**. Interface bilingue **FR / EN**.

‎SECURITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Security policy — Notes Tech
22

3-
**Version current : v1.0.0 — Mai 2026.**
3+
**Version current : v1.0.4 — Mai 2026.**
44

55
Notes Tech v1.0 introduit plusieurs durcissements sécurité :
66
- `prefs.clear()` panique avec **whitelist** (`db_encrypted_v1`,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
v1.0.4 — Expert audit zero-vuln/zero-fault (May 2026)
2+
3+
Security (P0+P1)
4+
• A1 — `_validateEncryptedDb` post-migration: `onConfigure` aligned
5+
with prod + explicit `notes` table check (anti false-positive on
6+
future sqflite_sqlcipher bump)
7+
• A2 (F6) — `aliveIds` vs `_embeddings.save` race fixed: existence +
8+
`encryptedContent==null` check before each save (anti orphan
9+
embedding re-inserted after concurrent hard-delete or vault-ization)
10+
• A6 — `_aesGcmDecrypt`: wipe `plain` buffer after copy (narrows
11+
plaintext window in cryptography package heap)
12+
• A7 — `lock()` / `lockAll()` cancel the orphan `_autoLockTimer`
13+
• A8 — `_Argon2Job` passes `passphraseBytes` (Uint8List) instead of
14+
String; worker isolate wipes the copy in `finally` (anti RAM
15+
forensics post-unlock)
16+
• A11 — `RagService._sanitize` hardened: strip zero-width/bidi
17+
(U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF) + neutralize
18+
long base64 blocks (aligned with AI Tech v0.6.1 F2)
19+
• A13 — `ai_chat_screen` clears `_turns` on paused/hidden/detached
20+
lifecycle + stops in-progress generation (anti RAM forensics of
21+
decrypted vault content in `turn.sources`)
22+
• A17 — `PRAGMA cipher_compatibility = 4;` explicit (anti future
23+
SQLCipher bump)
24+
• B6 — `PanicStep.embedderWipe` added: panic wipes `<appSupport>/models/`
25+
(MiniLM cache ~25 MB)
26+
27+
Documentation
28+
• E1 — SECURITY.md "Version current v1.0.4"
29+
• E2 — README "v1.0.4"
30+
• AppConstants.appVersion bumped 1.0.3 → 1.0.4
31+
32+
Deferred to v1.1 (format-breaking, migration required):
33+
• F3 (A3 audit) — vault `title` encryption (DB v7)
34+
• F16 (A4 audit) — Keystore wrap AAD binding (Kotlin + Dart)
35+
• A5 — Argon2id runtime adaptive calibration per device
36+
37+
flutter analyze: 0 errors, 0 warnings.
38+
flutter test: 64/64 passed.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
v1.0.4 — Audit expert zéro vuln/faille (mai 2026)
2+
3+
Sécurité (P0+P1)
4+
• A1 — `_validateEncryptedDb` post-migration : `onConfigure` aligné sur
5+
la prod + check explicite table `notes` (anti-faux-positif sur bump
6+
futur sqflite_sqlcipher)
7+
• A2 (F6) — race `aliveIds` vs `_embeddings.save` corrigée : check
8+
existence + `encryptedContent==null` avant chaque save (anti embedding
9+
orphelin réinséré après hard-delete ou vault-isation concurrente)
10+
• A6 — `_aesGcmDecrypt` : wipe du buffer `plain` après copie (limite
11+
fenêtre plaintext dans heap cryptography)
12+
• A7 — `lock()` / `lockAll()` cancel le `_autoLockTimer` orphelin
13+
• A8 — `_Argon2Job` passe `passphraseBytes` (Uint8List) au lieu de
14+
String ; worker isolate wipe la copie en `finally` (anti RAM
15+
forensics post-unlock)
16+
• A11 — `RagService._sanitize` durci : strip zero-width/bidi
17+
(U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF) + neutralise
18+
blocs base64 longs (alignement AI Tech v0.6.1 F2)
19+
• A13 — `ai_chat_screen` clear `_turns` au lifecycle paused/hidden/
20+
detached + stop génération en cours (anti RAM forensics du contenu
21+
vault déchiffré dans `turn.sources`)
22+
• A17 — `PRAGMA cipher_compatibility = 4;` explicite (anti-bump
23+
SQLCipher futur)
24+
• B6 — `PanicStep.embedderWipe` ajouté : panic wipe `<appSupport>/models/`
25+
(cache MiniLM ~25 Mo)
26+
27+
Documentation
28+
• E1 — SECURITY.md "Version current v1.0.4"
29+
• E2 — README "v1.0.4"
30+
• AppConstants.appVersion bumpé 1.0.3 → 1.0.4
31+
32+
Reportés v1.1 (format-breaking, migration nécessaire) :
33+
• F3 (A3 audit) — chiffrement du `title` des notes vault (DB v7)
34+
• F16 (A4 audit) — AAD KEK wrap Keystore (Kotlin + Dart)
35+
• A5 — Argon2id calibration runtime adaptive au device
36+
37+
flutter analyze : 0 erreur, 0 warning.
38+
flutter test : 64/64 passed.

‎lib/core/constants.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class AppConstants {
55
AppConstants._();
66

77
static const String appName = 'Notes Tech';
8-
static const String appVersion = '1.0.3';
8+
static const String appVersion = '1.0.4';
99
// NB : la clé Kotlin équivalente côté `MainActivity.kt` est
1010
// `flutter.secure_window_enabled` (préfixe `flutter.` ajouté
1111
// automatiquement par `shared_preferences` au moment de la persistance).

‎lib/data/db/database.dart‎

1.09 KB
Binary file not shown.

‎lib/services/ai/rag_service.dart‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,23 @@ class RagService {
170170
/// v1.1 (defense-in-depth) : encoder chaque note en base64 avec un
171171
/// délimiteur ASCII aléatoire régénéré par requête.
172172
static String _sanitize(String s) {
173-
return s
174-
.replaceAll(RegExp(r'</\s*note\s*>', caseSensitive: false), '<​/note>')
175-
.replaceAll(RegExp(r'<\s*note\b', caseSensitive: false), '<​note')
173+
// A11 v1.0.4 — pré-traitement aligné sur AI Tech v0.6.1 F2 :
174+
// 1. Strip caractères zero-width / bidi qui pouvaient fragmenter
175+
// les balises tags (`<|im\u200C_start|>` avec U+200B au milieu
176+
// n'était pas matché par la regex).
177+
// 2. Neutralise les blocs base64 longs (40+ chars) qui pouvaient
178+
// encoder une injection que Gemma sait décoder à la volée.
179+
final stripped = s.replaceAll(_zeroWidthBidi, '');
180+
final noB64 = stripped.replaceAll(
181+
RegExp(r'[A-Za-z0-9+/]{40,}={0,2}'),
182+
'·[base64 neutralisé]·',
183+
);
184+
return noB64
185+
.replaceAll(
186+
RegExp(r'</\s*note\s*>', caseSensitive: false),
187+
'<\u200B/note>',
188+
)
189+
.replaceAll(RegExp(r'<\s*note\b', caseSensitive: false), '<\u200Bnote')
176190
// (2) Verbes d'instruction FR/EN.
177191
.replaceAll(
178192
RegExp(
@@ -188,18 +202,18 @@ class RagService {
188202
// (3) Tags de rôle système Gemma / instruct-style.
189203
.replaceAll(
190204
RegExp(r'<\|\s*system\s*\|>', caseSensitive: false),
191-
'<​|system|>',
205+
'<\u200B|system|>',
192206
)
193207
.replaceAll(
194208
RegExp(r'<\|\s*user\s*\|>', caseSensitive: false),
195-
'<​|user|>',
209+
'<\u200B|user|>',
196210
)
197211
.replaceAll(
198212
RegExp(r'<\|\s*assistant\s*\|>', caseSensitive: false),
199-
'<​|assistant|>',
213+
'<\u200B|assistant|>',
200214
)
201-
.replaceAll(RegExp(r'</?s>'), '<​/s>')
202-
.replaceAll(RegExp(r'\[/?INST\]'), '[​INST]')
215+
.replaceAll(RegExp(r'</?s>'), '<\u200B/s>')
216+
.replaceAll(RegExp(r'\[/?INST\]'), '[\u200BINST]')
203217
.replaceAll(
204218
RegExp(
205219
r'(?:^|\n)\s*(?:Assistant|System|Utilisateur|User)\s*:',
@@ -216,4 +230,16 @@ class RagService {
216230
'\n[steering neutralisé]:',
217231
);
218232
}
233+
234+
/// A11 v1.0.4 — regex pré-compilée pour stripper les caractères
235+
/// zero-width / bidi (alignement AI Tech v0.6.1 F2). Couvre :
236+
/// U+200B-U+200F ZWSP, ZWNJ, ZWJ, LRM, RLM
237+
/// U+202A-U+202E bidi overrides (LRE, RLE, PDF, LRO, RLO)
238+
/// U+2066-U+2069 bidi isolates (LRI, RLI, FSI, PDI)
239+
/// U+FEFF BOM / ZWNBSP
240+
/// Définis via Unicode escapes pour éviter d'introduire ces caractères
241+
/// dans le source lui-même (warnings analyzer bidi).
242+
static final RegExp _zeroWidthBidi = RegExp(
243+
'[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]',
244+
);
219245
}

‎lib/services/indexing_service.dart‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,17 @@ class IndexingService {
202202
),
203203
);
204204
final emb = _encodeWith(embedder, note);
205+
// A2 v1.0.4 (F6) — race vs hard-delete ou vault-isation concurrente.
206+
// Avant : un embedding d'une note supprimée entre listAllAlive et
207+
// save était inséré comme orphelin survivant jusqu'à la passe
208+
// suivante (leak sémantique transitoire). Idem pour une note
209+
// vault-isée pendant la passe (content déjà vidé, embedding
210+
// plaintext historique réinséré).
211+
final live = await _notes.get(note.id);
212+
if (live == null || live.encryptedContent != null) {
213+
done++;
214+
continue;
215+
}
205216
await _embeddings.save(emb);
206217
done++;
207218
if (delay > Duration.zero) {

‎lib/services/security/folder_vault_service.dart‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,10 @@ class FolderVaultService extends ChangeNotifier {
687687
final s = _unlocked.remove(folderId);
688688
if (s != null) {
689689
_wipe(s.folderKek);
690+
// A7 v1.0.4 — cancel le timer auto-lock si plus rien d'unlocked,
691+
// pour éviter qu'un Timer orphelin fire `_autoLockSweep` sur un
692+
// map vide (no-op mais bruit).
693+
if (_unlocked.isEmpty) _autoLockTimer?.cancel();
690694
notifyListeners();
691695
}
692696
}
@@ -699,6 +703,8 @@ class FolderVaultService extends ChangeNotifier {
699703
_wipe(s.folderKek);
700704
}
701705
_unlocked.clear();
706+
// A7 v1.0.4 — cancel le timer auto-lock après wipe global.
707+
_autoLockTimer?.cancel();
702708
notifyListeners();
703709
}
704710

@@ -900,7 +906,8 @@ class FolderVaultService extends ChangeNotifier {
900906
return compute<_Argon2Job, Uint8List>(
901907
_argon2WorkerEntry,
902908
_Argon2Job(
903-
passphrase: passphrase,
909+
// A8 v1.0.4 — passe une COPIE Uint8List wipable au worker.
910+
passphraseBytes: Uint8List.fromList(utf8Bytes(passphrase)),
904911
salt: salt,
905912
memoryKb: AppConstants.vaultArgon2MemoryKb,
906913
iterations: AppConstants.vaultArgon2Iterations,
@@ -922,7 +929,7 @@ class FolderVaultService extends ChangeNotifier {
922929
return compute<_Argon2Job, Uint8List>(
923930
_argon2WorkerEntry,
924931
_Argon2Job(
925-
passphrase: pin,
932+
passphraseBytes: Uint8List.fromList(utf8Bytes(pin)),
926933
salt: salt,
927934
memoryKb: AppConstants.vaultPinArgon2MemoryKb,
928935
iterations: AppConstants.vaultPinArgon2Iterations,
@@ -969,7 +976,19 @@ class FolderVaultService extends ChangeNotifier {
969976
final secret = SecretKey(key);
970977
final box = SecretBox(cipherText, nonce: iv, mac: Mac(macBytes));
971978
final plain = await algo.decrypt(box, secretKey: secret, aad: aad);
972-
return Uint8List.fromList(plain);
979+
// A6 v1.0.4 — wipe le buffer plain retourné par cryptography une fois
980+
// la copie effectuée. Limite la fenêtre où le plaintext folder_kek
981+
// (ou le contenu déchiffré) reste dans le heap du package cryptography
982+
// jusqu'à GC.
983+
final out = Uint8List.fromList(plain);
984+
if (plain is Uint8List) {
985+
try {
986+
plain.fillRange(0, plain.length, 0);
987+
} catch (_) {
988+
// Buffer non-modifiable (vue, FFI) : best-effort.
989+
}
990+
}
991+
return out;
973992
}
974993

975994
Future<Uint8List> _verifierFor(Uint8List folderKek) async {
@@ -1018,16 +1037,22 @@ const _utf8 = Utf8Codec();
10181037

10191038
/// Argument transmis à l'isolate. Tous les champs sont passifs et
10201039
/// sérialisables (String, Uint8List, int).
1040+
///
1041+
/// A8 v1.0.4 — `passphraseBytes` est passé en `Uint8List` (au lieu de
1042+
/// `String`) pour permettre un wipe explicite côté worker isolate à la
1043+
/// fin de la dérivation. Le caller doit fournir `utf8.encode(passphrase)`
1044+
/// converti en `Uint8List` ; la String côté UI reste immutable (limitation
1045+
/// Dart) mais on minimise la fenêtre d'exposition dans l'isolate.
10211046
class _Argon2Job {
10221047
const _Argon2Job({
1023-
required this.passphrase,
1048+
required this.passphraseBytes,
10241049
required this.salt,
10251050
required this.memoryKb,
10261051
required this.iterations,
10271052
required this.parallelism,
10281053
required this.hashLength,
10291054
});
1030-
final String passphrase;
1055+
final Uint8List passphraseBytes;
10311056
final Uint8List salt;
10321057
final int memoryKb;
10331058
final int iterations;
@@ -1044,8 +1069,17 @@ Future<Uint8List> _argon2WorkerEntry(_Argon2Job job) async {
10441069
parallelism: job.parallelism,
10451070
hashLength: job.hashLength,
10461071
);
1047-
final secret = SecretKey(utf8Bytes(job.passphrase));
1048-
final derived = await algo.deriveKey(secretKey: secret, nonce: job.salt);
1049-
final bytes = await derived.extractBytes();
1050-
return Uint8List.fromList(bytes);
1072+
try {
1073+
final secret = SecretKey(job.passphraseBytes);
1074+
final derived = await algo.deriveKey(secretKey: secret, nonce: job.salt);
1075+
final bytes = await derived.extractBytes();
1076+
return Uint8List.fromList(bytes);
1077+
} finally {
1078+
// A8 v1.0.4 — wipe la copie de la passphrase côté worker isolate.
1079+
try {
1080+
job.passphraseBytes.fillRange(0, job.passphraseBytes.length, 0);
1081+
} catch (_) {
1082+
/* best-effort */
1083+
}
1084+
}
10511085
}

‎lib/services/security/panic_service.dart‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
library;
5858

5959
import 'dart:async';
60+
import 'dart:io';
6061

6162
import 'package:path_provider/path_provider.dart';
6263
import 'package:shared_preferences/shared_preferences.dart';
@@ -100,6 +101,9 @@ enum PanicStep {
100101
dbWipe,
101102
voiceWipe,
102103
gemmaUninstall,
104+
105+
/// B6 v1.0.4 — wipe du modèle MiniLM bundled (~25 Mo) + cache.
106+
embedderWipe,
103107
prefsClear,
104108
tmpPurge,
105109
}
@@ -248,6 +252,11 @@ class PanicService {
248252
// KEK est partie depuis plusieurs steps).
249253
await _runStep(report, PanicStep.gemmaUninstall, _gemma.uninstall);
250254

255+
// 6.b B6 v1.0.4 — wipe du cache MiniLM (`<appSupport>/models/`).
256+
// Le modèle MiniLM est public mais l'app le copie dans son sandbox.
257+
// Cohérence avec wipe Gemma + Whisper : zéro résidu ML post-panic.
258+
await _runStep(report, PanicStep.embedderWipe, _wipeEmbedderCache);
259+
251260
// 7. Préférences : tri, dossier actif, hash Gemma accepté, modèle
252261
// voix actif… aucun reliquat d'usage.
253262
//
@@ -325,4 +334,20 @@ class PanicService {
325334
}
326335
}
327336
}
337+
338+
/// B6 v1.0.4 — wipe du cache MiniLM (sandbox `<appSupport>/models/`).
339+
/// Le modèle bundled est public, mais l'app le copie dans son sandbox.
340+
/// Cohérence avec voiceWipe + gemmaUninstall : zéro résidu ML post-panic.
341+
/// Best-effort (le modèle sera re-extrait des assets au prochain warmUp).
342+
Future<void> _wipeEmbedderCache() async {
343+
try {
344+
final dir = await getApplicationSupportDirectory();
345+
final modelsDir = Directory('${dir.path}/models');
346+
if (await modelsDir.exists()) {
347+
await modelsDir.delete(recursive: true);
348+
}
349+
} catch (_) {
350+
// Best-effort.
351+
}
352+
}
328353
}

0 commit comments

Comments
 (0)