mirror of
https://github.com/NecroticBamboo/QRBee.git
synced 2025-12-21 12:11:53 +00:00
Certificate request implemented.
This commit is contained in:
parent
e07b45d43f
commit
8d5702b621
@ -6,16 +6,11 @@ namespace QRBee.Core.Security
|
||||
{
|
||||
public class ReadableCertificateRequest
|
||||
{
|
||||
private byte[] _rsaPublicKey;
|
||||
private byte[] _signature;
|
||||
|
||||
public string SubjectName { get; set; }
|
||||
|
||||
public string RsaPublicKey
|
||||
{
|
||||
get => _rsaPublicKey != null ? Convert.ToBase64String(_rsaPublicKey): null;
|
||||
set => _rsaPublicKey = value != null ? Convert.FromBase64String(value) : null;
|
||||
}
|
||||
public StringRSAParameters RsaPublicKey { get; set; }
|
||||
|
||||
public string Signature
|
||||
{
|
||||
@ -23,7 +18,7 @@ namespace QRBee.Core.Security
|
||||
set => _signature = value != null ? Convert.FromBase64String(value) : null;
|
||||
}
|
||||
|
||||
public string AsDataForSignature() => $"{SubjectName}|{RsaPublicKey}";
|
||||
public string AsDataForSignature() => $"{SubjectName}|{RsaPublicKey.ConvertToJson()}";
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
27
QRBee.Core/Security/StringRSAParameters.cs
Normal file
27
QRBee.Core/Security/StringRSAParameters.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace QRBee.Core.Security
|
||||
{
|
||||
public class StringRSAParameters
|
||||
{
|
||||
public string StringExponent { get; set; }
|
||||
|
||||
public string StringModulus { get; set; }
|
||||
|
||||
public string StringP { get; set; }
|
||||
|
||||
public string StringQ { get; set; }
|
||||
|
||||
public string StringDP { get; set; }
|
||||
|
||||
public string StringDQ { get; set; }
|
||||
|
||||
public string StringInverseQ { get; set; }
|
||||
|
||||
public string StringD { get; set; }
|
||||
|
||||
public string ConvertToJson() => JsonConvert.SerializeObject(this);
|
||||
|
||||
public static StringRSAParameters ConvertFromJson(string json) => JsonConvert.DeserializeObject<StringRSAParameters>(json);
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,7 @@
|
||||
<Compile Include="MainActivity.cs" />
|
||||
<Compile Include="Resources\Resource.designer.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="Services\CryptoHelper.cs" />
|
||||
<Compile Include="Services\LocalSettings.cs" />
|
||||
<Compile Include="Services\AndroidPrivateKeyHandler.cs" />
|
||||
<Compile Include="Services\QRScannerService.cs" />
|
||||
|
||||
@ -24,8 +24,8 @@ namespace QRBee.Droid.Services
|
||||
private const int RSABits = 2048;
|
||||
private const int CertificateValidityDays = 3650;
|
||||
|
||||
private string PrivateKeyFileName => $"{System.Environment.SpecialFolder.LocalApplicationData}/{SignedCertificateFileName}";
|
||||
private string PrivateRsaKeyFileName => $"{System.Environment.SpecialFolder.LocalApplicationData}/{RawRsaKeyFileName}";
|
||||
private string PrivateKeyFileName => $"{Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)}/{SignedCertificateFileName}";
|
||||
private string PrivateRsaKeyFileName => $"{Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)}/{RawRsaKeyFileName}";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Exists()
|
||||
@ -41,26 +41,51 @@ namespace QRBee.Droid.Services
|
||||
File.Delete(PrivateRsaKeyFileName);
|
||||
|
||||
using var rsa = RSA.Create(RSABits);
|
||||
var bytes = rsa.ExportEncryptedPkcs8PrivateKey(VeryBadNeverUsePrivateKeyPassword, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, EncryptionIterationCount));
|
||||
|
||||
var s = ExportKeyToJson(rsa,true);
|
||||
|
||||
var bytes = CryptoHelper.EncryptStringAES(s, VeryBadNeverUsePrivateKeyPassword);
|
||||
File.WriteAllBytes(PrivateRsaKeyFileName, bytes);
|
||||
}
|
||||
|
||||
return CreateCertificateRequest(subjectName);
|
||||
}
|
||||
|
||||
private static string ExportKeyToJson(RSA rsa,bool includePrivateKey)
|
||||
{
|
||||
//Workaround for absence of half of cryptography subsystem in Mono
|
||||
var stringParameters = ExportKey(rsa, includePrivateKey);
|
||||
var s = stringParameters.ConvertToJson();
|
||||
return s;
|
||||
}
|
||||
|
||||
private static StringRSAParameters ExportKey(RSA rsa, bool includePrivateKey)
|
||||
{
|
||||
var rsaParameters = rsa.ExportParameters(includePrivateKey);
|
||||
var stringParameters = new StringRSAParameters
|
||||
{
|
||||
StringExponent = SafeConvertToBase64(rsaParameters.Exponent),
|
||||
StringModulus = SafeConvertToBase64(rsaParameters.Modulus),
|
||||
StringP = SafeConvertToBase64(rsaParameters.P),
|
||||
StringQ = SafeConvertToBase64(rsaParameters.Q),
|
||||
StringDP = SafeConvertToBase64(rsaParameters.DP),
|
||||
StringDQ = SafeConvertToBase64(rsaParameters.DQ),
|
||||
StringInverseQ = SafeConvertToBase64(rsaParameters.InverseQ),
|
||||
StringD = SafeConvertToBase64(rsaParameters.D)
|
||||
};
|
||||
return stringParameters;
|
||||
}
|
||||
|
||||
private static string SafeConvertToBase64(byte[] bytes) => bytes == null ? "" : Convert.ToBase64String(bytes);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ReadableCertificateRequest CreateCertificateRequest(string subjectName)
|
||||
{
|
||||
if (File.Exists(PrivateRsaKeyFileName))
|
||||
throw new ApplicationException("Private key does not exist");
|
||||
|
||||
var bytes = File.ReadAllBytes(PrivateRsaKeyFileName);
|
||||
using var rsa = RSA.Create(RSABits);
|
||||
rsa.ImportEncryptedPkcs8PrivateKey(VeryBadNeverUsePrivateKeyPassword, bytes, out _);
|
||||
using var rsa = LoadRsaPrivateKey();
|
||||
|
||||
var request = new ReadableCertificateRequest
|
||||
{
|
||||
RsaPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()),
|
||||
RsaPublicKey = ExportKey(rsa,false),
|
||||
SubjectName = subjectName
|
||||
};
|
||||
var data = Encoding.UTF8.GetBytes(request.AsDataForSignature());
|
||||
@ -144,12 +169,7 @@ namespace QRBee.Droid.Services
|
||||
{
|
||||
// heavily modified version of:
|
||||
// https://stackoverflow.com/questions/18462064/associate-a-private-key-with-the-x509certificate2-class-in-net
|
||||
|
||||
// we can't use LoadPrivateKey here as it creating non-exportable key
|
||||
var pk = new X509Certificate2(PrivateKeyFileName, VeryBadNeverUsePrivateKeyPassword, X509KeyStorageFlags.Exportable);
|
||||
using var rsa = pk.GetRSAPrivateKey();
|
||||
if (rsa == null)
|
||||
throw new CryptographicException("Can't get PrivateKey");
|
||||
using var rsa = LoadRsaPrivateKey();
|
||||
|
||||
var newPk = cert.CopyWithPrivateKey(rsa);
|
||||
|
||||
@ -164,5 +184,30 @@ namespace QRBee.Droid.Services
|
||||
}
|
||||
}
|
||||
|
||||
private RSA LoadRsaPrivateKey()
|
||||
{
|
||||
var bytes = File.ReadAllBytes(PrivateRsaKeyFileName);
|
||||
var s = CryptoHelper.DecryptStringAES(bytes, VeryBadNeverUsePrivateKeyPassword);
|
||||
|
||||
var stringParameters = StringRSAParameters.ConvertFromJson(s);
|
||||
var rsaParameters = new RSAParameters
|
||||
{
|
||||
Exponent = SafeConvertFromBase64(stringParameters.StringExponent),
|
||||
Modulus = SafeConvertFromBase64(stringParameters.StringModulus),
|
||||
P = SafeConvertFromBase64(stringParameters.StringP),
|
||||
Q = SafeConvertFromBase64(stringParameters.StringQ),
|
||||
DP = SafeConvertFromBase64(stringParameters.StringDP),
|
||||
DQ = SafeConvertFromBase64(stringParameters.StringDQ),
|
||||
InverseQ = SafeConvertFromBase64(stringParameters.StringInverseQ),
|
||||
D = SafeConvertFromBase64(stringParameters.StringD)
|
||||
};
|
||||
|
||||
var rsa = RSA.Create(rsaParameters);
|
||||
if (rsa == null)
|
||||
throw new CryptographicException("Can't get PrivateKey");
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private static byte[] SafeConvertFromBase64(string s) => string.IsNullOrWhiteSpace(s) ? null : Convert.FromBase64String(s);
|
||||
}
|
||||
}
|
||||
|
||||
136
QRBee/QRBee.Android/Services/CryptoHelper.cs
Normal file
136
QRBee/QRBee.Android/Services/CryptoHelper.cs
Normal file
@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using Stream = System.IO.Stream;
|
||||
|
||||
namespace QRBee.Droid.Services
|
||||
{
|
||||
internal class CryptoHelper
|
||||
{
|
||||
//https://stackoverflow.com/questions/202011/encrypt-and-decrypt-a-string-in-c
|
||||
|
||||
//While an app specific salt is not the best practice for
|
||||
//password based encryption, it's probably safe enough as long as
|
||||
//it is truly uncommon. Also too much work to alter this answer otherwise.
|
||||
// Never use salt like this
|
||||
private static byte[] _salt = System.Text.Encoding.UTF8.GetBytes("‘wÑÏU4)MÓvcvsª");
|
||||
|
||||
/// <summary>
|
||||
/// Encrypt the given string using AES. The string can be decrypted using
|
||||
/// DecryptStringAES(). The sharedSecret parameters must match.
|
||||
/// </summary>
|
||||
/// <param name="plainText">The text to encrypt.</param>
|
||||
/// <param name="sharedSecret">A password used to generate a key for encryption.</param>
|
||||
public static byte[] EncryptStringAES(string plainText, string sharedSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plainText))
|
||||
throw new ArgumentNullException(nameof(plainText));
|
||||
if (string.IsNullOrEmpty(sharedSecret))
|
||||
throw new ArgumentNullException(nameof(sharedSecret));
|
||||
|
||||
RijndaelManaged aesAlg = null; // RijndaelManaged object used to encrypt the data.
|
||||
|
||||
try
|
||||
{
|
||||
// generate the key from the shared secret and the salt
|
||||
var key = new Rfc2898DeriveBytes(sharedSecret, _salt);
|
||||
|
||||
// Create a RijndaelManaged object
|
||||
aesAlg = new RijndaelManaged();
|
||||
aesAlg.Key = key.GetBytes(aesAlg.KeySize / 8);
|
||||
|
||||
// Create a decryptor to perform the stream transform.
|
||||
var encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);
|
||||
|
||||
// Create the streams used for encryption.
|
||||
using MemoryStream msEncrypt = new MemoryStream();
|
||||
// prepend the IV
|
||||
msEncrypt.Write(BitConverter.GetBytes(aesAlg.IV.Length), 0, sizeof(int));
|
||||
msEncrypt.Write(aesAlg.IV, 0, aesAlg.IV.Length);
|
||||
using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
|
||||
{
|
||||
using (var swEncrypt = new StreamWriter(csEncrypt))
|
||||
{
|
||||
//Write all data to the stream.
|
||||
swEncrypt.Write(plainText);
|
||||
}
|
||||
}
|
||||
return msEncrypt.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clear the RijndaelManaged object.
|
||||
aesAlg?.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypt the given string. Assumes the string was encrypted using
|
||||
/// EncryptStringAES(), using an identical sharedSecret.
|
||||
/// </summary>
|
||||
/// <param name="cipherText">The text to decrypt.</param>
|
||||
/// <param name="sharedSecret">A password used to generate a key for decryption.</param>
|
||||
public static string DecryptStringAES(byte[] bytes, string sharedSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sharedSecret))
|
||||
throw new ArgumentNullException(nameof(sharedSecret));
|
||||
|
||||
// Declare the RijndaelManaged object
|
||||
// used to decrypt the data.
|
||||
RijndaelManaged aesAlg = null;
|
||||
|
||||
// Declare the string used to hold
|
||||
// the decrypted text.
|
||||
string plaintext = null;
|
||||
|
||||
try
|
||||
{
|
||||
// generate the key from the shared secret and the salt
|
||||
var key = new Rfc2898DeriveBytes(sharedSecret, _salt);
|
||||
|
||||
// Create the streams used for decryption.
|
||||
using var msDecrypt = new MemoryStream(bytes);
|
||||
|
||||
// Create a RijndaelManaged object
|
||||
// with the specified key and IV.
|
||||
aesAlg = new RijndaelManaged();
|
||||
aesAlg.Key = key.GetBytes(aesAlg.KeySize / 8);
|
||||
|
||||
// Get the initialization vector from the encrypted stream
|
||||
aesAlg.IV = ReadByteArray(msDecrypt);
|
||||
|
||||
// Create a decrytor to perform the stream transform.
|
||||
var decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);
|
||||
using var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read);
|
||||
using var srDecrypt = new StreamReader(csDecrypt);
|
||||
|
||||
plaintext = srDecrypt.ReadToEnd();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clear the RijndaelManaged object.
|
||||
aesAlg?.Clear();
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private static byte[] ReadByteArray(Stream s)
|
||||
{
|
||||
byte[] rawLength = new byte[sizeof(int)];
|
||||
if (s.Read(rawLength, 0, rawLength.Length) != rawLength.Length)
|
||||
{
|
||||
throw new SystemException("Stream did not contain properly formatted byte array");
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[BitConverter.ToInt32(rawLength, 0)];
|
||||
if (s.Read(buffer, 0, buffer.Length) != buffer.Length)
|
||||
{
|
||||
throw new SystemException("Did not read byte array properly");
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -103,11 +103,13 @@ namespace QRBee.ViewModels
|
||||
var client = new HttpClient(GetInsecureHandler());
|
||||
|
||||
var service = new Core.Client.Client(_settings.QRBeeApiUrl,client);
|
||||
|
||||
Settings settings;
|
||||
RegistrationRequest request;
|
||||
try
|
||||
{
|
||||
//TODO Check if ClientId already in LocalSettings. If Yes update data in database
|
||||
var settings = _settings.LoadSettings();
|
||||
|
||||
settings = _settings.LoadSettings();
|
||||
|
||||
//save local settings
|
||||
settings.CardHolderName = CardHolderName;
|
||||
@ -128,7 +130,7 @@ namespace QRBee.ViewModels
|
||||
_privateKeyHandler.GeneratePrivateKey(settings.Name);
|
||||
}
|
||||
|
||||
var request = new RegistrationRequest
|
||||
request = new RegistrationRequest
|
||||
{
|
||||
DateOfBirth = DateOfBirth.ToString("yyyy-MM-dd"),
|
||||
Email = Email,
|
||||
@ -136,7 +138,17 @@ namespace QRBee.ViewModels
|
||||
CertificateRequest = _privateKeyHandler.CreateCertificateRequest(Email),
|
||||
RegisterAsMerchant = false
|
||||
};
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
//TODO: delete exception message in error message
|
||||
var page = Application.Current.MainPage.Navigation.NavigationStack.LastOrDefault();
|
||||
await page.DisplayAlert("Error", $"The ClientSide isn't working: {e.Message}", "Ok");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!settings.IsRegistered)
|
||||
{
|
||||
var response = await service.RegisterAsync(request);
|
||||
@ -159,8 +171,6 @@ namespace QRBee.ViewModels
|
||||
await page.DisplayAlert("Success", "Your data has been updated successfully", "Ok");
|
||||
}
|
||||
|
||||
|
||||
|
||||
await Shell.Current.GoToAsync($"//{nameof(MainPage)}");
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
@ -49,7 +50,7 @@ namespace QRBee.Api.Services
|
||||
var info = Convert(request);
|
||||
|
||||
var clientId = await _storage.PutUserInfo(info);
|
||||
var clientCertificate = _securityService.CreateCertificate(clientId,System.Convert.FromBase64String(request.CertificateRequest.RsaPublicKey));
|
||||
var clientCertificate = _securityService.CreateCertificate(clientId,Encoding.UTF8.GetBytes(request.CertificateRequest.RsaPublicKey.ConvertToJson()));
|
||||
|
||||
var convertedClientCertificate = Convert(clientCertificate, clientId);
|
||||
await _storage.InsertCertificate(convertedClientCertificate);
|
||||
@ -107,16 +108,45 @@ namespace QRBee.Api.Services
|
||||
}
|
||||
|
||||
//Check digital signature
|
||||
var verified = _securityService.Verify(
|
||||
Encoding.UTF8.GetBytes(certificateRequest.AsDataForSignature()),
|
||||
Encoding.UTF8.GetBytes(certificateRequest.Signature),
|
||||
_privateKeyHandler.LoadPrivateKey());
|
||||
using var rsa = LoadRsaPublicKey(certificateRequest.RsaPublicKey);
|
||||
|
||||
var data = Encoding.UTF8.GetBytes(certificateRequest.AsDataForSignature());
|
||||
var signature = System.Convert.FromBase64String(certificateRequest.Signature);
|
||||
var verified = rsa.VerifyData(
|
||||
data,
|
||||
signature,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1
|
||||
);
|
||||
|
||||
if (!verified)
|
||||
{
|
||||
throw new ApplicationException($"Digital signature is not valid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static RSA LoadRsaPublicKey(StringRSAParameters stringParameters)
|
||||
{
|
||||
var rsaParameters = new RSAParameters
|
||||
{
|
||||
Exponent = SafeConvertFromBase64(stringParameters.StringExponent),
|
||||
Modulus = SafeConvertFromBase64(stringParameters.StringModulus),
|
||||
P = SafeConvertFromBase64(stringParameters.StringP),
|
||||
Q = SafeConvertFromBase64(stringParameters.StringQ),
|
||||
DP = SafeConvertFromBase64(stringParameters.StringDP),
|
||||
DQ = SafeConvertFromBase64(stringParameters.StringDQ),
|
||||
InverseQ = SafeConvertFromBase64(stringParameters.StringInverseQ),
|
||||
D = SafeConvertFromBase64(stringParameters.StringD)
|
||||
};
|
||||
|
||||
var rsa = RSA.Create(rsaParameters);
|
||||
if (rsa == null)
|
||||
throw new CryptographicException("Can't create public key");
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private static byte[]? SafeConvertFromBase64(string? s) => string.IsNullOrWhiteSpace(s) ? null : System.Convert.FromBase64String(s);
|
||||
|
||||
private static UserInfo Convert(RegistrationRequest request)
|
||||
{
|
||||
return new UserInfo(request.Name, request.Email, request.DateOfBirth);
|
||||
|
||||
@ -19,7 +19,7 @@ namespace QRBee.Api.Services
|
||||
|
||||
private const string VeryBadNeverUseCertificatePassword = "+ñèbòFëc׎ßRúß¿ãçPN";
|
||||
|
||||
private string PrivateKeyFileName => $"{System.Environment.SpecialFolder.LocalApplicationData}/{FileName}";
|
||||
private string PrivateKeyFileName => $"{Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData)}/{FileName}";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Exists()
|
||||
@ -56,7 +56,7 @@ namespace QRBee.Api.Services
|
||||
|
||||
var request = new ReadableCertificateRequest
|
||||
{
|
||||
RsaPublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()),
|
||||
RsaPublicKey = ExportKey(rsa,false),
|
||||
SubjectName = subjectName
|
||||
};
|
||||
var data = Encoding.UTF8.GetBytes(request.AsDataForSignature());
|
||||
@ -67,6 +67,26 @@ namespace QRBee.Api.Services
|
||||
return request;
|
||||
}
|
||||
|
||||
private static StringRSAParameters ExportKey(RSA rsa, bool includePrivateKey)
|
||||
{
|
||||
var rsaParameters = rsa.ExportParameters(includePrivateKey);
|
||||
var stringParameters = new StringRSAParameters
|
||||
{
|
||||
StringExponent = SafeConvertToBase64(rsaParameters.Exponent),
|
||||
StringModulus = SafeConvertToBase64(rsaParameters.Modulus),
|
||||
StringP = SafeConvertToBase64(rsaParameters.P),
|
||||
StringQ = SafeConvertToBase64(rsaParameters.Q),
|
||||
StringDP = SafeConvertToBase64(rsaParameters.DP),
|
||||
StringDQ = SafeConvertToBase64(rsaParameters.DQ),
|
||||
StringInverseQ = SafeConvertToBase64(rsaParameters.InverseQ),
|
||||
StringD = SafeConvertToBase64(rsaParameters.D)
|
||||
};
|
||||
return stringParameters;
|
||||
}
|
||||
|
||||
private static string SafeConvertToBase64(byte[]? bytes) => bytes == null ? "" : Convert.ToBase64String(bytes);
|
||||
|
||||
|
||||
//private static string AsCsr(CertificateRequest request)
|
||||
//{
|
||||
// // https://stackoverflow.com/questions/65943968/how-to-convert-a-csr-text-file-into-net-core-standard-certificaterequest-for-s
|
||||
@ -149,7 +169,7 @@ namespace QRBee.Api.Services
|
||||
return _certificate;
|
||||
|
||||
if (!Exists())
|
||||
throw new CryptographicException("PrivateKey does not exist");
|
||||
GeneratePrivateKey("QRBeeCA");
|
||||
|
||||
_certificate = new X509Certificate2(PrivateKeyFileName, VeryBadNeverUseCertificatePassword);
|
||||
return _certificate;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user