fix: Reuse old key exchange when the ratchet is unacked
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
PapaTutuWawa 2022-10-22 12:41:41 +02:00
parent 1472624b1d
commit c68471349a
3 changed files with 284 additions and 35 deletions

View File

@ -69,6 +69,7 @@ class OmemoDoubleRatchet {
this.mkSkipped, // MKSKIPPED this.mkSkipped, // MKSKIPPED
this.acknowledged, this.acknowledged,
this.kexTimestamp, this.kexTimestamp,
this.kex,
); );
factory OmemoDoubleRatchet.fromJson(Map<String, dynamic> data) { factory OmemoDoubleRatchet.fromJson(Map<String, dynamic> data) {
@ -83,10 +84,11 @@ class OmemoDoubleRatchet {
'ns': 0, 'ns': 0,
'nr': 0, 'nr': 0,
'pn': 0, 'pn': 0,
'ik_pub': 'base/64/encoded', 'ik_pub': null | 'base/64/encoded',
'session_ad': 'base/64/encoded', 'session_ad': 'base/64/encoded',
'acknowledged': true | false, 'acknowledged': true | false,
'kex_timestamp': int, 'kex_timestamp': int,
'kex': 'base/64/encoded',
'mkskipped': [ 'mkskipped': [
{ {
'key': 'base/64/encoded', 'key': 'base/64/encoded',
@ -132,6 +134,7 @@ class OmemoDoubleRatchet {
mkSkipped, mkSkipped,
data['acknowledged']! as bool, data['acknowledged']! as bool,
data['kex_timestamp']! as int, data['kex_timestamp']! as int,
data['kex'] as String?,
); );
} }
@ -167,6 +170,9 @@ class OmemoDoubleRatchet {
/// Precision is milliseconds since epoch. /// Precision is milliseconds since epoch.
int kexTimestamp; int kexTimestamp;
/// The key exchange that was used for initiating the session.
final String? kex;
/// Indicates whether we received an empty OMEMO message after building a session with /// Indicates whether we received an empty OMEMO message after building a session with
/// the device. /// the device.
bool acknowledged; bool acknowledged;
@ -194,6 +200,7 @@ class OmemoDoubleRatchet {
{}, {},
false, false,
timestamp, timestamp,
'',
); );
} }
@ -216,6 +223,7 @@ class OmemoDoubleRatchet {
{}, {},
false, false,
kexTimestamp, kexTimestamp,
null,
); );
} }
@ -243,6 +251,7 @@ class OmemoDoubleRatchet {
'mkskipped': mkSkippedSerialised, 'mkskipped': mkSkippedSerialised,
'acknowledged': acknowledged, 'acknowledged': acknowledged,
'kex_timestamp': kexTimestamp, 'kex_timestamp': kexTimestamp,
'kex': kex,
}; };
} }
@ -359,6 +368,30 @@ class OmemoDoubleRatchet {
Map<SkippedKey, List<int>>.from(mkSkipped), Map<SkippedKey, List<int>>.from(mkSkipped),
acknowledged, acknowledged,
kexTimestamp, kexTimestamp,
kex,
);
}
OmemoDoubleRatchet cloneWithKex(String kex) {
return OmemoDoubleRatchet(
dhs,
dhr,
rk,
cks != null ?
List<int>.from(cks!) :
null,
ckr != null ?
List<int>.from(ckr!) :
null,
ns,
nr,
pn,
ik,
sessionAd,
Map<SkippedKey, List<int>>.from(mkSkipped),
acknowledged,
kexTimestamp,
kex,
); );
} }

View File

@ -170,7 +170,7 @@ class OmemoSessionManager {
/// Build a new session with the user at [jid] with the device [deviceId] using data /// Build a new session with the user at [jid] with the device [deviceId] using data
/// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey /// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey
/// identifier an UnknownSignedPrekeyException will be thrown. /// identifier an UnknownSignedPrekeyException will be thrown.
Future<void> _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async { Future<OmemoDoubleRatchet> _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async {
// Pick the correct SPK // Pick the correct SPK
final device = await getDevice(); final device = await getDevice();
final spk = await _lock.synchronized(() async { final spk = await _lock.synchronized(() async {
@ -204,8 +204,7 @@ class OmemoSessionManager {
getTimestamp(), getTimestamp(),
); );
await _trustManager.onNewSession(jid, deviceId); return ratchet;
await _addSession(jid, deviceId, ratchet);
} }
/// Like [encryptToJids] but only for one Jid [jid]. /// Like [encryptToJids] but only for one Jid [jid].
@ -264,24 +263,53 @@ class OmemoSessionManager {
} }
final ratchetKey = RatchetMapKey(jid, deviceId); final ratchetKey = RatchetMapKey(jid, deviceId);
final ratchet = _ratchetMap[ratchetKey]!; var ratchet = _ratchetMap[ratchetKey]!;
final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext; final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext;
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet));
if (kex.isNotEmpty && kex.containsKey(deviceId)) { if (kex.isNotEmpty && kex.containsKey(deviceId)) {
// The ratchet did not exist
final k = kex[deviceId]! final k = kex[deviceId]!
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
final buffer = base64.encode(k.writeToBuffer());
encryptedKeys.add( encryptedKeys.add(
EncryptedKey( EncryptedKey(
jid, jid,
deviceId, deviceId,
base64.encode(k.writeToBuffer()), buffer,
true, true,
), ),
); );
ratchet = ratchet.cloneWithKex(buffer);
_ratchetMap[ratchetKey] = ratchet;
} else if (!ratchet.acknowledged) {
// The ratchet exists but is not acked
if (ratchet.kex != null) {
final oldKex = OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!))
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(oldKex.writeToBuffer()),
true,
),
);
} else {
// The ratchet is not acked but we don't have the old key exchange
_log.warning('Ratchet for $jid:$deviceId is not acked but the kex attribute is null');
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(ciphertext),
false,
),
);
}
} else { } else {
// The ratchet exists and is acked
encryptedKeys.add( encryptedKeys.add(
EncryptedKey( EncryptedKey(
jid, jid,
@ -291,6 +319,9 @@ class OmemoSessionManager {
), ),
); );
} }
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet));
} }
} }
}); });
@ -319,6 +350,25 @@ class OmemoSessionManager {
); );
}); });
} }
Future<String?> _decryptAndVerifyHmac(List<int>? ciphertext, List<int> keyAndHmac) async {
// Empty OMEMO messages should just have the key decrypted and/or session set up.
if (ciphertext == null) {
return null;
}
final key = keyAndHmac.sublist(0, 32);
final hmac = keyAndHmac.sublist(32, 48);
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
final computedHmac = await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
if (!listsEqual(hmac, computedHmac)) {
throw InvalidMessageHMACException();
}
return utf8.decode(
await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv),
);
}
/// Attempt to decrypt [ciphertext]. [keys] refers to the <key /> elements inside the /// Attempt to decrypt [ciphertext]. [keys] refers to the <key /> elements inside the
/// <keys /> element with a "jid" attribute matching our own. [senderJid] refers to the /// <keys /> element with a "jid" attribute matching our own. [senderJid] refers to the
@ -342,6 +392,7 @@ class OmemoSessionManager {
final ratchetKey = RatchetMapKey(senderJid, senderDeviceId); final ratchetKey = RatchetMapKey(senderJid, senderDeviceId);
final decodedRawKey = base64.decode(rawKey.value); final decodedRawKey = base64.decode(rawKey.value);
List<int>? keyAndHmac;
OmemoAuthenticatedMessage authMessage; OmemoAuthenticatedMessage authMessage;
OmemoDoubleRatchet? oldRatchet; OmemoDoubleRatchet? oldRatchet;
OmemoMessage? message; OmemoMessage? message;
@ -359,14 +410,34 @@ class OmemoSessionManager {
if (oldRatchet.kexTimestamp > timestamp) { if (oldRatchet.kexTimestamp > timestamp) {
throw InvalidKeyExchangeException(); throw InvalidKeyExchangeException();
} }
// Try to decrypt it
try {
final decrypted = await oldRatchet.ratchetDecrypt(message, authMessage.writeToBuffer());
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
senderJid,
senderDeviceId,
oldRatchet,
),
);
final plaintext = await _decryptAndVerifyHmac(
ciphertext,
decrypted,
);
await _addSession(senderJid, senderDeviceId, oldRatchet);
return plaintext;
} catch (_) {
_log.finest('Failed to use old ratchet with KEX for existing ratchet');
}
} }
// TODO(PapaTutuWawa): Only do this when we should final r = await _addSessionFromKeyExchange(senderJid, senderDeviceId, kex);
await _addSessionFromKeyExchange( await _trustManager.onNewSession(senderJid, senderDeviceId);
senderJid, await _addSession(senderJid, senderDeviceId, r);
senderDeviceId,
kex,
);
// Replace the OPK // Replace the OPK
// TODO(PapaTutuWawa): Replace the OPK when we know that the KEX worked // TODO(PapaTutuWawa): Replace the OPK when we know that the KEX worked
@ -389,7 +460,6 @@ class OmemoSessionManager {
throw NoDecryptionKeyException(); throw NoDecryptionKeyException();
} }
List<int>? keyAndHmac;
// We can guarantee that the ratchet exists at this point in time // We can guarantee that the ratchet exists at this point in time
final ratchet = (await _getRatchet(ratchetKey))!; final ratchet = (await _getRatchet(ratchetKey))!;
oldRatchet ??= ratchet.clone(); oldRatchet ??= ratchet.clone();
@ -408,24 +478,12 @@ class OmemoSessionManager {
// Commit the ratchet // Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(senderJid, senderDeviceId, ratchet)); _eventStreamController.add(RatchetModifiedEvent(senderJid, senderDeviceId, ratchet));
// Empty OMEMO messages should just have the key decrypted and/or session set up. try {
if (ciphertext == null) { return _decryptAndVerifyHmac(ciphertext, keyAndHmac);
return null; } catch (_) {
}
final key = keyAndHmac.sublist(0, 32);
final hmac = keyAndHmac.sublist(32, 48);
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
final computedHmac = await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
if (!listsEqual(hmac, computedHmac)) {
// TODO(PapaTutuWawa): I am unsure if we should restore the ratchet here
await _restoreRatchet(ratchetKey, oldRatchet); await _restoreRatchet(ratchetKey, oldRatchet);
throw InvalidMessageHMACException(); rethrow;
} }
final plaintext = await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv);
return utf8.decode(plaintext);
} }
/// Returns the list of hex-encoded fingerprints we have for sessions with [jid]. /// Returns the list of hex-encoded fingerprints we have for sessions with [jid].

View File

@ -80,7 +80,7 @@ void main() {
], ],
); );
expect(aliceMessage.encryptedKeys.length, 1); expect(aliceMessage.encryptedKeys.length, 1);
// Alice sends the message to Bob // Alice sends the message to Bob
// ... // ...
@ -106,6 +106,10 @@ void main() {
false, false,
); );
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds to Alice // Bob responds to Alice
const bobResponseText = 'Oh, hello Alice!'; const bobResponseText = 'Oh, hello Alice!';
final bobResponseMessage = await bobSession.encryptToJid( final bobResponseMessage = await bobSession.encryptToJid(
@ -176,6 +180,10 @@ void main() {
); );
expect(messagePlaintext, bobMessage); expect(messagePlaintext, bobMessage);
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds to Alice // Bob responds to Alice
const bobResponseText = 'Oh, hello Alice!'; const bobResponseText = 'Oh, hello Alice!';
final bobResponseMessage = await bobSession.encryptToJid( final bobResponseMessage = await bobSession.encryptToJid(
@ -448,6 +456,10 @@ void main() {
0, 0,
); );
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
for (var i = 0; i < 100; i++) { for (var i = 0; i < 100; i++) {
final messageText = 'Test Message #$i'; final messageText = 'Test Message #$i';
// Bob responds to Alice // Bob responds to Alice
@ -665,6 +677,58 @@ void main() {
expect(await bobRatchet1.equals(bobRatchet2), false); expect(await bobRatchet1.equals(bobRatchet2), false);
}); });
test('Test resending key exchanges', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 2,
);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
// The first message should be a kex message
expect(msg1.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
0,
);
// Alice is impatient and immediately sends another message before the original one
// can be acknowledged by Bob
final msg2 = await aliceSession.encryptToJid(
bobJid,
"Why don't you answer?",
);
expect(msg2.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg2.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
});
test('Test receiving old messages including a KEX', () async { test('Test receiving old messages including a KEX', () async {
const aliceJid = 'alice@server.example'; const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example'; const bobJid = 'bob@other.server.example';
@ -703,6 +767,10 @@ void main() {
t1, t1,
); );
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds // Bob responds
final msg2 = await bobSession.encryptToJid( final msg2 = await bobSession.encryptToJid(
aliceJid, aliceJid,
@ -785,4 +853,94 @@ void main() {
expect(result, 'Are you okay?'); expect(result, 'Are you okay?');
}); });
test("Test ignoring a new KEX when we haven't acket it yet", () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
expect(msg1.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
getTimestamp(),
);
// Alice sends another message before the ack can reach us
final msg2 = await aliceSession.encryptToJid(
bobJid,
'ANSWER ME!',
);
expect(msg2.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg2.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
// Now the acks reach us
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Alice sends another message
final msg3 = await aliceSession.encryptToJid(
bobJid,
"You read the message, didn't you?",
);
expect(msg3.encryptedKeys.first.kex, false);
await bobSession.decryptMessage(
msg3.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg3.encryptedKeys,
getTimestamp(),
);
for (var i = 0; i < 100; i++) {
final messageText = 'Test Message #$i';
// Bob responds to Alice
final bobResponseMessage = await bobSession.encryptToJid(
aliceJid,
messageText,
);
// Bob sends the message to Alice
// ...
// Alice decrypts it
final aliceReceivedMessage = await aliceSession.decryptMessage(
bobResponseMessage.ciphertext,
bobJid,
await bobSession.getDeviceId(),
bobResponseMessage.encryptedKeys,
0,
);
expect(messageText, aliceReceivedMessage);
}
});
} }