Add tests and refactor session management
This commit is contained in:
parent
d4eb083d77
commit
1d0cd21965
16 changed files with 447 additions and 92 deletions
|
@ -1,4 +1,79 @@
|
||||||
package net.nosial.socialclient;
|
package net.nosial.socialclient;
|
||||||
|
|
||||||
public class Client {
|
import net.nosial.socialclient.abstracts.RpcResult;
|
||||||
|
import net.nosial.socialclient.classes.Cryptography;
|
||||||
|
import net.nosial.socialclient.classes.RpcClient;
|
||||||
|
import net.nosial.socialclient.classes.Utilities;
|
||||||
|
import net.nosial.socialclient.exceptions.CryptographyException;
|
||||||
|
import net.nosial.socialclient.exceptions.ResolutionException;
|
||||||
|
import net.nosial.socialclient.exceptions.RpcException;
|
||||||
|
import net.nosial.socialclient.objects.RpcRequest;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class Client extends RpcClient
|
||||||
|
{
|
||||||
|
protected Client(String domain) throws ResolutionException, CryptographyException
|
||||||
|
{
|
||||||
|
super(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Client(String endpoint, PublicKey serverPublicKey)
|
||||||
|
{
|
||||||
|
super(endpoint, serverPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createSession(KeyPair keyPair) throws RpcException
|
||||||
|
{
|
||||||
|
RpcResult response = this.sendRequest(new RpcRequest("create_session", Utilities.randomCrc32(), new HashMap<>(){{
|
||||||
|
put("public_key", Cryptography.exportPublicKey(keyPair));
|
||||||
|
}}));
|
||||||
|
|
||||||
|
if(response == null)
|
||||||
|
{
|
||||||
|
throw new RpcException("Response is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!response.isSuccess())
|
||||||
|
{
|
||||||
|
throw new RpcException(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (String) response.getResponse().getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSession(String sessionUuid, PrivateKey privateKey)
|
||||||
|
{
|
||||||
|
this.sessionUuid = sessionUuid;
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createAndSetSession(KeyPair keyPair) throws RpcException
|
||||||
|
{
|
||||||
|
String sessionUuid = this.createSession(keyPair);
|
||||||
|
this.setSession(sessionUuid, keyPair.getPrivate());
|
||||||
|
|
||||||
|
return sessionUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean ping() throws RpcException
|
||||||
|
{
|
||||||
|
RpcResult response = this.sendRequest(new RpcRequest("ping", Utilities.randomCrc32()));
|
||||||
|
|
||||||
|
if(response == null)
|
||||||
|
{
|
||||||
|
throw new RpcException("Response is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!response.isSuccess())
|
||||||
|
{
|
||||||
|
throw new RpcException(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import javax.crypto.BadPaddingException;
|
||||||
import javax.crypto.Cipher;
|
import javax.crypto.Cipher;
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
import javax.crypto.IllegalBlockSizeException;
|
||||||
import javax.crypto.NoSuchPaddingException;
|
import javax.crypto.NoSuchPaddingException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.*;
|
import java.security.*;
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
@ -79,15 +80,15 @@ public final class Cryptography
|
||||||
*/
|
*/
|
||||||
public static String signContent(String content, PrivateKey privateKey) throws CryptographyException
|
public static String signContent(String content, PrivateKey privateKey) throws CryptographyException
|
||||||
{
|
{
|
||||||
// Convert content to sha1 hash
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
final Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
|
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
|
||||||
|
|
||||||
signature.initSign(privateKey);
|
signature.initSign(privateKey);
|
||||||
signature.update(MessageDigest.getInstance("SHA-1").digest(content.getBytes()));
|
signature.update(sha1(content).getBytes(StandardCharsets.UTF_8));
|
||||||
|
byte[] signatureBytes = signature.sign();
|
||||||
|
|
||||||
return Base64.getEncoder().encodeToString(signature.sign());
|
// Base64 encode the signature
|
||||||
|
return Base64.getEncoder().encodeToString(signatureBytes);
|
||||||
}
|
}
|
||||||
catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e)
|
catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e)
|
||||||
{
|
{
|
||||||
|
@ -95,6 +96,22 @@ public final class Cryptography
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String bytesToHex(byte[] hash)
|
||||||
|
{
|
||||||
|
StringBuilder hexString = new StringBuilder(2 * hash.length);
|
||||||
|
for (byte b : hash)
|
||||||
|
{
|
||||||
|
String hex = Integer.toHexString(0xff & b);
|
||||||
|
if (hex.length() == 1)
|
||||||
|
{
|
||||||
|
hexString.append('0');
|
||||||
|
}
|
||||||
|
hexString.append(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexString.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the content using the provided signature and public key.
|
* Verifies the content using the provided signature and public key.
|
||||||
*
|
*
|
||||||
|
@ -123,9 +140,8 @@ public final class Cryptography
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
final Signature sign = Signature.getInstance(SIGNATURE_ALGORITHM);
|
final Signature sign = Signature.getInstance(SIGNATURE_ALGORITHM);
|
||||||
|
|
||||||
sign.initVerify(publicKey);
|
sign.initVerify(publicKey);
|
||||||
sign.update(MessageDigest.getInstance("SHA-1").digest(content.getBytes()));
|
sign.update(sha1(content).getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
return sign.verify(Base64.getDecoder().decode(signature));
|
return sign.verify(Base64.getDecoder().decode(signature));
|
||||||
}
|
}
|
||||||
|
@ -372,7 +388,7 @@ public final class Cryptography
|
||||||
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
|
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
|
||||||
return getKeyFactory().generatePublic(spec);
|
return getKeyFactory().generatePublic(spec);
|
||||||
}
|
}
|
||||||
catch (InvalidKeySpecException | NoSuchAlgorithmException e)
|
catch (InvalidKeySpecException | NoSuchAlgorithmException | IllegalArgumentException e)
|
||||||
{
|
{
|
||||||
throw new CryptographyException("Failed to import public key", e);
|
throw new CryptographyException("Failed to import public key", e);
|
||||||
}
|
}
|
||||||
|
@ -399,4 +415,11 @@ public final class Cryptography
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String sha1(String data) throws NoSuchAlgorithmException
|
||||||
|
{
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||||
|
byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return bytesToHex(hash);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,89 +6,54 @@ import net.nosial.socialclient.objects.ResolvedServer;
|
||||||
import javax.naming.NamingException;
|
import javax.naming.NamingException;
|
||||||
import javax.naming.directory.*;
|
import javax.naming.directory.*;
|
||||||
import java.util.Hashtable;
|
import java.util.Hashtable;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
// Improved Resolver class
|
public class Resolver {
|
||||||
public class Resolver
|
public static ResolvedServer resolveDomain(String domain) throws ResolutionException {
|
||||||
{
|
final Pattern fullPattern = Pattern.compile("v=socialbox;sb-rpc=(https?://[^;]+);sb-key=([^;]+)");
|
||||||
/**
|
|
||||||
* Resolves the domain to obtain endpoint and public key from its DNS TXT records.
|
|
||||||
*
|
|
||||||
* @param domain The domain to be resolved.
|
|
||||||
* @return ResolvedServer An instance of ResolvedServer containing the endpoint and public key.
|
|
||||||
* @throws ResolutionException If the DNS TXT records cannot be resolved or if required information is missing.
|
|
||||||
*/
|
|
||||||
public static ResolvedServer resolveDomain(String domain) throws ResolutionException
|
|
||||||
{
|
|
||||||
final String endpointPrefix = "socialbox=";
|
|
||||||
final String keyPrefix = "socialbox-key=";
|
|
||||||
|
|
||||||
// Disable caching
|
|
||||||
Hashtable<String, String> env = new Hashtable<>();
|
Hashtable<String, String> env = new Hashtable<>();
|
||||||
env.put("com.sun.jndi.dns.timeout.retries", "1");
|
env.put("com.sun.jndi.dns.timeout.retries", "1");
|
||||||
env.put("com.sun.jndi.dns.cache.ttl", "0");
|
env.put("com.sun.jndi.dns.cache.ttl", "1");
|
||||||
env.put("com.sun.jndi.dns.cache.negative.ttl", "0");
|
env.put("com.sun.jndi.dns.cache.negative.ttl", "0");
|
||||||
|
|
||||||
try
|
try {
|
||||||
{
|
|
||||||
DirContext dirContext = new InitialDirContext(env);
|
DirContext dirContext = new InitialDirContext(env);
|
||||||
Attributes attributes = dirContext.getAttributes("dns:/" + domain, new String[]{"TXT"});
|
Attributes attributes = dirContext.getAttributes("dns:/" + domain, new String[]{"TXT"});
|
||||||
Attribute attributeTXT = attributes.get("TXT");
|
Attribute attributeTXT = attributes.get("TXT");
|
||||||
|
|
||||||
if (attributeTXT == null)
|
if (attributeTXT == null) {
|
||||||
{
|
|
||||||
throw new ResolutionException("Failed to resolve DNS TXT records for " + domain);
|
throw new ResolutionException("Failed to resolve DNS TXT records for " + domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
String endpoint = null;
|
StringBuilder fullRecordBuilder = new StringBuilder();
|
||||||
StringBuilder publicKeyBuilder = new StringBuilder();
|
|
||||||
boolean publicKeyFound = false;
|
|
||||||
|
|
||||||
for (int i = 0; i < attributeTXT.size(); i++)
|
for (int i = 0; i < attributeTXT.size(); i++) {
|
||||||
{
|
|
||||||
String value = (String) attributeTXT.get(i);
|
String value = (String) attributeTXT.get(i);
|
||||||
|
fullRecordBuilder.append(value.replaceAll("^\"|\"$", "").trim());
|
||||||
// Remove surrounding quotes if present
|
|
||||||
if (value.startsWith("\"") && value.endsWith("\""))
|
|
||||||
{
|
|
||||||
value = value.substring(1, value.length() - 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split the value into fragments to find the relevant keys
|
String fullRecord = fullRecordBuilder.toString();
|
||||||
String[] fragments = value.split("\\s+");
|
Matcher matcher = fullPattern.matcher(fullRecord);
|
||||||
for (String fragment : fragments)
|
|
||||||
{
|
|
||||||
if (fragment.startsWith(endpointPrefix))
|
|
||||||
{
|
|
||||||
endpoint = fragment.substring(endpointPrefix.length());
|
|
||||||
}
|
|
||||||
else if (fragment.startsWith(keyPrefix))
|
|
||||||
{
|
|
||||||
publicKeyBuilder.append(fragment.substring(keyPrefix.length()));
|
|
||||||
publicKeyFound = true;
|
|
||||||
}
|
|
||||||
else if (publicKeyFound)
|
|
||||||
{
|
|
||||||
// If the public key has already started, append the fragment
|
|
||||||
publicKeyBuilder.append(fragment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endpoint == null)
|
if (matcher.find()) {
|
||||||
{
|
String endpoint = matcher.group(1);
|
||||||
|
String publicKey = matcher.group(2).replaceAll("\\s+", "");
|
||||||
|
|
||||||
|
if (endpoint == null || endpoint.isEmpty()) {
|
||||||
throw new ResolutionException("Failed to resolve RPC endpoint for " + domain);
|
throw new ResolutionException("Failed to resolve RPC endpoint for " + domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publicKeyBuilder.isEmpty())
|
if (publicKey == null || publicKey.isEmpty()) {
|
||||||
{
|
|
||||||
throw new ResolutionException("Failed to resolve public key for " + domain);
|
throw new ResolutionException("Failed to resolve public key for " + domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
String publicKey = publicKeyBuilder.toString();
|
|
||||||
return new ResolvedServer(endpoint, publicKey);
|
return new ResolvedServer(endpoint, publicKey);
|
||||||
|
} else {
|
||||||
|
throw new ResolutionException("Failed to find valid SocialBox record for " + domain);
|
||||||
}
|
}
|
||||||
catch (NamingException e)
|
} catch (NamingException e) {
|
||||||
{
|
|
||||||
throw new ResolutionException("Error resolving domain: " + e.getMessage(), e);
|
throw new ResolutionException("Error resolving domain: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import net.nosial.socialclient.objects.ResolvedServer;
|
||||||
import net.nosial.socialclient.objects.RpcRequest;
|
import net.nosial.socialclient.objects.RpcRequest;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
@ -30,8 +31,8 @@ public class RpcClient
|
||||||
private final PublicKey serverPublicKey;
|
private final PublicKey serverPublicKey;
|
||||||
private final OkHttpClient httpClient;
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
private String sessionUuid;
|
protected String sessionUuid;
|
||||||
private PrivateKey privateKey;
|
protected PrivateKey privateKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a new instance of the RpcClient class using the specified domain.
|
* Initializes a new instance of the RpcClient class using the specified domain.
|
||||||
|
@ -118,7 +119,7 @@ public class RpcClient
|
||||||
* @param privateKey the private key to be imported
|
* @param privateKey the private key to be imported
|
||||||
* @throws CryptographyException if an error occurs while importing the private key
|
* @throws CryptographyException if an error occurs while importing the private key
|
||||||
*/
|
*/
|
||||||
public void setSession(String sessionUuid, String privateKey) throws CryptographyException
|
protected void setSession(String sessionUuid, String privateKey) throws CryptographyException
|
||||||
{
|
{
|
||||||
this.sessionUuid = sessionUuid;
|
this.sessionUuid = sessionUuid;
|
||||||
this.privateKey = Cryptography.importPrivateKey(privateKey);
|
this.privateKey = Cryptography.importPrivateKey(privateKey);
|
||||||
|
@ -130,7 +131,7 @@ public class RpcClient
|
||||||
* @param sessionUuid the UUID of the session
|
* @param sessionUuid the UUID of the session
|
||||||
* @param privateKey the private key associated with the session
|
* @param privateKey the private key associated with the session
|
||||||
*/
|
*/
|
||||||
public void setSession(String sessionUuid, PrivateKey privateKey)
|
protected void setSession(String sessionUuid, PrivateKey privateKey)
|
||||||
{
|
{
|
||||||
this.sessionUuid = sessionUuid;
|
this.sessionUuid = sessionUuid;
|
||||||
this.privateKey = privateKey;
|
this.privateKey = privateKey;
|
||||||
|
@ -165,6 +166,7 @@ public class RpcClient
|
||||||
|
|
||||||
if(sessionUuid != null)
|
if(sessionUuid != null)
|
||||||
{
|
{
|
||||||
|
// We are seeing the session UUID correctly.
|
||||||
this.addHeader("Session-UUID", sessionUuid);
|
this.addHeader("Session-UUID", sessionUuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -172,9 +174,12 @@ public class RpcClient
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// NOTE: Signature looks okay, it's base64 encoded.
|
||||||
|
System.out.println("Hash: " + Cryptography.sha1(jsonData));
|
||||||
|
System.out.println("Signature: " + Cryptography.signContent(jsonData, privateKey));
|
||||||
this.addHeader("Signature", Cryptography.signContent(jsonData, privateKey));
|
this.addHeader("Signature", Cryptography.signContent(jsonData, privateKey));
|
||||||
}
|
}
|
||||||
catch(CryptographyException e)
|
catch(CryptographyException | NoSuchAlgorithmException e)
|
||||||
{
|
{
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
@ -193,7 +198,7 @@ public class RpcClient
|
||||||
throw new RuntimeException(responseString);
|
throw new RuntimeException(responseString);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException("Failed to send request");
|
throw new RuntimeException("Failed to send request: " + response.code());
|
||||||
}
|
}
|
||||||
|
|
||||||
if(response.code() == 204)
|
if(response.code() == 204)
|
||||||
|
@ -242,7 +247,14 @@ public class RpcClient
|
||||||
*/
|
*/
|
||||||
public RpcResult sendRequest(RpcRequest request)
|
public RpcResult sendRequest(RpcRequest request)
|
||||||
{
|
{
|
||||||
return sendRequest(encode(request.toMap())).getFirst();
|
List<RpcResult> results = sendRequest(encode(request.toMap()));
|
||||||
|
|
||||||
|
if(results.isEmpty())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.getFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
44
src/main/java/net/nosial/socialclient/classes/Utilities.java
Normal file
44
src/main/java/net/nosial/socialclient/classes/Utilities.java
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package net.nosial.socialclient.classes;
|
||||||
|
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public class Utilities
|
||||||
|
{
|
||||||
|
private static final int[] CRC32_TABLE = new int[256];
|
||||||
|
|
||||||
|
static
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
int crc = i;
|
||||||
|
for (int j = 8; j > 0; j--)
|
||||||
|
{
|
||||||
|
if ((crc & 1) == 1)
|
||||||
|
{
|
||||||
|
crc = (crc >>> 1) ^ 0xEDB88320;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
crc = crc >>> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CRC32_TABLE[i] = crc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String randomCrc32()
|
||||||
|
{
|
||||||
|
long randomValue = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
|
||||||
|
String valueString = Long.toString(randomValue);
|
||||||
|
int crc = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
for (char c : valueString.toCharArray())
|
||||||
|
{
|
||||||
|
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ c) & 0xFF];
|
||||||
|
}
|
||||||
|
|
||||||
|
crc = ~crc;
|
||||||
|
return Integer.toHexString(crc);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,21 @@
|
||||||
package net.nosial.socialclient.exceptions;
|
package net.nosial.socialclient.exceptions;
|
||||||
|
|
||||||
|
import net.nosial.socialclient.abstracts.RpcResult;
|
||||||
|
|
||||||
public class RpcException extends Exception
|
public class RpcException extends Exception
|
||||||
{
|
{
|
||||||
|
public RpcException(String message)
|
||||||
|
{
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpcException(String message, Throwable cause)
|
||||||
|
{
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpcException(RpcResult result)
|
||||||
|
{
|
||||||
|
super(result.getErrorResponse().getError());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,11 @@ public class ResolvedServer
|
||||||
|
|
||||||
public ResolvedServer(String endpoint, String publicKey)
|
public ResolvedServer(String endpoint, String publicKey)
|
||||||
{
|
{
|
||||||
|
if(endpoint == null || publicKey == null)
|
||||||
|
{
|
||||||
|
throw new IllegalArgumentException("Endpoint and public key must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
this.endpoint = endpoint;
|
this.endpoint = endpoint;
|
||||||
this.publicKey = publicKey;
|
this.publicKey = publicKey;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ public class RpcRequest
|
||||||
{
|
{
|
||||||
private final String method;
|
private final String method;
|
||||||
private final String id;
|
private final String id;
|
||||||
private final Map<String, Object> params;
|
private final Map<String, Object> parameters;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes a new RpcRequest object using the provided data map.
|
* Initializes a new RpcRequest object using the provided data map.
|
||||||
|
@ -23,7 +23,7 @@ public class RpcRequest
|
||||||
{
|
{
|
||||||
this.method = (String) data.get("method");
|
this.method = (String) data.get("method");
|
||||||
this.id = (String) data.get("id");
|
this.id = (String) data.get("id");
|
||||||
this.params = (Map<String, Object>) data.get("params");
|
this.parameters = (Map<String, Object>) data.get("params");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -31,13 +31,13 @@ public class RpcRequest
|
||||||
*
|
*
|
||||||
* @param method the RPC method to be called
|
* @param method the RPC method to be called
|
||||||
* @param id the unique identifier for this request
|
* @param id the unique identifier for this request
|
||||||
* @param params the parameters for the RPC method
|
* @param parameters the parameters for the RPC method
|
||||||
*/
|
*/
|
||||||
public RpcRequest(String method, String id, Map<String, Object> params)
|
public RpcRequest(String method, String id, Map<String, Object> parameters)
|
||||||
{
|
{
|
||||||
this.method = method;
|
this.method = method;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.params = params;
|
this.parameters = parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,13 +45,13 @@ public class RpcRequest
|
||||||
* The request ID will be set to null.
|
* The request ID will be set to null.
|
||||||
*
|
*
|
||||||
* @param method the name of the method being called
|
* @param method the name of the method being called
|
||||||
* @param params the parameters to be sent with the method call
|
* @param parameters the parameters to be sent with the method call
|
||||||
*/
|
*/
|
||||||
public RpcRequest(String method, Map<String, Object> params)
|
public RpcRequest(String method, Map<String, Object> parameters)
|
||||||
{
|
{
|
||||||
this.id = null;
|
this.id = null;
|
||||||
this.method = method;
|
this.method = method;
|
||||||
this.params = params;
|
this.parameters = parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,7 +64,7 @@ public class RpcRequest
|
||||||
{
|
{
|
||||||
this.method = method;
|
this.method = method;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.params = null;
|
this.parameters = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -76,7 +76,7 @@ public class RpcRequest
|
||||||
{
|
{
|
||||||
this.id = null;
|
this.id = null;
|
||||||
this.method = method;
|
this.method = method;
|
||||||
this.params = null;
|
this.parameters = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -104,9 +104,9 @@ public class RpcRequest
|
||||||
*
|
*
|
||||||
* @return a Map containing the parameters of the RPC request.
|
* @return a Map containing the parameters of the RPC request.
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> getParams()
|
public Map<String, Object> getParameters()
|
||||||
{
|
{
|
||||||
return this.params;
|
return this.parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -124,9 +124,9 @@ public class RpcRequest
|
||||||
map.put("id", this.id);
|
map.put("id", this.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.params != null)
|
if(this.parameters != null)
|
||||||
{
|
{
|
||||||
map.put("params", this.params);
|
map.put("parameters", this.parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
|
|
89
src/test/java/net/nosial/socialclient/SessionTest.java
Normal file
89
src/test/java/net/nosial/socialclient/SessionTest.java
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package net.nosial.socialclient;
|
||||||
|
|
||||||
|
import net.nosial.socialclient.classes.Cryptography;
|
||||||
|
import net.nosial.socialclient.exceptions.CryptographyException;
|
||||||
|
import net.nosial.socialclient.exceptions.ResolutionException;
|
||||||
|
import net.nosial.socialclient.exceptions.RpcException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SessionTest
|
||||||
|
{
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSessionTest()
|
||||||
|
{
|
||||||
|
Client socialClient;
|
||||||
|
KeyPair keyPair;
|
||||||
|
String sessionUuid;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socialClient = new Client("n64.cc");
|
||||||
|
}
|
||||||
|
catch (ResolutionException | CryptographyException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
keyPair = Cryptography.generateKeyPair();
|
||||||
|
}
|
||||||
|
catch (CryptographyException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionUuid = socialClient.createSession(keyPair);
|
||||||
|
}
|
||||||
|
catch (RpcException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(sessionUuid);
|
||||||
|
System.out.println("Session UUID: " + sessionUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pingSignedTest()
|
||||||
|
{
|
||||||
|
final Client socialClient;
|
||||||
|
final KeyPair keyPair;
|
||||||
|
final String sessionUuid;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socialClient = new Client("n64.cc");
|
||||||
|
keyPair = Cryptography.generateKeyPair();
|
||||||
|
sessionUuid = socialClient.createAndSetSession(keyPair);
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (ResolutionException | CryptographyException | RpcException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Session UUID: " + sessionUuid);
|
||||||
|
assertNotNull(sessionUuid);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
assertTrue(socialClient.ping());
|
||||||
|
}
|
||||||
|
catch (RpcException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,16 @@ import net.nosial.socialclient.exceptions.CryptographyException;
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@ -259,4 +267,78 @@ class CryptographyTest
|
||||||
// Assert
|
// Assert
|
||||||
assertThrows(CryptographyException.class, () -> Cryptography.encrypt(content, invalidPublicKey), "Encrypt should throw CryptographyException when public key is invalid");
|
assertThrows(CryptographyException.class, () -> Cryptography.encrypt(content, invalidPublicKey), "Encrypt should throw CryptographyException when public key is invalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clientSignTest() {
|
||||||
|
ClassLoader classLoader = getClass().getClassLoader();
|
||||||
|
|
||||||
|
PrivateKey privateKey;
|
||||||
|
PublicKey publicKey;
|
||||||
|
String content;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
privateKey = Cryptography.importPrivateKey(Files.readString(new File(Objects.requireNonNull(classLoader.getResource("client_private.der")).getFile()).toPath()));
|
||||||
|
publicKey = Cryptography.importPublicKey(Files.readString(new File(Objects.requireNonNull(classLoader.getResource("client_public.der")).getFile()).toPath()));
|
||||||
|
content = Files.readString(new File(Objects.requireNonNull(classLoader.getResource("content.txt")).getFile()).toPath());
|
||||||
|
}
|
||||||
|
catch(CryptographyException | IOException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(privateKey, "Private key should not be null");
|
||||||
|
assertNotNull(publicKey, "Public key should not be null");
|
||||||
|
assertNotNull(content, "Content should not be null");
|
||||||
|
|
||||||
|
// Hash the content to sha1 string
|
||||||
|
String sha1Content;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sha1Content = Cryptography.sha1(content);
|
||||||
|
}
|
||||||
|
catch (NoSuchAlgorithmException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(sha1Content, "SHA1 content should not be null");
|
||||||
|
assertEquals(40, sha1Content.length(), "SHA1 content should be 40 characters length");
|
||||||
|
assertEquals("fa2415f0735a8aa151195688852178e8fd6e77c5", sha1Content, "SHA1 content should be equal to expected value");
|
||||||
|
System.out.println("SHA1 content: " + sha1Content);
|
||||||
|
|
||||||
|
// Signature testing
|
||||||
|
String signature;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
signature = Cryptography.signContent(content, privateKey);
|
||||||
|
}
|
||||||
|
catch (CryptographyException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(signature, "Signature should not be null");
|
||||||
|
assertEquals("Gcnijq7V8AYXgdk/eP9IswXN7831FevlBNDTKN60Ku7xesPDuPX8e55+38WFGCQ87DbeiIr+61XIDoN4+bTM4Wl0YSUe7oHV9BBnBqGhyZTntDPedUYUomrF3IRcpVRK0SbQSRaYucIp/ZsSHdbQgQBtDCvH5pK1+5g+VK9ZFT16Isvk0PhMjZiLkUYxUklFuzak7agWiS3wllFPqYSM6ri0RF+5I5JbnR9fUAOfhOceax//5H7d2WsdLj6DwtuY+eL5WyHxSmGA04YeQF3JgOGJ3WX2DSH8L0zA7pkGOjz5y1Nu6+0U6KRUXcezU/iM4zy5OJOnD5eJH4pYZizkiA==", signature, "Signature should be equal to expected value");
|
||||||
|
System.out.println("Signature: " + signature);
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
boolean verified;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
verified = Cryptography.verifyContent(content, signature, publicKey);
|
||||||
|
}
|
||||||
|
catch (CryptographyException e)
|
||||||
|
{
|
||||||
|
fail(e.getMessage(), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(verified, "Signature should be verified successfully");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package net.nosial.socialclient.classes;
|
||||||
|
|
||||||
|
import net.nosial.socialclient.classes.Utilities;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
public class UtilitiesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRandomCrc32_formatValidity() {
|
||||||
|
String crcOutput = Utilities.randomCrc32();
|
||||||
|
assertNotNull(crcOutput);
|
||||||
|
assertTrue(crcOutput.matches("[a-f0-9]+"), "Expected hexadecimal output");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRandomCrc32_lengthValidity() {
|
||||||
|
String crcOutput = Utilities.randomCrc32();
|
||||||
|
assertEquals(8, crcOutput.length(), "Expected 8 characters length");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRandomCrc32_randomness() {
|
||||||
|
Set<String> randomResults = new HashSet<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 10_000; i++) {
|
||||||
|
randomResults.add(Utilities.randomCrc32());
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(randomResults.size() > 1, "Expected more than one different results");
|
||||||
|
}
|
||||||
|
}
|
1
src/test/resources/client_private.der
Normal file
1
src/test/resources/client_private.der
Normal file
|
@ -0,0 +1 @@
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZb4K+28kKYe1CvHPWHJALJFS396HOFmBv+anpAVWMDGBUyAWbWEqxmTAV17cBHiICjDDCNFBpOZLWzIiUpdWKA0Jo+Vu9zgWSPUyGe/Lik4GFNZ38gfolfdKGnLNFnn4nFR/fsZQ7hg4wWDarJmhJ+ZSLShOz2uIb4LaKk2qy12c6Zepufgrbk9TwWZQiXkzqBWbrZDpw0pp50CzoIwEnYJ+a7vhb98jpeS+Jjnp5zWlFjv9RgzOQUOwwOK4We2gNAVeFC5BP9trklpTh1bJlit4CECH68fCGjgoTOU92UbgucgyA4O5FVPGQYPAMuiZMGFaqXE2E7z1XwYIMAL4VAgMBAAECggEAAKiJz3CYuO+gGnL+F7qjaSXCUE8VvPfoCwuNYHNEFXo9DJBmnL7EU2WrYG+wARCP7O7qd0dEidx9u36ytjyCcKT4nYni8lM1zU7rVvbnLbsuRZS/4RO/RaYfPxig94fDfSeJ2ma0i7G56onj+MBbyTZarZ7Bf8hpcmKg9pkNEcEVcklNIwwbXKBOGq75Vka/+W56JZKJD3G9YmfrAO5RGF1prh93MRXlxlN/91k/m2pqkN9xYofepn0ePmI8Ci18jrMpJbmeu8BkypzgvC/5EfHipn7y/yJ215o/EtB575muz2zngRXe+GVO5lB5d5PuEwmXoaV5o3BqkIcb3aiz4QKBgQD7P1AE2/3oATNUF1FwlXzvdCS7M2BB28jQWjzJvHus1d1+qA2StWPgCPG2D/YTtHPI3xefBnAmeSIFCFEub0YLONbRvtQAZdTt5SAaZuUyMprqD1sCUHCizyVO0wHxo3DS0sIFmo/Lpc+jnYHn3KcuRPRJk3ncZNCQhy9a/rrnxQKBgQDdjHY82YdkWQWj/xM1EuVtkVVeCJWJ6tSDn+Uq8d+hXILFAQ47GOUbzj4Ty4qGgsAgsaAGqja5t6CE+fYs8Q34FsxTsYgIRm0VXqtPm4aYTQ4PwKbmMPEOgEsXBywe5Y+QB0u/WuNyhgwgYP5cy1IS3HA1HmbTisi0zLEfkVWSEQKBgCuP36zoA88NHjwvStSNZrsR1SiMEN16YQgXDUEhKARglGXYd3n/b1Cx3E7n14+1Evo6DBtrf1h8WjSrK4A0lN1vPnfhcVqcTV3uAzHwsz6P3aJFhU8SaWUhK2POXCDsaKx1FGTqVpJFrom8zoBIFsiD9iMnqdJXvH3CoqhRUFDNAoGAEJdwU2ZHCXDRR1LW8WaU3/u+VOh3qnh3qdPTqb+ra74t3OsTUcGvhsGPTJQ1r5UjJk+nGFiu+IGT9+FwWjVDQo0SiEIHWfdMPAl28uNG1SkQIIXg+eQ4aUmaVgMnfrjaY4LoXVBFMFJxngslgXWIk/kGPjQkpzsBhOi/awnLSsECgYEAkSEb3CXfq1r/8qXMTzI+A9CGPr++aC2H6ytFNGJq4J+P40u1tcsfkwNGcQ0Hp+Qz3FHBYFuMxtjXDq0QSvVKEhdV9bjlZhTqN3lqWcCukU3ESqRbxsIj9izuncpxSP7G19WEU0anGD9ev+QWYdHPTBY9nn1+H0tkJjqh4XkRBuY=
|
1
src/test/resources/client_public.der
Normal file
1
src/test/resources/client_public.der
Normal file
|
@ -0,0 +1 @@
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2W+CvtvJCmHtQrxz1hyQCyRUt/ehzhZgb/mp6QFVjAxgVMgFm1hKsZkwFde3AR4iAowwwjRQaTmS1syIlKXVigNCaPlbvc4Fkj1Mhnvy4pOBhTWd/IH6JX3ShpyzRZ5+JxUf37GUO4YOMFg2qyZoSfmUi0oTs9riG+C2ipNqstdnOmXqbn4K25PU8FmUIl5M6gVm62Q6cNKaedAs6CMBJ2Cfmu74W/fI6XkviY56ec1pRY7/UYMzkFDsMDiuFntoDQFXhQuQT/ba5JaU4dWyZYreAhAh+vHwho4KEzlPdlG4LnIMgODuRVTxkGDwDLomTBhWqlxNhO89V8GCDAC+FQIDAQAB
|
4
src/test/resources/content.txt
Normal file
4
src/test/resources/content.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"method" : "ping",
|
||||||
|
"id" : "daa31852"
|
||||||
|
}
|
1
src/test/resources/server_private.der
Normal file
1
src/test/resources/server_private.der
Normal file
|
@ -0,0 +1 @@
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDxcYLVvKYHpxJQe49Z7boilJfmp/uYAr4rQNN2El9nPG3hVRYIsmhzIeByU7ZJLn0EG5D2G6T4SlydfbHbHK3NMciTgmyAZRJl10z/KIlPG1n6DaK5Oo6VT9ty+uI7JKFdQQwzSrPP2u4KNERqK4vtfsxdiAcMXS/hUncID4ARvmigmcTOTcosdH57axSqf1xfJJ12zl3QPb6wppsBAJrZ811Ll8eZYhFoQwe0oE3T5Q0aqK0Ecgh4cYhF63nEAKkcPOMxkhcZWpr3YzqD7Rj9VAzk7xRM/QJ1SjsVwdB+YCQK1tbTloTY3BqtEib9ErZvjlaB26GZbV32kNsPnwHXAgMBAAECggEAJBVtUskzXRBsjcexmGSNfW6MtyWi1ciPKEKzd8FuLa0b1OHU/a7AKnjFJQD6zLwcZflCtG1UPeFLLyRiaNdD6FdI3TbQRW4Vjk/bi4TA5Kg3TcYs7BbiyVDagLgbCHDEhv3aN24yKl3TVoYSNXXVn0RkgZP7Ta89oSSkcnlyj/QFOA8RfIm5q+qiAPvOqFf8NKlm0hZDrxWHG/OduYHq25S9ohNzymyM+1CYTrVFZCTfscDvLBDd2MVpNRyxoQquiMlfIEUBGlu9uFWy9Hovv8Sd3irgvcBtjL8iPaMzJe3p6T83KL2AgXHcYT7r9Vlvqib5x1iTYvlid25zzQ19IQKBgQD1BISfPugEp+fAdoGHOygG+gzNE8/1ldhnA9bTCZZ3FQBTI2lPRZBFDKuirc6glbCHiWrd8HoJ3BO3kbGzq4EDBf0VDFby/7nkrroTW+RIn+THlfciWgjSATGgCPOHmvM6JmIpuYsbKkdmV4ITVWwvLPxDAwlMnHyJOuYTj8xJ4QKBgQD8Q/rdWoRBSVCrDb0QO/Or5FAJELmYFtFWBBCEpadr9ci0e/mSbbZlXjP98m4XesIIRpcpG3gU8P3hKB7H60ynPN6Jyw33YhIlJHaEjYISN/h5Vw0ybQkyFR9CBRjOp59CBcb8AsdA/OQjxFz8h46PbPLCWCR5kM3tKbuNobBytwKBgQC4Rr+gLWW/KrEolXhxxtIh/SpniwEbSanKQJ7vdgSOZ2MpJDbuAfmxlQf5gBMpv6tXJMkVRuniRH0n0RH/eXu8VGK10+QJOsAK+EbGjJQy8t7UJTwLv/9mQrOaE2FlmepYz8mAbCXtNm0g0avo8pQ9Hu5TUBNMZV1csMmd6MbSwQKBgQDit+X6kqNSWaXaVdqZgIga8HLN8u4aNkelWrnNvWOer6LWMqW2aEwJBoULsponF/jSnz6zfzCJAZ3qgbhITLzzgM0wYgIHV2ifYQnzT4qa/RqfUxFVRJGDJWCWYSZOdG+5Up/nVkflrGMNkilP/DSvymbTK4x8hRvODje1rp96OQKBgFmXLpHPN8WAXP7VVyb3RqYYRgtxXjY2yj/CYwnXl0k4Uji08S9Ke2AqljiSzmZs1Wh1UBLap90F0smRVHmYgwl2rPjNiXbyKd4W9R4vEVYgEmcnvzba107o76qFmEbyW/K7a7K/jKaH8KAytgR/cHd+SIBctcDv8uKmZ1MJT9p8
|
1
src/test/resources/server_public.der
Normal file
1
src/test/resources/server_public.der
Normal file
|
@ -0,0 +1 @@
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8XGC1bymB6cSUHuPWe26IpSX5qf7mAK+K0DTdhJfZzxt4VUWCLJocyHgclO2SS59BBuQ9huk+EpcnX2x2xytzTHIk4JsgGUSZddM/yiJTxtZ+g2iuTqOlU/bcvriOyShXUEMM0qzz9ruCjREaiuL7X7MXYgHDF0v4VJ3CA+AEb5ooJnEzk3KLHR+e2sUqn9cXySdds5d0D2+sKabAQCa2fNdS5fHmWIRaEMHtKBN0+UNGqitBHIIeHGIRet5xACpHDzjMZIXGVqa92M6g+0Y/VQM5O8UTP0CdUo7FcHQfmAkCtbW05aE2NwarRIm/RK2b45WgduhmW1d9pDbD58B1wIDAQAB
|
Loading…
Add table
Reference in a new issue