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.
This commit is contained in:
Julius Joska
2026-10-09 14:02:33 +02:00
parent 8b452a5593
commit e3a2378c34
33 changed files with 1055 additions and 162 deletions
+66 -3
View File
@@ -53,16 +53,20 @@ class DekWrap {
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);
final salt = _randomBytes(16);
// 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: salt,
salt: useSalt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
@@ -80,13 +84,72 @@ class DekWrap {
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(salt),
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({
+86
View File
@@ -81,6 +81,32 @@ class SepotSealedMessage {
}
/// Jeden one-time prekey k publikaci na relay.
/// Nový signed prekey k nahrání na relay (rotace).
class SepotSignedPreKey {
SepotSignedPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
/// Nový last-resort Kyber prekey k nahrání na relay.
class SepotKyberPreKey {
SepotKyberPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey});
@@ -420,4 +446,64 @@ class SepotCrypto {
}
return result;
}
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
}