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:
@@ -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({
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user