Files
sepot/app/lib/core/crypto/dek_wrap.dart
T
Julius Joska e3a2378c34 fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
2026-10-09 14:02:33 +02:00

236 lines
7.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Argon2id wrap databázového klíče (DEK) za PIN.
///
/// Schéma:
/// unlockKey = Argon2id(PIN, salt)
/// wrappedDek = AES-256-GCM(dek, unlockKey)
///
/// PIN se NIKDE neukládá ani nehashuje na porovnání — „ověření PINu" je
/// úspěšné rozbalení DEK (GCM auth tag). Špatný PIN = nečitelná DB.
///
/// Argon2id parametry jsou součástí blobu, takže budoucí rekalibrace
/// nerozbije stará data. Při wrap i unwrap se vynucuje dolní mez
/// (OWASP floor): m ≥ 19 MiB, t ≥ 2 — slabší
/// parametry se odmítnou (žádný tichý downgrade).
library;
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:cryptography/dart.dart';
/// GCM auth tag nesedí — špatný PIN, nebo poškozený/podvržený blob.
/// (AES-GCM tyhle dva případy principiálně nerozliší.)
class WrongPinException implements Exception {
const WrongPinException();
@override
String toString() => 'WrongPinException: wrong PIN or corrupted blob';
}
class DekWrap {
DekWrap._();
/// Výchozí parametry (m=64MiB, t=3, p=1).
/// Řádově ~2–3 s čistým Dartem na středně výkonném zařízení.
static const int defaultMemoryKiB = 64 * 1024;
static const int defaultIterations = 3;
static const int defaultParallelism = 1;
/// Dolní bezpečnostní mez (OWASP) — pod ni kalibrace NIKDY nesmí.
static const int floorMemoryKiB = 19 * 1024;
static const int floorIterations = 2;
static const String _version = 'sepot-dek-v1';
/// Zabal 32B DEK za PIN. Výstup je samopopisný string pro keystore:
/// `sepot-dek-v1:<mKiB>:<t>:<p>:<b64 salt>:<b64 nonce>:<b64 ct>:<b64 tag>`
static Future<String> wrap({
required Uint8List dek,
required String pin,
int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations,
int parallelism = defaultParallelism,
Uint8List? salt,
}) async {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
_enforceFloor(memoryKiB, iterations);
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
final useSalt = salt ?? _randomBytes(16);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: useSalt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
final box = await aes.encrypt(
dek,
secretKey: SecretKey(unlockKey),
nonce: _randomBytes(12),
);
return [
_version,
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(useSalt),
base64Encode(box.nonce),
base64Encode(box.cipherText),
base64Encode(box.mac.bytes),
].join(':');
}
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
static Uint8List saltOf(String wrapped) {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
return Uint8List.fromList(base64Decode(parts[4]));
}
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
/// [WrongPinException].
static Future<(int, Uint8List)> unwrapFirstMatching({
required List<String> wrapped,
required String pin,
}) async {
final parsed = <List<String>>[];
for (final w in wrapped) {
final parts = w.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', w);
}
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
parsed.add(parts);
}
final keys = <String, Uint8List>{};
for (final parts in parsed) {
final group = parts.sublist(1, 5).join(':');
keys[group] ??= await _deriveUnlockKey(
pin: pin,
salt: base64Decode(parts[4]),
memoryKiB: int.parse(parts[1]),
iterations: int.parse(parts[2]),
parallelism: int.parse(parts[3]),
);
}
final aes = AesGcm.with256bits();
for (var i = 0; i < parsed.length; i++) {
final parts = parsed[i];
try {
final dek = await aes.decrypt(
SecretBox(
base64Decode(parts[6]),
nonce: base64Decode(parts[5]),
mac: Mac(base64Decode(parts[7])),
),
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
);
return (i, Uint8List.fromList(dek));
} on SecretBoxAuthenticationError {
continue;
}
}
throw const WrongPinException();
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({
required String wrapped,
required String pin,
}) async {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
final memoryKiB = int.parse(parts[1]);
final iterations = int.parse(parts[2]);
final parallelism = int.parse(parts[3]);
// Floor PŘED odvozením — podvržený blob se slabými parametry
// nesmí projít, ani kdyby měl platný tag
_enforceFloor(memoryKiB, iterations);
final salt = base64Decode(parts[4]);
final nonce = base64Decode(parts[5]);
final cipherText = base64Decode(parts[6]);
final mac = base64Decode(parts[7]);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
try {
final dek = await aes.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: SecretKey(unlockKey),
);
return Uint8List.fromList(dek);
} on SecretBoxAuthenticationError {
throw const WrongPinException();
}
}
static void _enforceFloor(int memoryKiB, int iterations) {
if (memoryKiB < floorMemoryKiB || iterations < floorIterations) {
throw StateError(
'Argon2id parameters below security floor '
'(m=${memoryKiB}KiB t=$iterations, floor m=${floorMemoryKiB}KiB '
't=$floorIterations)',
);
}
}
/// Argon2id běží v samostatném isolate — 64 MiB memory-hard výpočet
/// by jinak na sekundy zmrazil UI.
static Future<Uint8List> _deriveUnlockKey({
required String pin,
required Uint8List salt,
required int memoryKiB,
required int iterations,
required int parallelism,
}) {
return Isolate.run(() async {
final algo = DartArgon2id(
memory: memoryKiB,
iterations: iterations,
parallelism: parallelism,
hashLength: 32,
);
final key = await algo.deriveKey(
secretKey: SecretKey(utf8.encode(pin)),
nonce: salt,
);
return Uint8List.fromList(await key.extractBytes());
});
}
static Uint8List _randomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(
List.generate(length, (_) => rng.nextInt(256)),
);
}
}