diff --git a/lib/omemo_dart.dart b/lib/omemo_dart.dart index 0b63122..06b49bf 100644 --- a/lib/omemo_dart.dart +++ b/lib/omemo_dart.dart @@ -1,6 +1,8 @@ library omemo_dart; export 'src/bundle.dart'; +export 'src/double_ratchet.dart'; export 'src/errors.dart'; +export 'src/helpers.dart'; export 'src/key.dart'; export 'src/x3dh.dart'; diff --git a/lib/src/double_ratchet.dart b/lib/src/double_ratchet.dart index edfc869..04cf144 100644 --- a/lib/src/double_ratchet.dart +++ b/lib/src/double_ratchet.dart @@ -1,281 +1,196 @@ -import 'dart:convert'; import 'package:cryptography/cryptography.dart'; +import 'package:meta/meta.dart'; import 'package:omemo_dart/protobuf/schema.pb.dart'; -import 'package:omemo_dart/src/bundle.dart'; +import 'package:omemo_dart/src/double_ratchet/crypto.dart'; +import 'package:omemo_dart/src/double_ratchet/kdf.dart'; import 'package:omemo_dart/src/helpers.dart'; import 'package:omemo_dart/src/key.dart'; import 'package:omemo_dart/src/x3dh.dart'; -class OmemoRatchetStepResult { +/// Amount of messages we may skip per session +const maxSkip = 1000; - const OmemoRatchetStepResult(this.header, this.cipherText); - final List header; - final List cipherText; +class RatchetStep { + + const RatchetStep(this.header, this.ciphertext); + final OMEMOMessage header; + final List ciphertext; } -class OmemoEncryptionResult { +@immutable +class SkippedKey { - const OmemoEncryptionResult(this.cipherText, this.keys); - /// The encrypted plaintext - final List cipherText; - /// Mapping between Device id and the key to decrypt cipherText; - final Map> keys; + const SkippedKey(this.dh, this.n); + final OmemoPublicKey dh; + final int n; + + @override + bool operator ==(Object other) { + return other is SkippedKey && other.dh == dh && other.n == n; + } + + @override + int get hashCode => dh.hashCode ^ n.hashCode; } -/// The session state of one party -class AliceOmemoSession { +class OmemoDoubleRatchet { - AliceOmemoSession( - this.dhs, - this.dhr, - this.ek, - this.rk, - this.cks, - this.ckr, - this.ns, - this.nr, - this.pn, - // this.skippedMessages, - this.ad, + OmemoDoubleRatchet( + this.dhs, // DHs + this.dhr, // DHr + this.rk, // RK + this.cks, // CKs + this.ckr, // CKr + this.ns, // Ns + this.nr, // Nr + this.pn, // Pn + this.sessionAd, ); - - /// The Diffie-Hellman sending key pair - final OmemoKeyPair dhs; + + /// Sending DH keypair + OmemoKeyPair dhs; - /// The Diffie-Hellman receiving key pair - final OmemoPublicKey dhr; + /// Receiving Public key + OmemoPublicKey? dhr; - /// The EK used by X3DH - final OmemoKeyPair ek; - - /// The Root Key + /// 32 byte Root Key List rk; - /// Sending Chain Key - List cks; - - /// Receiving Chain Key + /// Sending and receiving Chain Keys + List? cks; List? ckr; - /// Message number for sending + /// Sending and receiving message numbers int ns; - - /// Message number for receiving int nr; - /// Number of messages in the previous sending chain + /// Previous sending chain number int pn; - - /// The associated data from the X3DH - final List ad; - // TODO(PapaTutuWawa): Track skipped over message keys + final List sessionAd; - static Future newSession(OmemoBundle bundle, OmemoKeyPair ik) async { - // TODO(PapaTutuWawa): Error handling - final x3dhResult = await x3dhFromBundle(bundle, ik); + final Map> mkSkipped = {}; + + /// This is performed by the initiating entity + static Future initiateNewSession(OmemoPublicKey spk, List sk, List ad) async { final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); - final dhr = bundle.ik; - final ek = x3dhResult.ek; - final sk = x3dhResult.sk; - final kdfRkResult = await kdfRk(sk, await dh(dhs, dhr, 2)); - - return AliceOmemoSession( + final dhr = spk; + final rk = await kdfRk(sk, await dh(dhs, dhr, 0)); + final cks = rk; + + return OmemoDoubleRatchet( dhs, dhr, - ek, - kdfRkResult.rk, - kdfRkResult.ck, + rk, + cks, null, 0, 0, 0, - x3dhResult.ad, + ad, ); } - /// The associated_data parameter is implicit as it belongs to the session - Future> _encrypt(List mk, List plaintext, List associatedData) async { - final algorithm = Hkdf( - hmac: Hmac(Sha256()), - outputLength: 80, + /// This is performed by the accepting entity + static Future acceptNewSession(OmemoKeyPair spk, List sk, List ad) async { + final dhs = spk; + return OmemoDoubleRatchet( + dhs, + null, + sk, + null, + null, + 0, + 0, + 0, + ad, ); - final hkdfResult = await algorithm.deriveKey( - secretKey: SecretKey(mk), - nonce: List.filled(32, 0x00), - info: utf8.encode(encryptHkdfInfoString), - ); - final bytes = await hkdfResult.extractBytes(); - - final encKey = bytes.sublist(0, 32); - final authKey = bytes.sublist(32, 64); - final iv = bytes.sublist(64, 82); - - // TODO(PapaTutuWawa): Remove once done - assert(encKey.length == 32); - assert(authKey.length == 32); - assert(iv.length == 16); - - // 32 = 256 / 8 - final encodedPlaintext = pkcs7padding(plaintext, 32); - - final aesAlgorithm = AesCbc.with256bits( - macAlgorithm: Hmac.sha256(), - ); - final secretBox = await aesAlgorithm.encrypt( - encodedPlaintext, - secretKey: SecretKey(encKey), - nonce: iv, - ); - - final ad_ = associatedData.sublist(0, ad.length); - final message = OMEMOMessage.fromBuffer(associatedData.sublist(ad.length)) - ..ciphertext = secretBox.cipherText; - final messageBytes = message.writeToBuffer(); - - final input = concat([ad_, messageBytes]); - final authBytes = (await Hmac.sha256().calculateMac( - input, - secretKey: SecretKey(authKey), - )).bytes.sublist(0, 16); - - final authenticatedMessage = OMEMOAuthenticatedMessage() - ..mac = authBytes - ..message = messageBytes; - - return authenticatedMessage.writeToBuffer(); - } - - Future> ratchetStep(List plaintext) async { - final kdfResult = await kdfCk(cks); - final message = OMEMOMessage() - ..dhPub = await dhs.pk.getBytes() - ..pn = pn - ..n = ns; - final header = message.writeToBuffer(); - - cks = kdfResult.ck; - ns++; - - return _encrypt( - kdfResult.mk, - plaintext, - concat([ad, header]), - ); - } -} - -Future encryptForSessions(List sessions, String plaintext) async { - // TODO(PapaTutuWawa): Generate random data - final key = List.filled(32, 0x0); - final algorithm = Hkdf( - hmac: Hmac(Sha256()), - outputLength: 80, - ); - final result = await algorithm.deriveKey( - secretKey: SecretKey(key), - nonce: List.filled(32, 0x0), - info: utf8.encode(encryptionHkdfInfoString), - ); - final bytes = await result.extractBytes(); - - final encKey = bytes.sublist(0, 32); - final authKey = bytes.sublist(32, 64); - final iv = bytes.sublist(64, 80); - - final encodedPlaintext = pkcs7padding(utf8.encode(plaintext), 32); - final aesAlgorithm = AesCbc.with256bits( - macAlgorithm: Hmac.sha256(), - ); - final secretBox = await aesAlgorithm.encrypt( - encodedPlaintext, - secretKey: SecretKey(encKey), - nonce: iv, - ); - final hmac = (await Hmac.sha256().calculateMac( - secretBox.cipherText, - secretKey: SecretKey(authKey), - )).bytes.sublist(0, 16); - - final keyData = concat([encKey, hmac]); - - final keyMap = >{}; - for (final session in sessions) { - final ratchetKey = await session.ratchetStep(keyData); } - return OmemoEncryptionResult( - secretBox.cipherText, - keyMap, - ); -} - -/// Result of the KDF_RK function from the Double Ratchet spec. -class KdfRkResult { - - const KdfRkResult(this.rk, this.ck); - /// 32 byte Root Key - final List rk; - - /// 32 byte Chain Key - final List ck; -} - -/// Result of the KDF_CK function from the Double Ratchet spec. -class KdfCkResult { - - const KdfCkResult(this.ck, this.mk); - /// 32 byte Chain Key - final List ck; - - /// 32 byte Message Key - final List mk; -} - -/// Amount of messages we may skip per session -const maxSkip = 1000; - -/// Info string for KDF_RK -const kdfRkInfoString = 'OMEMO Root Chain'; - -/// Info string for ENCRYPT -const encryptHkdfInfoString = 'OMEMO Message Key Material'; - -/// Info string for encrypting a message -const encryptionHkdfInfoString = 'OMEMO Payload'; - -/// Flags for KDF_CK -const kdfCkNextMessageKey = 0x01; -const kdfCkNextChainKey = 0x02; - -Future kdfRk(List rk, List dhOut) async { - final algorithm = Hkdf( - hmac: Hmac(Sha256()), - outputLength: 32, - ); - final result = await algorithm.deriveKey( - secretKey: SecretKey(dhOut), - nonce: rk, - info: utf8.encode(kdfRkInfoString), - ); - - // TODO(PapaTutuWawa): Does the rk in the tuple (rk, ck) refer to the input rk? - return KdfRkResult(rk, await result.extractBytes()); -} - -Future kdfCk(List ck) async { - final hkdf = Hkdf(hmac: Hmac(Sha256()), outputLength: 32); - final newCk = await hkdf.deriveKey( - secretKey: SecretKey(ck), - nonce: [kdfCkNextChainKey], - ); - final mk = await hkdf.deriveKey( - secretKey: SecretKey(ck), - nonce: [kdfCkNextMessageKey], - ); - - return KdfCkResult( - await newCk.extractBytes(), - await mk.extractBytes(), - ); + Future ratchetEncrypt(List plaintext) async { + final newCks = await kdfCk(cks!, kdfCkNextChainKey); + final mk = await kdfCk(cks!, kdfCkNextMessageKey); + + cks = newCks; + final header = OMEMOMessage() + ..n = ns + ..pn = pn + ..dhPub = await dhs.pk.getBytes(); + + ns++; + + return RatchetStep( + header, + await encrypt(mk, plaintext, concat([sessionAd, header.writeToBuffer()]), sessionAd), + ); + } + + Future?> trySkippedMessageKeys(OMEMOMessage header, List ciphertext) async { + final key = SkippedKey( + // TODO(PapaTutuWawa): Is this correct + OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.ed25519), + header.n, + ); + if (mkSkipped.containsKey(key)) { + final mk = mkSkipped[key]!; + mkSkipped.remove(key); + + return decrypt(mk, ciphertext, concat([sessionAd, header.writeToBuffer()]), sessionAd); + } + + return null; + } + + Future skipMessageKeys(int until) async { + if (nr + maxSkip < until) { + // TODO(PapaTutuWawa): Custom exception + throw Exception(); + } + + if (ckr != null) { + while (nr < until) { + final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); + final mk = await kdfCk(ckr!, kdfCkNextMessageKey); + ckr = newCkr; + mkSkipped[SkippedKey(dhr!, nr)] = mk; + nr++; + } + } + } + + Future dhRatchet(OMEMOMessage header) async { + pn = header.n; + ns = 0; + nr = 0; + dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.ed25519); + + final newRk = await kdfRk(rk, await dh(dhs, dhr!, 2)); + rk = newRk; + ckr = newRk; + dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + final newNewRk = await kdfRk(rk, await dh(dhs, dhr!, 2)); + rk = newNewRk; + cks = newNewRk; + } + + Future> ratchetDecrypt(OMEMOMessage header, List ciphertext) async { + // Check if we skipped too many messages + final plaintext = await trySkippedMessageKeys(header, ciphertext); + if (plaintext != null) { + return plaintext; + } + + if (header.dhPub != await dhr?.getBytes()) { + await skipMessageKeys(header.pn); + await dhRatchet(header); + } + + await skipMessageKeys(header.n); + final newCkr = await kdfCk(ckr!, kdfCkNextChainKey); + final mk = await kdfCk(ckr!, kdfCkNextMessageKey); + ckr = newCkr; + nr++; + + return decrypt(mk, ciphertext, concat([sessionAd, header.writeToBuffer()]), sessionAd); + } } diff --git a/lib/src/double_ratchet/crypto.dart b/lib/src/double_ratchet/crypto.dart new file mode 100644 index 0000000..7568409 --- /dev/null +++ b/lib/src/double_ratchet/crypto.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'package:omemo_dart/protobuf/schema.pb.dart'; +import 'package:omemo_dart/src/helpers.dart'; + +/// Info string for ENCRYPT +const encryptHkdfInfoString = 'OMEMO Message Key Material'; + +/// cryptography _really_ wants to check the MAC output from AES-256-CBC. Since +/// we don't have it, we need the MAC check to always "pass". +class NoMacSecretBox extends SecretBox { + NoMacSecretBox(super.cipherText, { required super.nonce }) : super(mac: Mac.empty); + + @override + Future checkMac({ + required MacAlgorithm macAlgorithm, + required SecretKey secretKey, + required List aad, + }) async {} +} + +/// Signals ENCRYPT function as specified by OMEMO 0.8.0. +/// Encrypt [plaintext] using the message key [mk], given associated_data [associatedData] +/// and the AD output from the X3DH [sessionAd]. +Future> encrypt(List mk, List plaintext, List associatedData, List sessionAd) async { + final hkdf = Hkdf( + hmac: Hmac(Sha256()), + outputLength: 80, + ); + final hkdfResult = await hkdf.deriveKey( + secretKey: SecretKey(mk), + nonce: List.filled(32, 0x0), + info: utf8.encode(encryptHkdfInfoString), + ); + final hkdfBytes = await hkdfResult.extractBytes(); + + // Split hkdfBytes into encryption, authentication key and IV + final encryptionKey = hkdfBytes.sublist(0, 32); + final authenticationKey = hkdfBytes.sublist(32, 64); + final iv = hkdfBytes.sublist(64, 80); + + final aesResult = await AesCbc.with256bits( + macAlgorithm: MacAlgorithm.empty, + ).encrypt( + plaintext, + secretKey: SecretKey(encryptionKey), + nonce: iv, + ); + + final header = OMEMOMessage.fromBuffer(associatedData.sublist(sessionAd.length)) + ..ciphertext = aesResult.cipherText; + final headerBytes = header.writeToBuffer(); + final hmacInput = concat([sessionAd, headerBytes]); + final hmacResult = (await Hmac.sha256().calculateMac( + hmacInput, + secretKey: SecretKey(authenticationKey), + )).bytes.sublist(0, 16); + + final message = OMEMOAuthenticatedMessage() + ..mac = hmacResult + ..message = headerBytes; + return message.writeToBuffer(); +} + +/// Signals DECRYPT function as specified by OMEMO 0.8.0. +/// Decrypt [ciphertext] with the message key [mk], given the associated_data [associatedData] +/// and the AD output from the X3DH. +Future> decrypt(List mk, List ciphertext, List associatedData, List sessionAd) async { + // Generate the keys and iv from mk + final hkdf = Hkdf( + hmac: Hmac(Sha256()), + outputLength: 80, + ); + final hkdfResult = await hkdf.deriveKey( + secretKey: SecretKey(mk), + nonce: List.filled(32, 0x0), + info: utf8.encode(encryptHkdfInfoString), + ); + final hkdfBytes = await hkdfResult.extractBytes(); + + // Split hkdfBytes into encryption, authentication key and IV + final encryptionKey = hkdfBytes.sublist(0, 32); + final authenticationKey = hkdfBytes.sublist(32, 64); + final iv = hkdfBytes.sublist(64, 80); + + // Assumption ciphertext is a OMEMOAuthenticatedMessage + final message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext); + final header = OMEMOMessage.fromBuffer(message.message); + + final hmacInput = concat([sessionAd, header.writeToBuffer()]); + final hmacResult = (await Hmac.sha256().calculateMac( + hmacInput, + secretKey: SecretKey(authenticationKey), + )).bytes.sublist(0, 16); + + // TODO(PapaTutuWawa): Check the HMAC result + + final plaintext = await AesCbc.with256bits( + macAlgorithm: MacAlgorithm.empty, + ).decrypt( + NoMacSecretBox( + header.ciphertext, + nonce: iv, + ), + secretKey: SecretKey(encryptionKey), + ); + + return plaintext; +} diff --git a/lib/src/double_ratchet/kdf.dart b/lib/src/double_ratchet/kdf.dart new file mode 100644 index 0000000..751e208 --- /dev/null +++ b/lib/src/double_ratchet/kdf.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; + +/// Info string for KDF_RK +const kdfRkInfoString = 'OMEMO Root Chain'; + +/// Flags for KDF_CK +const kdfCkNextMessageKey = 0x01; +const kdfCkNextChainKey = 0x02; + +/// Signals KDF_CK function as specified by OMEMO 0.8.0. +Future> kdfCk(List ck, int constant) async { + final hkdf = Hkdf(hmac: Hmac(Sha256()), outputLength: 32); + final result = await hkdf.deriveKey( + secretKey: SecretKey(ck), + nonce: [constant], + ); + + return result.extractBytes(); +} + +/// Signals KDF_RK function as specified by OMEMO 0.8.0. +Future> kdfRk(List rk, List dhOut) async { + final algorithm = Hkdf( + hmac: Hmac(Sha256()), + outputLength: 32, + ); + final result = await algorithm.deriveKey( + secretKey: SecretKey(dhOut), + nonce: rk, + info: utf8.encode(kdfRkInfoString), + ); + + return result.extractBytes(); +} diff --git a/lib/src/x3dh.dart b/lib/src/x3dh.dart index f674382..5a16b62 100644 --- a/lib/src/x3dh.dart +++ b/lib/src/x3dh.dart @@ -59,6 +59,9 @@ Future> dh(OmemoKeyPair kp, OmemoPublicKey pk, int identityKey) async ckp = await kp.toCurve25519(); } else if (identityKey == 2) { cpk = await pk.toCurve25519(); + } else if (identityKey == 3) { + ckp = await kp.toCurve25519(); + cpk = await pk.toCurve25519(); } final shared = await Cryptography.instance.x25519().sharedSecretKey( diff --git a/test/double_ratchet_test.dart b/test/double_ratchet_test.dart new file mode 100644 index 0000000..ac464eb --- /dev/null +++ b/test/double_ratchet_test.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'package:omemo_dart/omemo_dart.dart'; +import 'package:omemo_dart/protobuf/schema.pb.dart'; +import 'package:omemo_dart/src/double_ratchet/crypto.dart'; +import 'package:test/test.dart'; + +void main() { + test('Test encrypting and decrypting', () async { + final sessionAd = List.filled(32, 0x0); + final mk = List.filled(32, 0x1); + final plaintext = utf8.encode('Hallo'); + final header = OMEMOMessage() + ..n = 0 + ..pn = 0 + ..dhPub = List.empty(); + final asd = concat([sessionAd, header.writeToBuffer()]); + + final ciphertext = await encrypt( + mk, + plaintext, + asd, + sessionAd, + ); + + final decrypted = await decrypt( + mk, + ciphertext, + asd, + sessionAd, + ); + + expect(decrypted, plaintext); + }); + + /* + test('Test the Double Ratchet', () async { + // Generate keys + final ikAlice = await OmemoKeyPair.generateNewPair(KeyPairType.ed25519); + final ikBob = await OmemoKeyPair.generateNewPair(KeyPairType.ed25519); + final spkBob = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + final opkBob = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); + final bundleBob = OmemoBundle( + '1', + await spkBob.pk.asBase64(), + '3', + base64Encode( + await sig(ikBob, await spkBob.pk.getBytes()), + ), + //'Q5in+/L4kJixEX692h6mJkPMyp4I3SlQ84L0E7ipPzqfPHOMiraUlqG2vG/O8wvFjLsKYZpPBraga9IvwhqVDA==', + await ikBob.pk.asBase64(), + { + '2': await opkBob.pk.asBase64(), + }, + ); + + // Alice does X3DH + final resultAlice = await x3dhFromBundle(bundleBob, ikAlice); + + // Alice sends the inital message to Bob + // ... + + // Bob does X3DH + final resultBob = await x3dhFromInitialMessage( + X3DHMessage( + ikAlice.pk, + resultAlice.ek.pk, + '2', + ), + spkBob, + opkBob, + ikBob, + ); + + print('X3DH key exchange done'); + + // Alice and Bob now share sk as a common secret and ad + final alicesRatchet = await OmemoDoubleRatchet.initiateNewSession( + spkBob.pk, + resultAlice.sk, + resultAlice.ad, + ); + final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( + spkBob, + resultBob.sk, + resultBob.ad, + ); + + expect(alicesRatchet.sessionAd, bobsRatchet.sessionAd); + //expect(await alicesRatchet.dhr.getBytes(), await ikBob.pk.getBytes()); + + // Alice encrypts a message + final aliceRatchetResult = await alicesRatchet.ratchetEncrypt(utf8.encode('Hello Bob')); + print('Alice sent the message'); + + // Alice sends it to Bob + // ... + + // Bob tries to decrypt it + final bobRatchetResult = await bobsRatchet.ratchetDecrypt( + aliceRatchetResult.header, + aliceRatchetResult.ciphertext, + ); + print('Bob decrypted the message'); + + expect(utf8.encode('Hello Bob'), bobRatchetResult); + }); + */ +}