mirror of
https://github.com/AsamK/signal-cli
synced 2025-08-29 10:30:38 +00:00
Decrypt and verify the profile payment address
This commit is contained in:
parent
3666531f8b
commit
bf75d9b4e0
7 changed files with 129 additions and 36 deletions
|
@ -538,7 +538,7 @@
|
||||||
{"name":"familyName","parameterTypes":[] },
|
{"name":"familyName","parameterTypes":[] },
|
||||||
{"name":"givenName","parameterTypes":[] },
|
{"name":"givenName","parameterTypes":[] },
|
||||||
{"name":"lastUpdateTimestamp","parameterTypes":[] },
|
{"name":"lastUpdateTimestamp","parameterTypes":[] },
|
||||||
{"name":"paymentAddress","parameterTypes":[] }
|
{"name":"mobileCoinAddress","parameterTypes":[] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -2846,6 +2846,21 @@
|
||||||
{"name":"padding_"}
|
{"name":"padding_"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$PaymentAddress",
|
||||||
|
"fields":[
|
||||||
|
{"name":"addressCase_"},
|
||||||
|
{"name":"address_"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$PaymentAddress$MobileCoinAddress",
|
||||||
|
"fields":[
|
||||||
|
{"name":"address_"},
|
||||||
|
{"name":"bitField0_"},
|
||||||
|
{"name":"signature_"}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$Preview",
|
"name":"org.whispersystems.signalservice.internal.push.SignalServiceProtos$Preview",
|
||||||
"fields":[
|
"fields":[
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
package org.asamk.signal.manager.helper;
|
package org.asamk.signal.manager.helper;
|
||||||
|
|
||||||
import com.google.protobuf.InvalidProtocolBufferException;
|
|
||||||
|
|
||||||
import org.asamk.signal.manager.SignalDependencies;
|
import org.asamk.signal.manager.SignalDependencies;
|
||||||
import org.asamk.signal.manager.config.ServiceConfig;
|
import org.asamk.signal.manager.config.ServiceConfig;
|
||||||
import org.asamk.signal.manager.groups.GroupNotFoundException;
|
import org.asamk.signal.manager.groups.GroupNotFoundException;
|
||||||
|
@ -13,6 +11,7 @@ import org.asamk.signal.manager.storage.recipients.RecipientAddress;
|
||||||
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
import org.asamk.signal.manager.storage.recipients.RecipientId;
|
||||||
import org.asamk.signal.manager.util.IOUtils;
|
import org.asamk.signal.manager.util.IOUtils;
|
||||||
import org.asamk.signal.manager.util.KeyUtils;
|
import org.asamk.signal.manager.util.KeyUtils;
|
||||||
|
import org.asamk.signal.manager.util.PaymentUtils;
|
||||||
import org.asamk.signal.manager.util.ProfileUtils;
|
import org.asamk.signal.manager.util.ProfileUtils;
|
||||||
import org.asamk.signal.manager.util.Utils;
|
import org.asamk.signal.manager.util.Utils;
|
||||||
import org.signal.libsignal.protocol.IdentityKey;
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
|
@ -29,7 +28,6 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -185,13 +183,9 @@ public final class ProfileHelper {
|
||||||
final var avatarUploadParams = streamDetails != null
|
final var avatarUploadParams = streamDetails != null
|
||||||
? AvatarUploadParams.forAvatar(streamDetails)
|
? AvatarUploadParams.forAvatar(streamDetails)
|
||||||
: avatar == null ? AvatarUploadParams.unchanged(true) : AvatarUploadParams.unchanged(false);
|
: avatar == null ? AvatarUploadParams.unchanged(true) : AvatarUploadParams.unchanged(false);
|
||||||
final var paymentsAddress = Optional.ofNullable(newProfile.getPaymentAddress()).map(data -> {
|
final var paymentsAddress = Optional.ofNullable(newProfile.getMobileCoinAddress())
|
||||||
try {
|
.map(address -> PaymentUtils.signPaymentsAddress(address,
|
||||||
return SignalServiceProtos.PaymentAddress.parseFrom(data);
|
account.getAciIdentityKeyPair().getPrivateKey()));
|
||||||
} catch (InvalidProtocolBufferException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.debug("Uploading new profile");
|
logger.debug("Uploading new profile");
|
||||||
final var avatarPath = dependencies.getAccountManager()
|
final var avatarPath = dependencies.getAccountManager()
|
||||||
.setVersionedProfile(account.getAci(),
|
.setVersionedProfile(account.getAci(),
|
||||||
|
|
|
@ -20,7 +20,7 @@ public class Profile {
|
||||||
|
|
||||||
private final String avatarUrlPath;
|
private final String avatarUrlPath;
|
||||||
|
|
||||||
private final byte[] paymentAddress;
|
private final byte[] mobileCoinAddress;
|
||||||
|
|
||||||
private final UnidentifiedAccessMode unidentifiedAccessMode;
|
private final UnidentifiedAccessMode unidentifiedAccessMode;
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ public class Profile {
|
||||||
final String about,
|
final String about,
|
||||||
final String aboutEmoji,
|
final String aboutEmoji,
|
||||||
final String avatarUrlPath,
|
final String avatarUrlPath,
|
||||||
final byte[] paymentAddress,
|
final byte[] mobileCoinAddress,
|
||||||
final UnidentifiedAccessMode unidentifiedAccessMode,
|
final UnidentifiedAccessMode unidentifiedAccessMode,
|
||||||
final Set<Capability> capabilities
|
final Set<Capability> capabilities
|
||||||
) {
|
) {
|
||||||
|
@ -43,7 +43,7 @@ public class Profile {
|
||||||
this.about = about;
|
this.about = about;
|
||||||
this.aboutEmoji = aboutEmoji;
|
this.aboutEmoji = aboutEmoji;
|
||||||
this.avatarUrlPath = avatarUrlPath;
|
this.avatarUrlPath = avatarUrlPath;
|
||||||
this.paymentAddress = paymentAddress;
|
this.mobileCoinAddress = mobileCoinAddress;
|
||||||
this.unidentifiedAccessMode = unidentifiedAccessMode;
|
this.unidentifiedAccessMode = unidentifiedAccessMode;
|
||||||
this.capabilities = capabilities;
|
this.capabilities = capabilities;
|
||||||
}
|
}
|
||||||
|
@ -55,7 +55,7 @@ public class Profile {
|
||||||
about = builder.about;
|
about = builder.about;
|
||||||
aboutEmoji = builder.aboutEmoji;
|
aboutEmoji = builder.aboutEmoji;
|
||||||
avatarUrlPath = builder.avatarUrlPath;
|
avatarUrlPath = builder.avatarUrlPath;
|
||||||
paymentAddress = builder.paymentAddress;
|
mobileCoinAddress = builder.mobileCoinAddress;
|
||||||
unidentifiedAccessMode = builder.unidentifiedAccessMode;
|
unidentifiedAccessMode = builder.unidentifiedAccessMode;
|
||||||
capabilities = builder.capabilities;
|
capabilities = builder.capabilities;
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ public class Profile {
|
||||||
builder.about = copy.getAbout();
|
builder.about = copy.getAbout();
|
||||||
builder.aboutEmoji = copy.getAboutEmoji();
|
builder.aboutEmoji = copy.getAboutEmoji();
|
||||||
builder.avatarUrlPath = copy.getAvatarUrlPath();
|
builder.avatarUrlPath = copy.getAvatarUrlPath();
|
||||||
builder.paymentAddress = copy.getPaymentAddress();
|
builder.mobileCoinAddress = copy.getMobileCoinAddress();
|
||||||
builder.unidentifiedAccessMode = copy.getUnidentifiedAccessMode();
|
builder.unidentifiedAccessMode = copy.getUnidentifiedAccessMode();
|
||||||
builder.capabilities = copy.getCapabilities();
|
builder.capabilities = copy.getCapabilities();
|
||||||
return builder;
|
return builder;
|
||||||
|
@ -124,8 +124,8 @@ public class Profile {
|
||||||
return avatarUrlPath;
|
return avatarUrlPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] getPaymentAddress() {
|
public byte[] getMobileCoinAddress() {
|
||||||
return paymentAddress;
|
return mobileCoinAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UnidentifiedAccessMode getUnidentifiedAccessMode() {
|
public UnidentifiedAccessMode getUnidentifiedAccessMode() {
|
||||||
|
@ -200,7 +200,7 @@ public class Profile {
|
||||||
private String about;
|
private String about;
|
||||||
private String aboutEmoji;
|
private String aboutEmoji;
|
||||||
private String avatarUrlPath;
|
private String avatarUrlPath;
|
||||||
private byte[] paymentAddress;
|
private byte[] mobileCoinAddress;
|
||||||
private UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.UNKNOWN;
|
private UnidentifiedAccessMode unidentifiedAccessMode = UnidentifiedAccessMode.UNKNOWN;
|
||||||
private Set<Capability> capabilities = Collections.emptySet();
|
private Set<Capability> capabilities = Collections.emptySet();
|
||||||
private long lastUpdateTimestamp = 0;
|
private long lastUpdateTimestamp = 0;
|
||||||
|
@ -252,8 +252,8 @@ public class Profile {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Builder withPaymentAddress(final byte[] val) {
|
public Builder withMobileCoinAddress(final byte[] val) {
|
||||||
paymentAddress = val;
|
mobileCoinAddress = val;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,9 +105,9 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile
|
||||||
r.profile.about,
|
r.profile.about,
|
||||||
r.profile.aboutEmoji,
|
r.profile.aboutEmoji,
|
||||||
r.profile.avatarUrlPath,
|
r.profile.avatarUrlPath,
|
||||||
r.profile.paymentAddress == null
|
r.profile.mobileCoinAddress == null
|
||||||
? null
|
? null
|
||||||
: Base64.getDecoder().decode(r.profile.paymentAddress),
|
: Base64.getDecoder().decode(r.profile.mobileCoinAddress),
|
||||||
Profile.UnidentifiedAccessMode.valueOfOrUnknown(r.profile.unidentifiedAccessMode),
|
Profile.UnidentifiedAccessMode.valueOfOrUnknown(r.profile.unidentifiedAccessMode),
|
||||||
r.profile.capabilities.stream()
|
r.profile.capabilities.stream()
|
||||||
.map(Profile.Capability::valueOfOrNull)
|
.map(Profile.Capability::valueOfOrNull)
|
||||||
|
@ -592,9 +592,9 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile
|
||||||
recipientProfile.getAbout(),
|
recipientProfile.getAbout(),
|
||||||
recipientProfile.getAboutEmoji(),
|
recipientProfile.getAboutEmoji(),
|
||||||
recipientProfile.getAvatarUrlPath(),
|
recipientProfile.getAvatarUrlPath(),
|
||||||
recipientProfile.getPaymentAddress() == null
|
recipientProfile.getMobileCoinAddress() == null
|
||||||
? null
|
? null
|
||||||
: base64.encodeToString(recipientProfile.getPaymentAddress()),
|
: base64.encodeToString(recipientProfile.getMobileCoinAddress()),
|
||||||
recipientProfile.getUnidentifiedAccessMode().name(),
|
recipientProfile.getUnidentifiedAccessMode().name(),
|
||||||
recipientProfile.getCapabilities().stream().map(Enum::name).collect(Collectors.toSet()));
|
recipientProfile.getCapabilities().stream().map(Enum::name).collect(Collectors.toSet()));
|
||||||
return new Storage.Recipient(pair.getKey().id(),
|
return new Storage.Recipient(pair.getKey().id(),
|
||||||
|
@ -651,7 +651,7 @@ public class RecipientStore implements RecipientResolver, ContactsStore, Profile
|
||||||
String about,
|
String about,
|
||||||
String aboutEmoji,
|
String aboutEmoji,
|
||||||
String avatarUrlPath,
|
String avatarUrlPath,
|
||||||
String paymentAddress,
|
String mobileCoinAddress,
|
||||||
String unidentifiedAccessMode,
|
String unidentifiedAccessMode,
|
||||||
Set<String> capabilities
|
Set<String> capabilities
|
||||||
) {}
|
) {}
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.asamk.signal.manager.util;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
|
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||||
|
import org.signal.libsignal.protocol.ecc.ECPrivateKey;
|
||||||
|
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||||
|
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||||
|
|
||||||
|
public class PaymentUtils {
|
||||||
|
|
||||||
|
private PaymentUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the supplied address bytes with the {@link IdentityKeyPair}'s private key and returns a proto that includes it, and it's signature.
|
||||||
|
*/
|
||||||
|
public static SignalServiceProtos.PaymentAddress signPaymentsAddress(
|
||||||
|
byte[] publicAddressBytes, ECPrivateKey privateKey
|
||||||
|
) {
|
||||||
|
byte[] signature = privateKey.calculateSignature(publicAddressBytes);
|
||||||
|
|
||||||
|
return SignalServiceProtos.PaymentAddress.newBuilder()
|
||||||
|
.setMobileCoinAddress(SignalServiceProtos.PaymentAddress.MobileCoinAddress.newBuilder()
|
||||||
|
.setAddress(ByteString.copyFrom(publicAddressBytes))
|
||||||
|
.setSignature(ByteString.copyFrom(signature)))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the payments address is signed with the supplied {@link IdentityKey}.
|
||||||
|
* <p>
|
||||||
|
* Returns the validated bytes if so, otherwise returns null.
|
||||||
|
*/
|
||||||
|
public static byte[] verifyPaymentsAddress(
|
||||||
|
SignalServiceProtos.PaymentAddress paymentAddress, ECPublicKey publicKey
|
||||||
|
) {
|
||||||
|
if (!paymentAddress.hasMobileCoinAddress()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = paymentAddress.getMobileCoinAddress().getAddress().toByteArray();
|
||||||
|
byte[] signature = paymentAddress.getMobileCoinAddress().getSignature().toByteArray();
|
||||||
|
|
||||||
|
if (signature.length != 64 || !publicKey.verifySignature(bytes, signature)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,21 @@
|
||||||
package org.asamk.signal.manager.util;
|
package org.asamk.signal.manager.util;
|
||||||
|
|
||||||
|
import com.google.protobuf.InvalidProtocolBufferException;
|
||||||
|
|
||||||
import org.asamk.signal.manager.api.Pair;
|
import org.asamk.signal.manager.api.Pair;
|
||||||
import org.asamk.signal.manager.storage.recipients.Profile;
|
import org.asamk.signal.manager.storage.recipients.Profile;
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
|
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||||
|
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||||
|
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
|
||||||
|
@ -20,6 +27,12 @@ public class ProfileUtils {
|
||||||
final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
|
final ProfileKey profileKey, final SignalServiceProfile encryptedProfile
|
||||||
) {
|
) {
|
||||||
var profileCipher = new ProfileCipher(profileKey);
|
var profileCipher = new ProfileCipher(profileKey);
|
||||||
|
IdentityKey identityKey = null;
|
||||||
|
try {
|
||||||
|
identityKey = new IdentityKey(Base64.getDecoder().decode(encryptedProfile.getIdentityKey()), 0);
|
||||||
|
} catch (InvalidKeyException ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var name = decrypt(encryptedProfile.getName(), profileCipher);
|
var name = decrypt(encryptedProfile.getName(), profileCipher);
|
||||||
var about = trimZeros(decrypt(encryptedProfile.getAbout(), profileCipher));
|
var about = trimZeros(decrypt(encryptedProfile.getAbout(), profileCipher));
|
||||||
|
@ -32,7 +45,11 @@ public class ProfileUtils {
|
||||||
about,
|
about,
|
||||||
aboutEmoji,
|
aboutEmoji,
|
||||||
encryptedProfile.getAvatar(),
|
encryptedProfile.getAvatar(),
|
||||||
encryptedProfile.getPaymentAddress(),
|
identityKey == null || encryptedProfile.getPaymentAddress() == null
|
||||||
|
? null
|
||||||
|
: decryptAndVerifyMobileCoinAddress(encryptedProfile.getPaymentAddress(),
|
||||||
|
profileCipher,
|
||||||
|
identityKey.getPublicKey()),
|
||||||
getUnidentifiedAccessMode(encryptedProfile, profileCipher),
|
getUnidentifiedAccessMode(encryptedProfile, profileCipher),
|
||||||
getCapabilities(encryptedProfile));
|
getCapabilities(encryptedProfile));
|
||||||
} catch (InvalidCiphertextException e) {
|
} catch (InvalidCiphertextException e) {
|
||||||
|
@ -88,6 +105,26 @@ public class ProfileUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static byte[] decryptAndVerifyMobileCoinAddress(
|
||||||
|
final byte[] encryptedPaymentAddress, final ProfileCipher profileCipher, final ECPublicKey publicKey
|
||||||
|
) throws InvalidCiphertextException {
|
||||||
|
byte[] decrypted;
|
||||||
|
try {
|
||||||
|
decrypted = profileCipher.decryptWithLength(encryptedPaymentAddress);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalServiceProtos.PaymentAddress paymentAddress;
|
||||||
|
try {
|
||||||
|
paymentAddress = SignalServiceProtos.PaymentAddress.parseFrom(decrypted);
|
||||||
|
} catch (InvalidProtocolBufferException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PaymentUtils.verifyPaymentsAddress(paymentAddress, publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
private static Pair<String, String> splitName(String name) {
|
private static Pair<String, String> splitName(String name) {
|
||||||
if (name == null) {
|
if (name == null) {
|
||||||
return new Pair<>(null, null);
|
return new Pair<>(null, null);
|
||||||
|
|
|
@ -81,10 +81,10 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||||
r.getProfile().getFamilyName(),
|
r.getProfile().getFamilyName(),
|
||||||
r.getProfile().getAbout(),
|
r.getProfile().getAbout(),
|
||||||
r.getProfile().getAboutEmoji(),
|
r.getProfile().getAboutEmoji(),
|
||||||
r.getProfile().getPaymentAddress() == null
|
r.getProfile().getMobileCoinAddress() == null
|
||||||
? null
|
? null
|
||||||
: Base64.getEncoder()
|
: Base64.getEncoder()
|
||||||
.encodeToString(r.getProfile().getPaymentAddress())));
|
.encodeToString(r.getProfile().getMobileCoinAddress())));
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
writer.write(jsonContacts);
|
writer.write(jsonContacts);
|
||||||
|
@ -92,12 +92,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record JsonContact(
|
private record JsonContact(
|
||||||
String number,
|
String number, String uuid, String name, boolean isBlocked, int messageExpirationTime, JsonProfile profile
|
||||||
String uuid,
|
|
||||||
String name,
|
|
||||||
boolean isBlocked,
|
|
||||||
int messageExpirationTime,
|
|
||||||
JsonProfile profile
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private record JsonProfile(
|
private record JsonProfile(
|
||||||
|
@ -106,7 +101,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
|
||||||
String familyName,
|
String familyName,
|
||||||
String about,
|
String about,
|
||||||
String aboutEmoji,
|
String aboutEmoji,
|
||||||
String paymentAddress
|
String mobileCoinAddress
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue