feat: Rework the double ratchet
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user