feat: Rework the double ratchet

This commit is contained in:
2023-06-14 19:55:47 +02:00
parent d2558ea9fa
commit f6f0e145cc
17 changed files with 565 additions and 1234 deletions

View File

@@ -2,46 +2,29 @@ import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart';
import 'package:meta/meta.dart';
import 'package:omemo_dart/protobuf/schema.pb.dart';
import 'package:omemo_dart/src/common/result.dart';
import 'package:omemo_dart/src/crypto.dart';
import 'package:omemo_dart/src/double_ratchet/crypto.dart';
import 'package:omemo_dart/src/double_ratchet/kdf.dart';
import 'package:omemo_dart/src/errors.dart';
import 'package:omemo_dart/src/helpers.dart';
import 'package:omemo_dart/src/keys.dart';
import 'package:omemo_dart/src/protobuf/schema.pb.dart';
/// Amount of messages we may skip per session
const maxSkip = 1000;
class RatchetStep {
const RatchetStep(this.header, this.ciphertext);
final OMEMOMessage header;
final List<int> ciphertext;
}
/// Info string for ENCRYPT
const encryptHkdfInfoString = 'OMEMO Message Key Material';
@immutable
class SkippedKey {
const SkippedKey(this.dh, this.n);
factory SkippedKey.fromJson(Map<String, dynamic> data) {
return SkippedKey(
OmemoPublicKey.fromBytes(
base64.decode(data['public']! as String),
KeyPairType.x25519,
),
data['n']! as int,
);
}
/// The DH public key for which we skipped a message key.
final OmemoPublicKey dh;
final int n;
Future<Map<String, dynamic>> toJson() async {
return {
'public': base64.encode(await dh.getBytes()),
'n': n,
};
}
/// The associated number of the message key we skipped.
final int n;
@override
bool operator ==(Object other) {
@@ -63,6 +46,7 @@ class OmemoDoubleRatchet {
this.nr, // Nr
this.pn, // Pn
this.ik,
this.ek,
this.sessionAd,
this.mkSkipped, // MKSKIPPED
this.acknowledged,
@@ -70,73 +54,6 @@ class OmemoDoubleRatchet {
this.kex,
);
factory OmemoDoubleRatchet.fromJson(Map<String, dynamic> data) {
/*
{
'dhs': 'base/64/encoded',
'dhs_pub': 'base/64/encoded',
'dhr': null | 'base/64/encoded',
'rk': 'base/64/encoded',
'cks': null | 'base/64/encoded',
'ckr': null | 'base/64/encoded',
'ns': 0,
'nr': 0,
'pn': 0,
'ik_pub': null | 'base/64/encoded',
'session_ad': 'base/64/encoded',
'acknowledged': true | false,
'kex_timestamp': int,
'kex': 'base/64/encoded',
'mkskipped': [
{
'key': 'base/64/encoded',
'public': 'base/64/encoded',
'n': 0
}, ...
]
}
*/
// NOTE: Dart has some issues with just casting a List<dynamic> to List<Map<...>>, as
// such we need to convert the items by hand.
final mkSkipped = Map<SkippedKey, List<int>>.fromEntries(
(data['mkskipped']! as List<dynamic>)
.map<MapEntry<SkippedKey, List<int>>>(
(entry) {
final map = entry as Map<String, dynamic>;
final key = SkippedKey.fromJson(map);
return MapEntry(
key,
base64.decode(map['key']! as String),
);
},
),
);
return OmemoDoubleRatchet(
OmemoKeyPair.fromBytes(
base64.decode(data['dhs_pub']! as String),
base64.decode(data['dhs']! as String),
KeyPairType.x25519,
),
decodeKeyIfNotNull(data, 'dhr', KeyPairType.x25519),
base64.decode(data['rk']! as String),
base64DecodeIfNotNull(data, 'cks'),
base64DecodeIfNotNull(data, 'ckr'),
data['ns']! as int,
data['nr']! as int,
data['pn']! as int,
OmemoPublicKey.fromBytes(
base64.decode(data['ik_pub']! as String),
KeyPairType.ed25519,
),
base64.decode(data['session_ad']! as String),
mkSkipped,
data['acknowledged']! as bool,
data['kex_timestamp']! as int,
data['kex'] as String?,
);
}
/// Sending DH keypair
OmemoKeyPair dhs;
@@ -161,6 +78,11 @@ class OmemoDoubleRatchet {
/// for verification purposes
final OmemoPublicKey ik;
/// The ephemeral public key of the chat partner. Not used for encryption but for possible
/// checks when replacing the ratchet. As such, this is only non-null for the initiating
/// side.
final OmemoPublicKey? ek;
final List<int> sessionAd;
final Map<SkippedKey, List<int>> mkSkipped;
@@ -182,25 +104,25 @@ class OmemoDoubleRatchet {
static Future<OmemoDoubleRatchet> initiateNewSession(
OmemoPublicKey spk,
OmemoPublicKey ik,
OmemoPublicKey ek,
List<int> sk,
List<int> ad,
int timestamp,
) async {
final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final dhr = spk;
final rk = await kdfRk(sk, await omemoDH(dhs, dhr, 0));
final cks = rk;
final rk = await kdfRk(sk, await omemoDH(dhs, spk, 0));
return OmemoDoubleRatchet(
dhs,
dhr,
rk,
cks,
spk,
List.from(rk),
List.from(rk),
null,
0,
0,
0,
ik,
ek,
ad,
{},
false,
@@ -230,6 +152,7 @@ class OmemoDoubleRatchet {
0,
0,
ik,
null,
ad,
{},
true,
@@ -238,67 +161,42 @@ class OmemoDoubleRatchet {
);
}
Future<Map<String, dynamic>> toJson() async {
final mkSkippedSerialised =
List<Map<String, dynamic>>.empty(growable: true);
for (final entry in mkSkipped.entries) {
final result = await entry.key.toJson();
result['key'] = base64.encode(entry.value);
mkSkippedSerialised.add(result);
}
return {
'dhs': base64.encode(await dhs.sk.getBytes()),
'dhs_pub': base64.encode(await dhs.pk.getBytes()),
'dhr': dhr != null ? base64.encode(await dhr!.getBytes()) : null,
'rk': base64.encode(rk),
'cks': cks != null ? base64.encode(cks!) : null,
'ckr': ckr != null ? base64.encode(ckr!) : null,
'ns': ns,
'nr': nr,
'pn': pn,
'ik_pub': base64.encode(await ik.getBytes()),
'session_ad': base64.encode(sessionAd),
'mkskipped': mkSkippedSerialised,
'acknowledged': acknowledged,
'kex_timestamp': kexTimestamp,
'kex': kex,
};
}
/// Returns the OMEMO compatible fingerprint of the ratchet session.
Future<String> getOmemoFingerprint() async {
final curveKey = await ik.toCurve25519();
return HEX.encode(await curveKey.getBytes());
}
Future<List<int>?> _trySkippedMessageKeys(
OMEMOMessage header,
List<int> ciphertext,
) async {
final key = SkippedKey(
OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519),
header.n,
/// Performs a single ratchet step in case we received a new
/// public key in [header].
Future<void> _dhRatchet(OMEMOMessage header) async {
pn = ns;
ns = 0;
nr = 0;
dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519);
final newRk1 = await kdfRk(
rk,
await omemoDH(
dhs,
dhr!,
0,
),
);
if (mkSkipped.containsKey(key)) {
final mk = mkSkipped[key]!;
mkSkipped.remove(key);
return decrypt(
mk,
ciphertext,
concat([sessionAd, header.writeToBuffer()]),
sessionAd,
);
}
return null;
rk = List.from(newRk1);
ckr = List.from(newRk1);
dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final newRk2 = await kdfRk(
rk,
await omemoDH(
dhs,
dhr!,
0,
),
);
rk = List.from(newRk2);
cks = List.from(newRk2);
}
Future<void> _skipMessageKeys(int until) async {
/// Skip (and keep track of) message keys until our receive counter is
/// equal to [until]. If we would skip too many messages, returns
/// a [SkippingTooManyKeysError]. If not, returns null.
Future<OmemoError?> _skipMessageKeys(int until) async {
if (nr + maxSkip < until) {
throw SkippingTooManyMessagesException();
return SkippingTooManyKeysError();
}
if (ckr != null) {
@@ -310,121 +208,119 @@ class OmemoDoubleRatchet {
nr++;
}
}
return null;
}
Future<void> _dhRatchet(OMEMOMessage header) async {
pn = ns;
ns = 0;
nr = 0;
dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519);
/// Decrypt [ciphertext] using keys derived from the message key [mk]. Also computes the
/// HMAC from the [OMEMOMessage] embedded in [message].
///
/// If the computed HMAC does not match the HMAC in [message], returns
/// [InvalidMessageHMACError]. If it matches, returns the decrypted
/// payload.
Future<Result<OmemoError, List<int>>> _decrypt(OMEMOAuthenticatedMessage message, List<int> ciphertext, List<int> mk) async {
final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString);
final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0));
rk = List.from(newRk);
ckr = List.from(newRk);
dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final newNewRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0));
rk = List.from(newNewRk);
cks = List.from(newNewRk);
}
/// Encrypt [plaintext] using the Double Ratchet.
Future<RatchetStep> ratchetEncrypt(List<int> plaintext) async {
final newCks = await kdfCk(cks!, kdfCkNextChainKey);
final mk = await kdfCk(cks!, kdfCkNextMessageKey);
cks = newCks;
final header = OMEMOMessage()
..dhPub = await dhs.pk.getBytes()
..pn = pn
..n = ns;
ns++;
return RatchetStep(
header,
await encrypt(
mk,
plaintext,
concat([sessionAd, header.writeToBuffer()]),
sessionAd,
),
);
}
/// Decrypt a [ciphertext] that was sent with the header [header] using the Double
/// Ratchet. Returns the decrypted (raw) plaintext.
///
/// Throws an SkippingTooManyMessagesException if too many messages were to be skipped.
Future<List<int>> ratchetDecrypt(
OMEMOMessage header,
List<int> ciphertext,
) async {
// Check if we skipped too many messages
final plaintext = await _trySkippedMessageKeys(header, ciphertext);
if (plaintext != null) {
return plaintext;
final hmacInput = concat([sessionAd, message.message]);
final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey);
if (!listsEqual(hmacResult, message.mac)) {
return Result(InvalidMessageHMACError());
}
final dhPubMatches = listsEqual(
header.dhPub,
(await dhr?.getBytes()) ?? <int>[],
final plaintext = await aes256CbcDecrypt(ciphertext, keys.encryptionKey, keys.iv);
return Result(plaintext);
}
/// Checks whether we could decrypt the payload in [header] with a skipped key. If yes,
/// attempts to decrypt it. If not, returns null.
///
/// If the decryption is successful, returns the plaintext payload. If an error occurs, like
/// an [InvalidMessageHMACError], that is returned instead.
Future<Result<OmemoError, List<int>?>> _trySkippedMessageKeys(OMEMOAuthenticatedMessage message, OMEMOMessage header) async {
final key = SkippedKey(
OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519),
header.n,
);
if (!dhPubMatches) {
await _skipMessageKeys(header.pn);
if (mkSkipped.containsKey(key)) {
final mk = mkSkipped[key]!;
mkSkipped.remove(key);
return _decrypt(message, header.ciphertext, mk);
}
return const Result(null);
}
/// Decrypt the payload (deeply) embedded in [message].
///
/// If everything goes well, returns the plaintext payload. If an error occurs, that
/// is returned instead.
Future<Result<OmemoError, List<int>>> ratchetDecrypt(OMEMOAuthenticatedMessage message) async {
final header = OMEMOMessage.fromBuffer(message.message);
// Try skipped keys
final plaintextRaw = await _trySkippedMessageKeys(message, header);
if (plaintextRaw.isType<OmemoError>()) {
// Propagate the error
return Result(plaintextRaw.get<OmemoError>());
}
final plaintext = plaintextRaw.get<List<int>?>();
if (plaintext != null) {
return Result(plaintext);
}
if (dhr == null || !listsEqual(header.dhPub, await dhr!.getBytes())) {
final skipResult1 = await _skipMessageKeys(header.pn);
if (skipResult1 != null) {
return Result(skipResult1);
}
await _dhRatchet(header);
}
await _skipMessageKeys(header.n);
final newCkr = await kdfCk(ckr!, kdfCkNextChainKey);
final skipResult2 = await _skipMessageKeys(header.n);
if (skipResult2 != null) {
return Result(skipResult2);
}
final ck = await kdfCk(ckr!, kdfCkNextChainKey);
final mk = await kdfCk(ckr!, kdfCkNextMessageKey);
ckr = newCkr;
nr++;
ckr = ck;
return decrypt(
mk,
ciphertext,
concat([sessionAd, header.writeToBuffer()]),
sessionAd,
);
return _decrypt(message, header.ciphertext, mk);
}
OmemoDoubleRatchet clone() {
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,
);
/// Encrypt the payload [plaintext] using the double ratchet session.
Future<OMEMOAuthenticatedMessage> ratchetEncrypt(List<int> plaintext) async {
// Advance the ratchet
final ck = await kdfCk(cks!, kdfCkNextChainKey);
final mk = await kdfCk(cks!, kdfCkNextMessageKey);
cks = ck;
// Generate encryption, authentication key and IV
final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString);
final ciphertext =
await aes256CbcEncrypt(plaintext, keys.encryptionKey, keys.iv);
// Fill-in the header and serialize it here so we do it only once
final header = OMEMOMessage()
..dhPub = await dhs.pk.getBytes()
..pn = pn
..n = ns
..ciphertext = ciphertext;
final headerBytes = header.writeToBuffer();
// Increment the send counter
ns++;
final newAd = concat([sessionAd, headerBytes]);
final hmac = await truncatedHmac(newAd, keys.authenticationKey);
return OMEMOAuthenticatedMessage()
..mac = hmac
..message = headerBytes;
}
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,
);
}
@visibleForTesting
Future<bool> equals(OmemoDoubleRatchet other) async {