Skip to content

Commit 7b6bae9

Browse files
committed
merge: correctifs securite SecretBytes (wipe silencieux, fromHex permissif)
2 parents dc6e71a + f486348 commit 7b6bae9

2 files changed

Lines changed: 128 additions & 6 deletions

File tree

‎lib/src/security/secret_bytes.dart‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,24 @@ abstract final class SecretBytes {
4242
/// de portée d'une clé pour limiter la fenêtre où elle est en clair en RAM.
4343
///
4444
/// Sémantique identique à un `bytes.fillRange(0, bytes.length, 0)`.
45-
static void wipe(Uint8List bytes) {
45+
///
46+
/// **Retourne `true` si l'effacement a réellement eu lieu, `false` si le
47+
/// buffer était non modifiable.** Avant, l'échec était avalé en silence :
48+
/// un appelant écrivant `SecretBytes.wipe(finalKey)` croyait le matériel de
49+
/// clé zéroïsé alors qu'une `UnmodifiableUint8ListView` — retournée par
50+
/// certaines implémentations FFI de `cryptography_flutter` — laissait la clé
51+
/// intacte en RAM jusqu'au GC. Le résultat doit être vérifié partout où
52+
/// l'effacement fait partie du modèle de menace ; quand il vaut `false`, la
53+
/// seule parade est de ne pas conserver de référence au buffer.
54+
@pragma('vm:prefer-inline')
55+
static bool wipe(Uint8List bytes) {
4656
try {
4757
bytes.fillRange(0, bytes.length, 0);
58+
return true;
4859
} catch (_) {
49-
// Buffer non-modifiable (UnmodifiableUint8ListView retourné par
50-
// certaines impls FFI de `cryptography_flutter`). Best-effort : on ne
51-
// peut pas effacer une vue read-only — la mémoire sera libérée par GC.
60+
// Buffer non-modifiable : on ne peut pas effacer une vue read-only —
61+
// la mémoire sera libérée par GC, à une date non déterministe.
62+
return false;
5263
}
5364
}
5465

@@ -93,12 +104,39 @@ abstract final class SecretBytes {
93104
}
94105
final out = Uint8List(hex.length ~/ 2);
95106
for (var i = 0; i < out.length; i++) {
96-
final byte = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
97-
out[i] = byte;
107+
// `int.parse(..., radix: 16)` accepte un SIGNE et des espaces de tête :
108+
// "+a" rendait 10, " a" rendait 10, et "-a" rendait -10 — silencieusement
109+
// tronqué à l'octet 246 par `Uint8List`. La documentation promet pourtant
110+
// une FormatException sur entrée invalide. On valide donc chaque demi-
111+
// octet nous-mêmes avant de parser.
112+
final hi = _hexDigit(hex.codeUnitAt(i * 2));
113+
final lo = _hexDigit(hex.codeUnitAt(i * 2 + 1));
114+
if (hi < 0 || lo < 0) {
115+
throw FormatException('invalid hex byte', hex, i * 2);
116+
}
117+
out[i] = (hi << 4) | lo;
98118
}
99119
return out;
100120
}
101121

122+
/// Valeur d'un chiffre hexadécimal, ou -1 si le code unité n'en est pas un.
123+
/// Accepte `0-9`, `a-f`, `A-F` et RIEN d'autre — pas de signe, pas d'espace.
124+
static int _hexDigit(int codeUnit) {
125+
// 0-9
126+
if (codeUnit >= 0x30 && codeUnit <= 0x39) {
127+
return codeUnit - 0x30;
128+
}
129+
// a-f
130+
if (codeUnit >= 0x61 && codeUnit <= 0x66) {
131+
return codeUnit - 0x61 + 10;
132+
}
133+
// A-F
134+
if (codeUnit >= 0x41 && codeUnit <= 0x46) {
135+
return codeUnit - 0x41 + 10;
136+
}
137+
return -1;
138+
}
139+
102140
/// Comparaison en temps constant de deux strings hex (sortie SHA-256, etc.).
103141
/// Lance [ArgumentError] si tailles différentes — contrairement à
104142
/// [constantTimeEq] sur bytes (qui retourne `false`), ici on sait que les

‎test/secret_bytes_test.dart‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:files_tech_core/files_tech_core.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
void main() {
7+
group('SecretBytes.fromHex — rejet des entrées non hexadécimales', () {
8+
// Régression : `int.parse(..., radix: 16)` accepte un signe de tête et des
9+
// espaces. `fromHex('-a')` rendait donc l'octet 246 (troncature deux
10+
// compléments par Uint8List) au lieu de lever, alors que la doc promet une
11+
// FormatException. Ces cas verrouillent le contrat.
12+
for (final bad in const ['+a', '-a', ' a', 'a ', '+0', '-0', 'g0', '0g']) {
13+
test('rejette "$bad"', () {
14+
expect(() => SecretBytes.fromHex(bad), throwsFormatException);
15+
});
16+
}
17+
18+
test('rejette une longueur impaire', () {
19+
expect(() => SecretBytes.fromHex('abc'), throwsFormatException);
20+
});
21+
22+
test('accepte le hex valide, casses mélangées', () {
23+
expect(SecretBytes.fromHex('00ffAb'), [0x00, 0xff, 0xab]);
24+
});
25+
26+
test('chaîne vide → buffer vide', () {
27+
expect(SecretBytes.fromHex(''), isEmpty);
28+
});
29+
30+
test('round-trip toHex ∘ fromHex est l\'identité', () {
31+
final src = Uint8List.fromList([0, 1, 15, 16, 127, 128, 254, 255]);
32+
expect(SecretBytes.fromHex(SecretBytes.toHex(src)), src);
33+
});
34+
});
35+
36+
group('SecretBytes.wipe — signale l\'échec au lieu de l\'avaler', () {
37+
test('buffer modifiable : zéroïse et retourne true', () {
38+
final b = Uint8List.fromList([1, 2, 3, 4]);
39+
expect(SecretBytes.wipe(b), isTrue);
40+
expect(b, everyElement(0));
41+
});
42+
43+
test('vue non modifiable : retourne false sans lever', () {
44+
// C'est le cas que certaines impls FFI de `cryptography_flutter`
45+
// produisent. Avant, l'échec était silencieux : l'appelant croyait le
46+
// matériel de clé effacé alors qu'il restait intact en RAM.
47+
final ro = Uint8List.fromList([1, 2, 3, 4]).asUnmodifiableView();
48+
expect(SecretBytes.wipe(ro), isFalse);
49+
expect(ro, [1, 2, 3, 4]);
50+
});
51+
52+
test('buffer vide : succès trivial', () {
53+
expect(SecretBytes.wipe(Uint8List(0)), isTrue);
54+
});
55+
});
56+
57+
group('SecretBytes.constantTimeEq', () {
58+
test('longueurs différentes → false', () {
59+
expect(SecretBytes.constantTimeEq([1, 2], [1, 2, 3]), isFalse);
60+
});
61+
62+
test('contenu identique → true', () {
63+
expect(SecretBytes.constantTimeEq([1, 2, 3], [1, 2, 3]), isTrue);
64+
});
65+
66+
test('un seul bit différent → false', () {
67+
expect(SecretBytes.constantTimeEq([1, 2, 3], [1, 2, 2]), isFalse);
68+
});
69+
});
70+
71+
group('SecretBytes.randomBytes', () {
72+
test('longueur négative → ArgumentError', () {
73+
expect(() => SecretBytes.randomBytes(-1), throwsArgumentError);
74+
});
75+
76+
test('n == 0 → buffer vide', () {
77+
expect(SecretBytes.randomBytes(0), isEmpty);
78+
});
79+
80+
test('deux tirages de 32 octets diffèrent', () {
81+
expect(SecretBytes.randomBytes(32), isNot(SecretBytes.randomBytes(32)));
82+
});
83+
});
84+
}

0 commit comments

Comments
 (0)