feat: Remove custom protobuf parsing
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import 'package:omemo_dart/protobuf/schema.pb.dart';
|
||||
import 'package:omemo_dart/src/crypto.dart';
|
||||
import 'package:omemo_dart/src/errors.dart';
|
||||
import 'package:omemo_dart/src/helpers.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_message.dart';
|
||||
|
||||
/// Info string for ENCRYPT
|
||||
const encryptHkdfInfoString = 'OMEMO Message Key Material';
|
||||
@@ -22,12 +21,12 @@ Future<List<int>> encrypt(
|
||||
await aes256CbcEncrypt(plaintext, keys.encryptionKey, keys.iv);
|
||||
|
||||
final header =
|
||||
OmemoMessage.fromBuffer(associatedData.sublist(sessionAd.length))
|
||||
OMEMOMessage.fromBuffer(associatedData.sublist(sessionAd.length))
|
||||
..ciphertext = ciphertext;
|
||||
final headerBytes = header.writeToBuffer();
|
||||
final hmacInput = concat([sessionAd, headerBytes]);
|
||||
final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey);
|
||||
final message = OmemoAuthenticatedMessage()
|
||||
final message = OMEMOAuthenticatedMessage()
|
||||
..mac = hmacResult
|
||||
..message = headerBytes;
|
||||
return message.writeToBuffer();
|
||||
@@ -46,15 +45,15 @@ Future<List<int>> decrypt(
|
||||
final keys = await deriveEncryptionKeys(mk, encryptHkdfInfoString);
|
||||
|
||||
// Assumption ciphertext is a OMEMOAuthenticatedMessage
|
||||
final message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
final header = OmemoMessage.fromBuffer(message.message!);
|
||||
final message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
final header = OMEMOMessage.fromBuffer(message.message);
|
||||
|
||||
final hmacInput = concat([sessionAd, header.writeToBuffer()]);
|
||||
final hmacResult = await truncatedHmac(hmacInput, keys.authenticationKey);
|
||||
|
||||
if (!listsEqual(hmacResult, message.mac!)) {
|
||||
if (!listsEqual(hmacResult, message.mac)) {
|
||||
throw InvalidMessageHMACException();
|
||||
}
|
||||
|
||||
return aes256CbcDecrypt(header.ciphertext!, keys.encryptionKey, keys.iv);
|
||||
return aes256CbcDecrypt(header.ciphertext, keys.encryptionKey, keys.iv);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@ 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/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/omemo_message.dart';
|
||||
|
||||
/// Amount of messages we may skip per session
|
||||
const maxSkip = 1000;
|
||||
|
||||
class RatchetStep {
|
||||
const RatchetStep(this.header, this.ciphertext);
|
||||
final OmemoMessage header;
|
||||
final OMEMOMessage header;
|
||||
final List<int> ciphertext;
|
||||
}
|
||||
|
||||
@@ -274,12 +274,12 @@ class OmemoDoubleRatchet {
|
||||
}
|
||||
|
||||
Future<List<int>?> _trySkippedMessageKeys(
|
||||
OmemoMessage header,
|
||||
OMEMOMessage header,
|
||||
List<int> ciphertext,
|
||||
) async {
|
||||
final key = SkippedKey(
|
||||
OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519),
|
||||
header.n!,
|
||||
OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519),
|
||||
header.n,
|
||||
);
|
||||
if (mkSkipped.containsKey(key)) {
|
||||
final mk = mkSkipped[key]!;
|
||||
@@ -312,11 +312,11 @@ class OmemoDoubleRatchet {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dhRatchet(OmemoMessage header) async {
|
||||
Future<void> _dhRatchet(OMEMOMessage header) async {
|
||||
pn = ns;
|
||||
ns = 0;
|
||||
nr = 0;
|
||||
dhr = OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519);
|
||||
dhr = OmemoPublicKey.fromBytes(header.dhPub, KeyPairType.x25519);
|
||||
|
||||
final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0));
|
||||
rk = List.from(newRk);
|
||||
@@ -333,7 +333,7 @@ class OmemoDoubleRatchet {
|
||||
final mk = await kdfCk(cks!, kdfCkNextMessageKey);
|
||||
|
||||
cks = newCks;
|
||||
final header = OmemoMessage()
|
||||
final header = OMEMOMessage()
|
||||
..dhPub = await dhs.pk.getBytes()
|
||||
..pn = pn
|
||||
..n = ns;
|
||||
@@ -356,7 +356,7 @@ class OmemoDoubleRatchet {
|
||||
///
|
||||
/// Throws an SkippingTooManyMessagesException if too many messages were to be skipped.
|
||||
Future<List<int>> ratchetDecrypt(
|
||||
OmemoMessage header,
|
||||
OMEMOMessage header,
|
||||
List<int> ciphertext,
|
||||
) async {
|
||||
// Check if we skipped too many messages
|
||||
@@ -366,15 +366,15 @@ class OmemoDoubleRatchet {
|
||||
}
|
||||
|
||||
final dhPubMatches = listsEqual(
|
||||
header.dhPub!,
|
||||
header.dhPub,
|
||||
(await dhr?.getBytes()) ?? <int>[],
|
||||
);
|
||||
if (!dhPubMatches) {
|
||||
await _skipMessageKeys(header.pn!);
|
||||
await _skipMessageKeys(header.pn);
|
||||
await _dhRatchet(header);
|
||||
}
|
||||
|
||||
await _skipMessageKeys(header.n!);
|
||||
await _skipMessageKeys(header.n);
|
||||
final newCkr = await kdfCk(ckr!, kdfCkNextChainKey);
|
||||
final mk = await kdfCk(ckr!, kdfCkNextMessageKey);
|
||||
ckr = newCkr;
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:cryptography/cryptography.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:omemo_dart/protobuf/schema.pb.dart';
|
||||
import 'package:omemo_dart/src/crypto.dart';
|
||||
import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart';
|
||||
import 'package:omemo_dart/src/errors.dart';
|
||||
@@ -21,9 +22,6 @@ import 'package:omemo_dart/src/omemo/events.dart';
|
||||
import 'package:omemo_dart/src/omemo/fingerprint.dart';
|
||||
import 'package:omemo_dart/src/omemo/ratchet_map_key.dart';
|
||||
import 'package:omemo_dart/src/omemo/stanza.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_key_exchange.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_message.dart';
|
||||
import 'package:omemo_dart/src/trust/base.dart';
|
||||
import 'package:omemo_dart/src/x3dh/x3dh.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
@@ -194,7 +192,7 @@ class OmemoManager {
|
||||
Future<OmemoDoubleRatchet> _addSessionFromKeyExchange(
|
||||
String jid,
|
||||
int deviceId,
|
||||
OmemoKeyExchange kex,
|
||||
OMEMOKeyExchange kex,
|
||||
) async {
|
||||
// Pick the correct SPK
|
||||
final device = await getDevice();
|
||||
@@ -209,17 +207,17 @@ class OmemoManager {
|
||||
|
||||
final kexResult = await x3dhFromInitialMessage(
|
||||
X3DHMessage(
|
||||
OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519),
|
||||
OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519),
|
||||
kex.pkId!,
|
||||
OmemoPublicKey.fromBytes(kex.ik, KeyPairType.ed25519),
|
||||
OmemoPublicKey.fromBytes(kex.ek, KeyPairType.x25519),
|
||||
kex.pkId,
|
||||
),
|
||||
spk,
|
||||
device.opks.values.elementAt(kex.pkId!),
|
||||
device.opks.values.elementAt(kex.pkId),
|
||||
device.ik,
|
||||
);
|
||||
final ratchet = await OmemoDoubleRatchet.acceptNewSession(
|
||||
spk,
|
||||
OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519),
|
||||
OmemoPublicKey.fromBytes(kex.ik, KeyPairType.ed25519),
|
||||
kexResult.sk,
|
||||
kexResult.ad,
|
||||
getTimestamp(),
|
||||
@@ -234,7 +232,7 @@ class OmemoManager {
|
||||
/// Create a ratchet session initiated by Alice to the user with Jid [jid] and the device
|
||||
/// [deviceId] from the bundle [bundle].
|
||||
@visibleForTesting
|
||||
Future<OmemoKeyExchange> addSessionFromBundle(
|
||||
Future<OMEMOKeyExchange> addSessionFromBundle(
|
||||
String jid,
|
||||
int deviceId,
|
||||
OmemoBundle bundle,
|
||||
@@ -255,7 +253,7 @@ class OmemoManager {
|
||||
await _trustManager.onNewSession(jid, deviceId);
|
||||
_addSession(jid, deviceId, ratchet);
|
||||
|
||||
return OmemoKeyExchange()
|
||||
return OMEMOKeyExchange()
|
||||
..pkId = kexResult.opkId
|
||||
..spkId = bundle.spkId
|
||||
..ik = await device.ik.pk.getBytes()
|
||||
@@ -312,17 +310,17 @@ class OmemoManager {
|
||||
|
||||
final decodedRawKey = base64.decode(rawKey.value);
|
||||
List<int>? keyAndHmac;
|
||||
OmemoAuthenticatedMessage authMessage;
|
||||
OmemoMessage? message;
|
||||
OMEMOAuthenticatedMessage authMessage;
|
||||
OMEMOMessage? message;
|
||||
|
||||
// If the ratchet already existed, we store it. If it didn't, oldRatchet will stay
|
||||
// null.
|
||||
final ratchetKey = RatchetMapKey(senderJid, senderDeviceId);
|
||||
final oldRatchet = getRatchet(ratchetKey)?.clone();
|
||||
if (rawKey.kex) {
|
||||
final kex = OmemoKeyExchange.fromBuffer(decodedRawKey);
|
||||
authMessage = kex.message!;
|
||||
message = OmemoMessage.fromBuffer(authMessage.message!);
|
||||
final kex = OMEMOKeyExchange.fromBuffer(decodedRawKey);
|
||||
authMessage = kex.message;
|
||||
message = OMEMOMessage.fromBuffer(authMessage.message);
|
||||
|
||||
// Guard against old key exchanges
|
||||
if (oldRatchet != null) {
|
||||
@@ -348,7 +346,7 @@ class OmemoManager {
|
||||
|
||||
// Replace the OPK
|
||||
await _deviceLock.synchronized(() async {
|
||||
device = await device.replaceOnetimePrekey(kex.pkId!);
|
||||
device = await device.replaceOnetimePrekey(kex.pkId);
|
||||
|
||||
// Commit the device
|
||||
_eventStreamController.add(DeviceModifiedEvent(device));
|
||||
@@ -374,8 +372,8 @@ class OmemoManager {
|
||||
_log.finest('Kex failed due to $ex. Not proceeding with kex.');
|
||||
}
|
||||
} else {
|
||||
authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey);
|
||||
message = OmemoMessage.fromBuffer(authMessage.message!);
|
||||
authMessage = OMEMOAuthenticatedMessage.fromBuffer(decodedRawKey);
|
||||
message = OMEMOMessage.fromBuffer(authMessage.message);
|
||||
}
|
||||
|
||||
final devices = _deviceList[senderJid];
|
||||
@@ -499,7 +497,7 @@ class OmemoManager {
|
||||
keyPayload = List<int>.filled(32, 0x0);
|
||||
}
|
||||
|
||||
final kex = <RatchetMapKey, OmemoKeyExchange>{};
|
||||
final kex = <RatchetMapKey, OMEMOKeyExchange>{};
|
||||
for (final jid in jids) {
|
||||
for (final newSession in await _fetchNewBundles(jid)) {
|
||||
kex[RatchetMapKey(jid, newSession.id)] = await addSessionFromBundle(
|
||||
@@ -551,7 +549,7 @@ class OmemoManager {
|
||||
if (kex.containsKey(ratchetKey)) {
|
||||
// The ratchet did not exist
|
||||
final k = kex[ratchetKey]!
|
||||
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
..message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
final buffer = base64.encode(k.writeToBuffer());
|
||||
encryptedKeys.add(
|
||||
EncryptedKey(
|
||||
@@ -568,8 +566,8 @@ class OmemoManager {
|
||||
// The ratchet exists but is not acked
|
||||
if (ratchet.kex != null) {
|
||||
final oldKex =
|
||||
OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!))
|
||||
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
OMEMOKeyExchange.fromBuffer(base64.decode(ratchet.kex!))
|
||||
..message = OMEMOAuthenticatedMessage.fromBuffer(ciphertext);
|
||||
|
||||
encryptedKeys.add(
|
||||
EncryptedKey(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:omemo_dart/src/helpers.dart';
|
||||
import 'package:omemo_dart/src/protobuf/protobuf.dart';
|
||||
|
||||
class OmemoAuthenticatedMessage {
|
||||
OmemoAuthenticatedMessage();
|
||||
|
||||
factory OmemoAuthenticatedMessage.fromBuffer(List<int> data) {
|
||||
var i = 0;
|
||||
|
||||
// required bytes mac = 1;
|
||||
if (data[0] != fieldId(1, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final mac = data.sublist(2, i + 2 + data[1]);
|
||||
i += data[1] + 2;
|
||||
|
||||
if (data[i] != fieldId(2, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final message = data.sublist(i + 2, i + 2 + data[i + 1]);
|
||||
|
||||
return OmemoAuthenticatedMessage()
|
||||
..mac = mac
|
||||
..message = message;
|
||||
}
|
||||
|
||||
List<int>? mac;
|
||||
List<int>? message;
|
||||
|
||||
List<int> writeToBuffer() {
|
||||
return concat([
|
||||
[fieldId(1, fieldTypeByteArray), mac!.length],
|
||||
mac!,
|
||||
[fieldId(2, fieldTypeByteArray), message!.length],
|
||||
message!,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:omemo_dart/src/helpers.dart';
|
||||
import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart';
|
||||
import 'package:omemo_dart/src/protobuf/protobuf.dart';
|
||||
|
||||
class OmemoKeyExchange {
|
||||
OmemoKeyExchange();
|
||||
|
||||
factory OmemoKeyExchange.fromBuffer(List<int> data) {
|
||||
var i = 0;
|
||||
|
||||
if (data[i] != fieldId(1, fieldTypeUint32)) {
|
||||
throw Exception();
|
||||
}
|
||||
var decoded = decodeVarint(data, 1);
|
||||
final pkId = decoded.n;
|
||||
i += decoded.length + 1;
|
||||
|
||||
if (data[i] != fieldId(2, fieldTypeUint32)) {
|
||||
throw Exception();
|
||||
}
|
||||
decoded = decodeVarint(data, i + 1);
|
||||
final spkId = decoded.n;
|
||||
i += decoded.length + 1;
|
||||
|
||||
if (data[i] != fieldId(3, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final ik = data.sublist(i + 2, i + 2 + data[i + 1]);
|
||||
i += 2 + data[i + 1];
|
||||
|
||||
if (data[i] != fieldId(4, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final ek = data.sublist(i + 2, i + 2 + data[i + 1]);
|
||||
i += 2 + data[i + 1];
|
||||
|
||||
if (data[i] != fieldId(5, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final message = OmemoAuthenticatedMessage.fromBuffer(data.sublist(i + 2));
|
||||
|
||||
return OmemoKeyExchange()
|
||||
..pkId = pkId
|
||||
..spkId = spkId
|
||||
..ik = ik
|
||||
..ek = ek
|
||||
..message = message;
|
||||
}
|
||||
|
||||
int? pkId;
|
||||
int? spkId;
|
||||
List<int>? ik;
|
||||
List<int>? ek;
|
||||
OmemoAuthenticatedMessage? message;
|
||||
|
||||
List<int> writeToBuffer() {
|
||||
final msg = message!.writeToBuffer();
|
||||
return concat([
|
||||
[fieldId(1, fieldTypeUint32)],
|
||||
encodeVarint(pkId!),
|
||||
[fieldId(2, fieldTypeUint32)],
|
||||
encodeVarint(spkId!),
|
||||
[fieldId(3, fieldTypeByteArray), ik!.length],
|
||||
ik!,
|
||||
[fieldId(4, fieldTypeByteArray), ek!.length],
|
||||
ek!,
|
||||
[fieldId(5, fieldTypeByteArray), msg.length],
|
||||
msg,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:omemo_dart/src/helpers.dart';
|
||||
import 'package:omemo_dart/src/protobuf/protobuf.dart';
|
||||
|
||||
class OmemoMessage {
|
||||
OmemoMessage();
|
||||
|
||||
factory OmemoMessage.fromBuffer(List<int> data) {
|
||||
var i = 0;
|
||||
|
||||
// required uint32 n = 1;
|
||||
if (data[0] != fieldId(1, fieldTypeUint32)) {
|
||||
throw Exception();
|
||||
}
|
||||
var decode = decodeVarint(data, 1);
|
||||
final n = decode.n;
|
||||
i += decode.length + 1;
|
||||
|
||||
// required uint32 pn = 2;
|
||||
if (data[i] != fieldId(2, fieldTypeUint32)) {
|
||||
throw Exception();
|
||||
}
|
||||
decode = decodeVarint(data, i + 1);
|
||||
final pn = decode.n;
|
||||
i += decode.length + 1;
|
||||
|
||||
// required bytes dh_pub = 3;
|
||||
if (data[i] != fieldId(3, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
final dhPub = data.sublist(i + 2, i + 2 + data[i + 1]);
|
||||
i += 2 + data[i + 1];
|
||||
|
||||
// optional bytes ciphertext = 4;
|
||||
List<int>? ciphertext;
|
||||
if (i < data.length) {
|
||||
if (data[i] != fieldId(4, fieldTypeByteArray)) {
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
ciphertext = data.sublist(i + 2, i + 2 + data[i + 1]);
|
||||
}
|
||||
|
||||
return OmemoMessage()
|
||||
..n = n
|
||||
..pn = pn
|
||||
..dhPub = dhPub
|
||||
..ciphertext = ciphertext;
|
||||
}
|
||||
|
||||
int? n;
|
||||
int? pn;
|
||||
List<int>? dhPub;
|
||||
List<int>? ciphertext;
|
||||
|
||||
List<int> writeToBuffer() {
|
||||
final data = concat([
|
||||
[fieldId(1, fieldTypeUint32)],
|
||||
encodeVarint(n!),
|
||||
[fieldId(2, fieldTypeUint32)],
|
||||
encodeVarint(pn!),
|
||||
[fieldId(3, fieldTypeByteArray), dhPub!.length],
|
||||
dhPub!,
|
||||
]);
|
||||
|
||||
if (ciphertext != null) {
|
||||
return concat([
|
||||
data,
|
||||
[fieldId(4, fieldTypeByteArray), ciphertext!.length],
|
||||
ciphertext!,
|
||||
]);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/// Masks the 7 LSB
|
||||
const lsb7Mask = 0x7F;
|
||||
|
||||
/// Constant for setting the MSB
|
||||
const msb = 1 << 7;
|
||||
|
||||
/// Field types
|
||||
const fieldTypeUint32 = 0;
|
||||
const fieldTypeByteArray = 2;
|
||||
|
||||
int fieldId(int number, int type) {
|
||||
return (number << 3) | type;
|
||||
}
|
||||
|
||||
class VarintDecode {
|
||||
const VarintDecode(this.n, this.length);
|
||||
final int n;
|
||||
final int length;
|
||||
}
|
||||
|
||||
/// Decode a Varint that begins at [input]'s index [offset].
|
||||
VarintDecode decodeVarint(List<int> input, int offset) {
|
||||
// The return value
|
||||
var n = 0;
|
||||
// The byte offset counter
|
||||
var i = 0;
|
||||
|
||||
// Iterate until the MSB of the byte is 0
|
||||
while (true) {
|
||||
// Mask only the 7 LSB and "move" them accordingly
|
||||
n += (input[offset + i] & lsb7Mask) << (7 * i);
|
||||
|
||||
// Break if we reached the end
|
||||
if (input[offset + i] & 1 << 7 == 0) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return VarintDecode(n, i + 1);
|
||||
}
|
||||
|
||||
// Encodes the integer [i] into a Varint.
|
||||
List<int> encodeVarint(int i) {
|
||||
assert(i >= 0, "Two's complement is not implemented");
|
||||
final ret = List<int>.empty(growable: true);
|
||||
|
||||
// Thanks to https://github.com/hathibelagal-dev/LEB128 for the trick with toRadixString!
|
||||
final numSevenBlocks = (i.toRadixString(2).length / 7).ceil();
|
||||
for (var j = 0; j < numSevenBlocks; j++) {
|
||||
// The 7 LSB of the byte we're creating
|
||||
final x = (i & (lsb7Mask << j * 7)) >> j * 7;
|
||||
|
||||
if (j == numSevenBlocks - 1) {
|
||||
// If we were to shift further, we only get zero, so we're at the end
|
||||
ret.add(x);
|
||||
} else {
|
||||
// We still have at least one bit more to go, so set the MSB to 1
|
||||
ret.add(x + msb);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
Reference in New Issue
Block a user