feature: online activation

This commit is contained in:
Gary Sharp
2024-12-27 14:28:56 +11:00
parent 39ba206831
commit b6dfaa3445
35 changed files with 2287 additions and 346 deletions
+11 -4
View File
@@ -374,9 +374,12 @@
<Compile Include="Interop\ActiveDirectory\ADUserAccountControlFlags.cs" />
<Compile Include="Interop\ActiveDirectory\Description.cs" />
<Compile Include="Interop\ActiveDirectory\IADObject.cs" />
<Compile Include="Interop\DiscoServices\ActivationCleanupTask.cs" />
<Compile Include="Interop\DiscoServices\ActivationService.cs" />
<Compile Include="Interop\DiscoServices\DiscoServiceHelpers.cs" />
<Compile Include="Interop\DiscoServices\Jobs.cs" />
<Compile Include="Interop\DiscoServices\LicenseValidationTask.cs" />
<Compile Include="Interop\DiscoServices\OnlineServicesAuthentication.cs" />
<Compile Include="Interop\DiscoServices\PluginLibrary.cs" />
<Compile Include="Interop\DiscoServices\PluginLibraryUpdateTask.cs" />
<Compile Include="Interop\IIS\PreserveIisBindingsTask.cs" />
@@ -536,6 +539,9 @@
<Generator>TextTemplatingFileGenerator</Generator>
<LastGenOutput>Claims.cs</LastGenOutput>
</None>
<EmbeddedResource Include="Interop\DiscoServices\Activation.key">
<LogicalName>DiscoIct.OnlineServices.Activation.key</LogicalName>
</EmbeddedResource>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
@@ -561,10 +567,11 @@
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>
<PostBuildEvent>
if not exist "$(TargetDir)x86" md "$(TargetDir)x86"
xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\x86\*.*" "$(TargetDir)x86"
if not exist "$(TargetDir)amd64" md "$(TargetDir)amd64"
xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\amd64\*.*" "$(TargetDir)amd64"</PostBuildEvent>
if not exist "$(TargetDir)x86" md "$(TargetDir)x86"
xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\x86\*.*" "$(TargetDir)x86"
if not exist "$(TargetDir)amd64" md "$(TargetDir)amd64"
xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\amd64\*.*" "$(TargetDir)amd64"
</PostBuildEvent>
</PropertyGroup>
<Import Project="..\packages\Microsoft.Bcl.Build.1.0.14\tools\Microsoft.Bcl.Build.targets" Condition="Exists('..\packages\Microsoft.Bcl.Build.1.0.14\tools\Microsoft.Bcl.Build.targets')" />
<Target Name="EnsureBclBuildImported" BeforeTargets="BeforeBuild" Condition="'$(BclBuildImported)' == ''">
@@ -123,7 +123,7 @@ namespace Disco
{
var epoc = new DateTime(unixEpocOffset, DateTimeKind.Utc);
var offset = d.ToUniversalTime() - epoc;
return offset.Ticks / 10000;
return offset.Ticks / TimeSpan.TicksPerMillisecond;
}
public static long? ToUnixEpoc(this DateTime? d)
{
@@ -132,6 +132,11 @@ namespace Disco
else
return null;
}
public static DateTime FromUnixEpoc(this long d)
{
var epoc = new DateTime(unixEpocOffset, DateTimeKind.Utc);
return epoc.AddMilliseconds(d);
}
public static string ToISO8601(this DateTime d)
{
Binary file not shown.
@@ -0,0 +1,42 @@
using Disco.Data.Repository;
using Disco.Services.Tasks;
using Quartz;
using System;
namespace Disco.Services.Interop.DiscoServices
{
public class ActivationCleanupTask : ScheduledTask
{
public override string TaskName { get { return "Activation Cleanup"; } }
public override bool LogExceptionsOnly => true;
public override void InitalizeScheduledTask(DiscoDataContext Database)
{
var service = new ActivationService(Database);
if (service.RequiresCleanup)
{
// Trigger in 1 + 0-29 minutes
var rng = new Random();
var delay = rng.Next(30) + 1;
TriggerBuilder triggerBuilder = TriggerBuilder.Create()
.StartAt(DateTimeOffset.Now.AddMinutes(delay));
ScheduleTask(triggerBuilder);
base.InitalizeScheduledTask(Database);
}
}
protected override void ExecuteTask()
{
using (var database = new DiscoDataContext())
{
var service = new ActivationService(database);
service.CleanupExpiredActivations();
}
}
}
}
@@ -0,0 +1,434 @@
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; }
}
}
}
@@ -0,0 +1,167 @@
using Disco.Data.Repository;
using Newtonsoft.Json;
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Disco.Services.Interop.DiscoServices
{
public static class OnlineServicesAuthentication
{
private static SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
private static readonly Guid deploymentId;
private static Guid? activationId;
private static byte[] key;
private static string token;
private static DateTime? tokenExpires;
static OnlineServicesAuthentication()
{
using (var database = new DiscoDataContext())
{
deploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId);
UpdateActivation(database);
}
}
public static bool IsActivated => activationId.HasValue;
public static string GetToken()
=> GetTokenAsync().Result;
public async static Task<string> GetTokenAsync()
{
var localExpires = tokenExpires;
var localToken = token;
if (tokenExpires != null && tokenExpires.Value > DateTime.UtcNow && localToken != null)
return localToken;
if (!IsActivated)
throw new InvalidOperationException("Not activated");
await semaphore.WaitAsync();
try
{
localExpires = tokenExpires;
localToken = token;
if (tokenExpires != null && tokenExpires.Value < DateTime.UtcNow && localToken != null)
return localToken;
if (!IsActivated)
throw new InvalidOperationException("Not activated");
using (var httpClient = new HttpClient())
{
httpClient.BaseAddress = new Uri(ActivationService.baseUrl);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var timeStamp = DateTime.UtcNow.ToUnixEpoc();
var iv = new byte[32];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(iv);
var dataStream = new MemoryStream(16 + 16 + 8 + iv.Length);
dataStream.Write(deploymentId.ToByteArray(), 0, 16);
dataStream.Write(activationId.Value.ToByteArray(), 0, 16);
dataStream.Write(BitConverter.GetBytes(timeStamp), 0, 8);
dataStream.Write(iv, 0, iv.Length);
byte[] hash;
using (var hasher = SHA256.Create())
hash = hasher.ComputeHash(dataStream.ToArray());
var signature = ActivationService.SignHash(key, hash);
var body = new AuthenticationRequest()
{
DeploymentId = deploymentId,
ActivationId = activationId.Value,
TimeStamp = timeStamp,
IV = iv,
Signature = signature,
};
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/authenticate", request);
if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.BadRequest)
{
var responseJson = await response.Content.ReadAsStringAsync();
var authResponse = JsonConvert.DeserializeObject<AuthenticationResponse>(responseJson);
if (authResponse == null)
throw new InvalidOperationException("Failed to authenticate (empty response)");
if (!authResponse.Success)
throw new InvalidOperationException($"Failed to authenticate ({authResponse.ErrorMessage})");
token = authResponse.Token;
tokenExpires = DateTimeExtensions.FromUnixEpoc(authResponse.Expiry.Value).AddMinutes(-5);
return token;
}
else
throw new InvalidOperationException($"Failed to authenticate ({response.StatusCode} {response.ReasonPhrase})");
}
}
}
finally
{
semaphore.Release();
}
}
internal static void UpdateActivation(DiscoDataContext database)
{
semaphore.Wait();
try
{
var config = database.DiscoConfiguration;
if (config.IsActivated)
{
activationId = config.ActivationId;
key = config.ActivationKey;
token = null;
tokenExpires = null;
}
else
{
activationId = null;
key = null;
token = null;
tokenExpires = null;
}
}
finally
{
semaphore.Release();
}
}
private class AuthenticationRequest
{
public Guid DeploymentId { get; set; }
public Guid ActivationId { get; set; }
public long TimeStamp { get; set; }
public byte[] IV { get; set; }
public byte[] Signature { get; set; }
}
private class AuthenticationResponse
{
public bool Success { get; set; }
public string Token { get; set; }
public long? Expiry { get; set; }
public string ErrorMessage { get; set; }
}
}
}