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; 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 Uri GetCallbackUrl() => new Uri(DiscoServiceHelpers.ActivationServiceUrl, "/api/callback"); /// /// Begin the activation process /// /// A redirect URL where the user can attempt activation public async Task 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 = DiscoServiceHelpers.ActivationServiceUrl; 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(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(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 = new Uri(DiscoServiceHelpers.ActivationServiceUrl, "/").ToString(), }; // 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(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 = DiscoServiceHelpers.ActivationServiceUrl; 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 GetMachineIpAddresses() { var ipAddresses = new List(); 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; } } } }