435 lines
18 KiB
C#
435 lines
18 KiB
C#
using Disco.Data.Repository;
|
|
using Disco.Models.Repository;
|
|
using Disco.Models.Services.Interop.DiscoServices.Activation;
|
|
using Disco.Services.Interop.VicEduDept;
|
|
using Newtonsoft.Json;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Disco.Services.Interop.DiscoServices
|
|
{
|
|
public class ActivationService
|
|
{
|
|
private static readonly byte[] onlineServicesActivationKey;
|
|
internal const string baseUrl = "https://activate.discoict.com.au";
|
|
private readonly DiscoDataContext database;
|
|
|
|
static ActivationService()
|
|
{
|
|
using (var resourceStream = typeof(ActivationService).Assembly.GetManifestResourceStream("DiscoIct.OnlineServices.Activation.key"))
|
|
{
|
|
var key = new byte[resourceStream.Length];
|
|
resourceStream.Read(key, 0, key.Length);
|
|
onlineServicesActivationKey = key;
|
|
}
|
|
}
|
|
|
|
public ActivationService(DiscoDataContext database)
|
|
{
|
|
this.database = database;
|
|
}
|
|
|
|
public string GetDataStoreLocation => Path.Combine(database.DiscoConfiguration.DataStoreLocation, "Activations");
|
|
public bool RequiresCleanup => Directory.Exists(GetDataStoreLocation);
|
|
|
|
public string GetCallbackUrl()
|
|
=> $"{baseUrl}/api/callback";
|
|
|
|
/// <summary>
|
|
/// Begin the activation process
|
|
/// </summary>
|
|
/// <returns>A redirect URL where the user can attempt activation</returns>
|
|
public async Task<ChallengeModel> BeginActivation(User user, string completeUrl, string finalUrl)
|
|
{
|
|
if (database.DiscoConfiguration.IsActivated)
|
|
throw new InvalidOperationException("Deployment is already activated");
|
|
|
|
// generate activation key
|
|
var (privateKey, publicKey) = GenerateActivationKey();
|
|
|
|
// get machine ip addresses
|
|
var networkInterfaces = GetMachineIpAddresses();
|
|
|
|
var vicSchool = VicSmart.WhoAmI();
|
|
|
|
// get challenge
|
|
ChallengeResponse challenge;
|
|
using (var httpClient = new HttpClient())
|
|
{
|
|
httpClient.BaseAddress = new Uri(baseUrl);
|
|
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
|
|
var body = new ChallengeRequest()
|
|
{
|
|
DeploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId),
|
|
DeploymentVersion = typeof(ActivationService).Assembly.GetName().Version.ToString(4),
|
|
OrganisationName = database.DiscoConfiguration.OrganisationName,
|
|
PublicKey = publicKey,
|
|
UserId = user.UserId,
|
|
UserName = user.DisplayName,
|
|
UserEmail = user.EmailAddress,
|
|
CompleteUrl = completeUrl,
|
|
DeploymentIpAddresses = string.Join(";", networkInterfaces),
|
|
VicGovSchoolId = vicSchool?.Item1,
|
|
VicGovSchoolName = vicSchool?.Item2,
|
|
};
|
|
var requestJson = JsonConvert.SerializeObject(body);
|
|
|
|
using (var request = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(requestJson)))
|
|
{
|
|
request.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
|
|
var response = await httpClient.PostAsync("/api/challenge", request);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var responseJson = await response.Content.ReadAsStringAsync();
|
|
|
|
challenge = JsonConvert.DeserializeObject<ChallengeResponse>(responseJson);
|
|
}
|
|
}
|
|
|
|
// validate signature
|
|
if (!ValidateSignature(challenge))
|
|
throw new InvalidOperationException("Invalid challenge signature");
|
|
|
|
// decrypt challenge token
|
|
var token = Decrypt(privateKey, challenge.ChallengeIv, challenge.TimeStamp, challenge.Challenge);
|
|
if (token.Length != 32)
|
|
throw new InvalidOperationException("Unexpected challenge length");
|
|
// invert token
|
|
for (int i = 0; i < token.Length; i++)
|
|
token[i] = (byte)~token[i];
|
|
var challengeResponseIv = new byte[16];
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
rng.GetBytes(challengeResponseIv);
|
|
// encrypt token
|
|
var challengeResponse = Encrypt(privateKey, challengeResponseIv, challenge.TimeStamp, token);
|
|
if (challengeResponse.Length != 48)
|
|
throw new InvalidOperationException("Unexpected challenge response length");
|
|
|
|
var result = new ChallengeModel
|
|
{
|
|
Key = privateKey,
|
|
ActivationId = challenge.ActivationId,
|
|
UserId = user.UserId,
|
|
TimeStamp = challenge.TimeStamp,
|
|
ChallengeResponse = challengeResponse,
|
|
ChallengeResponseIv = challengeResponseIv,
|
|
RedirectUrl = $"{baseUrl}/",
|
|
};
|
|
|
|
// store activation
|
|
var datastore = GetDataStoreLocation;
|
|
if (!Directory.Exists(datastore))
|
|
Directory.CreateDirectory(datastore);
|
|
var resultJson = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(result));
|
|
var protectedResult = ProtectedData.Protect(resultJson, challenge.ActivationId.ToByteArray(), DataProtectionScope.LocalMachine);
|
|
var datastoreFile = Path.Combine(datastore, $"{challenge.ActivationId:N}.bin");
|
|
File.WriteAllBytes(datastoreFile, protectedResult);
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task CompleteActivation(Guid activationId, byte[] challenge, byte[] challengeIv, byte[] signature)
|
|
{
|
|
if (database.DiscoConfiguration.IsActivated)
|
|
throw new InvalidOperationException("Deployment is already activated");
|
|
|
|
// validate signature
|
|
if (!ValidateSignature(activationId, challenge, challengeIv, signature))
|
|
throw new InvalidOperationException("Invalid signature");
|
|
|
|
// load activation
|
|
var datastoreFile = Path.Combine(GetDataStoreLocation, $"{activationId:N}.bin");
|
|
if (!File.Exists(datastoreFile))
|
|
throw new InvalidOperationException("Activation not found");
|
|
var protectedActivation = File.ReadAllBytes(datastoreFile);
|
|
var activationJson = ProtectedData.Unprotect(protectedActivation, activationId.ToByteArray(), DataProtectionScope.LocalMachine);
|
|
var activation = JsonConvert.DeserializeObject<ChallengeModel>(Encoding.UTF8.GetString(activationJson));
|
|
|
|
// decrypt challenge token
|
|
var token = Decrypt(activation.Key, challengeIv, activation.TimeStamp, challenge);
|
|
if (token.Length != 32)
|
|
throw new InvalidOperationException("Unexpected challenge length");
|
|
// reverse token
|
|
Array.Reverse(token);
|
|
var challengeResponseIv = new byte[16];
|
|
using (var rng = RandomNumberGenerator.Create())
|
|
rng.GetBytes(challengeResponseIv);
|
|
// encrypt token
|
|
var challengeResponse = Encrypt(activation.Key, challengeResponseIv, activation.TimeStamp, token);
|
|
if (challengeResponse.Length != 48)
|
|
throw new InvalidOperationException("Unexpected challenge response length");
|
|
|
|
var responseSignature = Sign(activation.Key, activationId, challengeResponse, challengeResponseIv);
|
|
|
|
using (var httpClient = new HttpClient())
|
|
{
|
|
httpClient.BaseAddress = new Uri(baseUrl);
|
|
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
|
|
var body = new CompleteRequest()
|
|
{
|
|
DeploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId),
|
|
ActivationId = activationId,
|
|
ChallengeResponse = challengeResponse,
|
|
ChallengeResponseIv = challengeResponseIv,
|
|
Signature = responseSignature,
|
|
};
|
|
var requestJson = JsonConvert.SerializeObject(body);
|
|
|
|
using (var request = new ByteArrayContent(Encoding.UTF8.GetBytes(requestJson)))
|
|
{
|
|
request.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
|
|
var response = await httpClient.PostAsync("/api/complete", request);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
database.DiscoConfiguration.ActivationId = activationId;
|
|
database.DiscoConfiguration.ActivatedOn = DateTime.Now;
|
|
database.DiscoConfiguration.ActivatedBy = activation.UserId;
|
|
database.DiscoConfiguration.ActivationKey = activation.Key;
|
|
database.SaveChanges();
|
|
|
|
OnlineServicesAuthentication.UpdateActivation(database);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
public void CleanupExpiredActivations()
|
|
{
|
|
var dataStore = GetDataStoreLocation;
|
|
if (Directory.Exists(dataStore))
|
|
{
|
|
if (database.DiscoConfiguration.IsActivated)
|
|
{
|
|
Directory.Delete(dataStore, true);
|
|
}
|
|
else
|
|
{
|
|
var threshold = DateTime.Now.AddDays(-14);
|
|
foreach (var file in Directory.EnumerateFiles(dataStore, "*.bin"))
|
|
{
|
|
if (File.GetCreationTime(file) < threshold)
|
|
File.Delete(file);
|
|
}
|
|
if (!Directory.EnumerateFileSystemEntries(dataStore).Any())
|
|
Directory.Delete(dataStore);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool ValidateSignature(ChallengeResponse response)
|
|
{
|
|
var stream = new MemoryStream();
|
|
stream.Write(response.ActivationId.ToByteArray(), 0, 16);
|
|
stream.Write(BitConverter.GetBytes(response.TimeStamp), 0, 8);
|
|
stream.Write(response.Challenge, 0, response.Challenge.Length);
|
|
stream.Write(response.ChallengeIv, 0, response.ChallengeIv.Length);
|
|
|
|
return ValidateSignature(stream.ToArray(), response.Signature);
|
|
}
|
|
|
|
private static bool ValidateSignature(Guid activationId, byte[] challenge, byte[] challengeIv, byte[] signature)
|
|
{
|
|
var stream = new MemoryStream();
|
|
stream.Write(activationId.ToByteArray(), 0, 16);
|
|
stream.Write(challenge, 0, challenge.Length);
|
|
stream.Write(challengeIv, 0, challengeIv.Length);
|
|
return ValidateSignature(stream.ToArray(), signature);
|
|
}
|
|
|
|
private static bool ValidateSignature(byte[] bytes, byte[] signature)
|
|
{
|
|
byte[] hash;
|
|
using (var hasher = SHA256.Create())
|
|
{
|
|
hash = hasher.ComputeHash(bytes);
|
|
}
|
|
|
|
using (var serverKey = CngKey.Import(onlineServicesActivationKey, CngKeyBlobFormat.EccPublicBlob))
|
|
{
|
|
using (var ecdsa = new ECDsaCng(serverKey))
|
|
{
|
|
ecdsa.HashAlgorithm = CngAlgorithm.Sha256;
|
|
return ecdsa.VerifyHash(hash, signature);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] Sign(byte[] privateKey, Guid activationId, byte[] challengeResponse, byte[] challengeResponseIv)
|
|
{
|
|
using (var stream = new MemoryStream())
|
|
{
|
|
stream.Write(activationId.ToByteArray(), 0, 16);
|
|
stream.Write(challengeResponse, 0, challengeResponse.Length);
|
|
stream.Write(challengeResponseIv, 0, challengeResponseIv.Length);
|
|
byte[] hash;
|
|
using (var hasher = SHA256.Create())
|
|
{
|
|
hash = hasher.ComputeHash(stream.ToArray());
|
|
return SignHash(privateKey, hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static byte[] SignHash(byte[] privateKey, byte[] hash)
|
|
{
|
|
using (var key = CngKey.Import(privateKey, CngKeyBlobFormat.EccPrivateBlob))
|
|
{
|
|
using (var ecdsa = new ECDsaCng(key))
|
|
{
|
|
ecdsa.HashAlgorithm = CngAlgorithm.Sha256;
|
|
return ecdsa.SignHash(hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] Encrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data)
|
|
{
|
|
var key = DeriveEncryptionKey(privateKey, iv, timeStamp);
|
|
|
|
using (var aes = Aes.Create())
|
|
{
|
|
aes.Key = key;
|
|
aes.IV = iv;
|
|
aes.Mode = CipherMode.CBC;
|
|
aes.Padding = PaddingMode.PKCS7;
|
|
|
|
using (var encryptor = aes.CreateEncryptor())
|
|
{
|
|
var ms = new MemoryStream();
|
|
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
|
|
{
|
|
cs.Write(data, 0, data.Length);
|
|
}
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] Decrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data)
|
|
{
|
|
var key = DeriveEncryptionKey(privateKey, iv, timeStamp);
|
|
|
|
using (var aes = Aes.Create())
|
|
{
|
|
aes.Key = key;
|
|
aes.IV = iv;
|
|
aes.Mode = CipherMode.CBC;
|
|
aes.Padding = PaddingMode.PKCS7;
|
|
|
|
using (var decryptor = aes.CreateDecryptor())
|
|
{
|
|
var ms = new MemoryStream(data);
|
|
using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read))
|
|
{
|
|
var output = new MemoryStream();
|
|
cs.CopyTo(output);
|
|
return output.ToArray();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] DeriveEncryptionKey(byte[] privateKey, byte[] iv, long timeStamp)
|
|
{
|
|
using (var serverKey = CngKey.Import(onlineServicesActivationKey, CngKeyBlobFormat.EccPublicBlob))
|
|
{
|
|
using (var clientKey = CngKey.Import(privateKey, CngKeyBlobFormat.EccPrivateBlob))
|
|
{
|
|
using (var serverEcdh = new ECDiffieHellmanCng(serverKey))
|
|
{
|
|
using (var ecdh = new ECDiffieHellmanCng(clientKey))
|
|
{
|
|
return ecdh.DeriveKeyFromHash(serverEcdh.PublicKey, HashAlgorithmName.SHA256, iv, BitConverter.GetBytes(timeStamp));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static (byte[] privateKey, byte[] publicKey) GenerateActivationKey()
|
|
{
|
|
using (var key = CngKey.Create(CngAlgorithm.ECDiffieHellmanP521, null, new CngKeyCreationParameters
|
|
{
|
|
ExportPolicy = CngExportPolicies.AllowPlaintextExport,
|
|
KeyUsage = CngKeyUsages.KeyAgreement,
|
|
}))
|
|
{
|
|
var privateKey = key.Export(CngKeyBlobFormat.EccPrivateBlob);
|
|
var publicKey = key.Export(CngKeyBlobFormat.EccPublicBlob);
|
|
|
|
return (privateKey, publicKey);
|
|
}
|
|
}
|
|
|
|
private static List<string> GetMachineIpAddresses()
|
|
{
|
|
var ipAddresses = new List<string>();
|
|
foreach (var networkInterface in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
|
|
{
|
|
foreach (var address in networkInterface.GetIPProperties().UnicastAddresses)
|
|
{
|
|
if (address.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
ipAddresses.Add(address.Address.ToString());
|
|
}
|
|
}
|
|
return ipAddresses;
|
|
}
|
|
|
|
private class ChallengeRequest
|
|
{
|
|
public Guid DeploymentId { get; set; }
|
|
[MaxLength(20)]
|
|
public string DeploymentVersion { get; set; }
|
|
[MaxLength(200)]
|
|
public string OrganisationName { get; set; }
|
|
[MaxLength(158)]
|
|
public byte[] PublicKey { get; set; }
|
|
[StringLength(50)]
|
|
public string UserId { get; set; }
|
|
[StringLength(150)]
|
|
public string UserName { get; set; }
|
|
[StringLength(150)]
|
|
public string UserEmail { get; set; }
|
|
[StringLength(200)]
|
|
public string CompleteUrl { get; set; }
|
|
[StringLength(200)]
|
|
public string DeploymentIpAddresses { get; set; }
|
|
public string VicGovSchoolId { get; set; }
|
|
[StringLength(150)]
|
|
public string VicGovSchoolName { get; set; }
|
|
}
|
|
|
|
private class ChallengeResponse
|
|
{
|
|
public Guid ActivationId { get; set; }
|
|
public long TimeStamp { get; set; }
|
|
public byte[] Challenge { get; set; }
|
|
public byte[] ChallengeIv { get; set; }
|
|
public byte[] Signature { get; set; }
|
|
}
|
|
|
|
private class CompleteRequest
|
|
{
|
|
public Guid DeploymentId { get; set; }
|
|
public Guid ActivationId { get; set; }
|
|
public byte[] ChallengeResponse { get; set; }
|
|
public byte[] ChallengeResponseIv { get; set; }
|
|
public byte[] Signature { get; set; }
|
|
}
|
|
}
|
|
}
|