From 04be92a1dfbd68d54dc16e684309d02a1368f789 Mon Sep 17 00:00:00 2001 From: Gary Sharp Date: Thu, 1 Jan 2026 19:21:00 +1100 Subject: [PATCH] feature: online session authentication and one-way decryption --- Disco.Services/Disco.Services.csproj | 1 + .../DiscoServices/ActivationService.cs | 133 +++++++++++++++++- .../AuthenticationSessionScope.cs | 11 ++ .../OnlineServicesAuthentication.cs | 113 ++++++++++----- 4 files changed, 223 insertions(+), 35 deletions(-) create mode 100644 Disco.Services/Interop/DiscoServices/AuthenticationSessionScope.cs diff --git a/Disco.Services/Disco.Services.csproj b/Disco.Services/Disco.Services.csproj index c94f9a01..1b57ab56 100644 --- a/Disco.Services/Disco.Services.csproj +++ b/Disco.Services/Disco.Services.csproj @@ -484,6 +484,7 @@ + diff --git a/Disco.Services/Interop/DiscoServices/ActivationService.cs b/Disco.Services/Interop/DiscoServices/ActivationService.cs index cfcda7ab..d9cb1b12 100644 --- a/Disco.Services/Interop/DiscoServices/ActivationService.cs +++ b/Disco.Services/Interop/DiscoServices/ActivationService.cs @@ -18,6 +18,7 @@ namespace Disco.Services.Interop.DiscoServices { public class ActivationService { + private static readonly HttpClient httpClient; private static readonly byte[] onlineServicesActivationKey; private readonly DiscoDataContext database; @@ -29,6 +30,10 @@ namespace Disco.Services.Interop.DiscoServices resourceStream.Read(key, 0, key.Length); onlineServicesActivationKey = key; } + httpClient = new HttpClient(new OnlineServicesAuthenticatedHandler()) + { + BaseAddress = DiscoServiceHelpers.ActivationServiceUrl + }; } public ActivationService(DiscoDataContext database) @@ -247,6 +252,38 @@ namespace Disco.Services.Interop.DiscoServices } } + public static async Task Post(string url, object request) + { + var stream = new MemoryStream(); + using (var jsonWriter = new StreamWriter(stream, new UTF8Encoding(false), 1024, true)) + { + var serializer = new JsonSerializer(); + serializer.Serialize(jsonWriter, request); + } + stream.Position = 0; + using (var content = new StreamContent(stream)) + { + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using (var response = await httpClient.PostAsync(url, content)) + { + response.EnsureSuccessStatusCode(); + + using (var responseContent = await response.Content.ReadAsStreamAsync()) + { + using (var reader = new StreamReader(responseContent, Encoding.UTF8)) + { + using (var jsonReader = new JsonTextReader(reader)) + { + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonReader); + } + } + } + } + } + } + private static bool ValidateSignature(ChallengeResponse response) { var stream = new MemoryStream(); @@ -255,7 +292,7 @@ namespace Disco.Services.Interop.DiscoServices stream.Write(response.Challenge, 0, response.Challenge.Length); stream.Write(response.ChallengeIv, 0, response.ChallengeIv.Length); - return ValidateSignature(stream.ToArray(), response.Signature); + return ValidateOnlineServicesSignature(stream.ToArray(), response.Signature); } private static bool ValidateSignature(Guid activationId, byte[] challenge, byte[] challengeIv, byte[] signature) @@ -264,10 +301,10 @@ namespace Disco.Services.Interop.DiscoServices stream.Write(activationId.ToByteArray(), 0, 16); stream.Write(challenge, 0, challenge.Length); stream.Write(challengeIv, 0, challengeIv.Length); - return ValidateSignature(stream.ToArray(), signature); + return ValidateOnlineServicesSignature(stream.ToArray(), signature); } - private static bool ValidateSignature(byte[] bytes, byte[] signature) + public static bool ValidateOnlineServicesSignature(byte[] bytes, byte[] signature) { byte[] hash; using (var hasher = SHA256.Create()) @@ -313,6 +350,11 @@ namespace Disco.Services.Interop.DiscoServices } } + public static byte[] SignSHA256Hash(byte[] hash) + { + return SignHash(OnlineServicesAuthentication.Key, hash); + } + private static byte[] Encrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data) { var key = DeriveEncryptionKey(privateKey, iv, timeStamp); @@ -336,6 +378,17 @@ namespace Disco.Services.Interop.DiscoServices } } + public static byte[] Encrypt(byte[] data, out byte[] iv, out long timeStamp) + { + iv = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(iv); + + timeStamp = DateTime.UtcNow.Ticks; + + return Encrypt(OnlineServicesAuthentication.Key, iv, timeStamp, data); + } + private static byte[] Decrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data) { var key = DeriveEncryptionKey(privateKey, iv, timeStamp); @@ -360,6 +413,63 @@ namespace Disco.Services.Interop.DiscoServices } } + public static byte[] Decrypt(byte[] data, byte[] iv, long timeStamp) + { + return Decrypt(OnlineServicesAuthentication.Key, iv, timeStamp, data); + } + + public static byte[] OneWayDecrypt(byte[] data) + { + var span = data.AsSpan(); + + if (span.Length < 13) + throw new ArgumentException("Data is too short", nameof(data)); + Span magicBytes = new byte[] { 0xF0, 0x9F, 0x94, 0x8F, 0x44, 0x69, 0x73, 0x63, 0x6F, 0x49, 0x43, 0x54 }.AsSpan(); + if (!MemoryExtensions.SequenceEqual(magicBytes, span.Slice(0, 12))) + throw new InvalidOperationException("Invalid format signature"); + + span = span.Slice(12); + + byte[] readChunk(ref Span source) + { + var length = source[0]; + if (source.Length < (length + 1)) + throw new ArgumentException("Data is too short", nameof(data)); + var buffer = new byte[length]; + source.Slice(1, length).CopyTo(buffer); + source = source.Slice(length + 1); + return buffer; + } + + var publicKey = readChunk(ref span); + var iv = readChunk(ref span); + var signature = readChunk(ref span); + + var key = DeriveOneWayDecryptingKey(OnlineServicesAuthentication.Key, publicKey, iv); + + var outputStream = new MemoryStream(); + 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(span.ToArray()); + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + { + cs.CopyTo(outputStream); + } + } + } + var output = outputStream.ToArray(); + if (!ValidateOnlineServicesSignature(output, signature)) + throw new InvalidOperationException("Invalid encrypted data signature"); + return output; + } + private static byte[] DeriveEncryptionKey(byte[] privateKey, byte[] iv, long timeStamp) { using (var serverKey = CngKey.Import(onlineServicesActivationKey, CngKeyBlobFormat.EccPublicBlob)) @@ -377,6 +487,23 @@ namespace Disco.Services.Interop.DiscoServices } } + private static byte[] DeriveOneWayDecryptingKey(byte[] privateKey, byte[] publicKey, byte[] iv) + { + using (var ephemeralKey = CngKey.Import(publicKey, CngKeyBlobFormat.EccPublicBlob)) + { + using (var clientKey = CngKey.Import(privateKey, CngKeyBlobFormat.EccPrivateBlob)) + { + using (var ephemeralEcdh = new ECDiffieHellmanCng(ephemeralKey)) + { + using (var ecdh = new ECDiffieHellmanCng(clientKey)) + { + return ecdh.DeriveKeyFromHash(ephemeralEcdh.PublicKey, HashAlgorithmName.SHA256, iv, null); + } + } + } + } + } + private static (byte[] privateKey, byte[] publicKey) GenerateActivationKey() { using (var key = CngKey.Create(CngAlgorithm.ECDiffieHellmanP521, null, new CngKeyCreationParameters diff --git a/Disco.Services/Interop/DiscoServices/AuthenticationSessionScope.cs b/Disco.Services/Interop/DiscoServices/AuthenticationSessionScope.cs new file mode 100644 index 00000000..d02154f7 --- /dev/null +++ b/Disco.Services/Interop/DiscoServices/AuthenticationSessionScope.cs @@ -0,0 +1,11 @@ +using System; + +namespace Disco.Services.Interop.DiscoServices +{ + [Flags] + public enum AuthenticationSessionScope + { + Ping = 1, + Host = 2, + } +} diff --git a/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs b/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs index bbe99ebb..3319385b 100644 --- a/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs +++ b/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs @@ -1,7 +1,9 @@ using Disco.Data.Repository; +using Disco.Models.Repository; using Newtonsoft.Json; using System; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,7 +16,7 @@ namespace Disco.Services.Interop.DiscoServices { public static class OnlineServicesAuthentication { - private static SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); + private static readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); private static readonly Guid deploymentId; private static Guid? activationId; private static byte[] key; @@ -31,11 +33,12 @@ namespace Disco.Services.Interop.DiscoServices } public static bool IsActivated => activationId.HasValue; + internal static byte[] Key => key.ToArray() ?? throw new InvalidOperationException("Not activated"); public static string GetToken() => GetTokenAsync().Result; - public async static Task GetTokenAsync() + public static async Task GetTokenAsync() { var localExpires = tokenExpires; var localToken = token; @@ -56,40 +59,40 @@ namespace Disco.Services.Interop.DiscoServices if (!IsActivated) throw new InvalidOperationException("Not activated"); - using (var httpClient = new HttpClient()) + 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() { - httpClient.BaseAddress = DiscoServiceHelpers.ActivationServiceUrl; - httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + DeploymentId = deploymentId, + ActivationId = activationId.Value, + TimeStamp = timeStamp, + IV = iv, + Signature = signature, + }; + var requestJson = JsonConvert.SerializeObject(body); - var timeStamp = DateTime.UtcNow.ToUnixEpoc(); - var iv = new byte[32]; - using (var rng = RandomNumberGenerator.Create()) - rng.GetBytes(iv); + using (var request = new ByteArrayContent(Encoding.UTF8.GetBytes(requestJson))) + { + request.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - 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() + using (var httpClient = new HttpClient()) { - 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"); + httpClient.BaseAddress = DiscoServiceHelpers.ActivationServiceUrl; + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await httpClient.PostAsync("/api/authenticate", request); @@ -120,6 +123,32 @@ namespace Disco.Services.Interop.DiscoServices } } + public static async Task CreateSession(AuthenticationSessionScope scope, User user, Uri returnUrl) + { + if (!IsActivated) + throw new InvalidOperationException("Not activated"); + + var request = new AuthenticationSessionRequest() + { + DeploymentId = deploymentId, + ActivationId = activationId.Value, + TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Scope = scope, + UserId = user.UserId, + UserName = user.DisplayName ?? string.Empty, + UserEmail = user.EmailAddress, + UserPhone = user.PhoneNumber, + ReturnUrl = returnUrl.ToString(), + }; + + var response = await ActivationService.Post($"/api/authenticate/session", request); + + if (response.Success) + return new Uri(response.Endpoint); + else + throw new InvalidOperationException($"Failed to create authentication session: {response.ErrorMessage}"); + } + internal static void UpdateActivation(DiscoDataContext database) { semaphore.Wait(); @@ -165,5 +194,25 @@ namespace Disco.Services.Interop.DiscoServices public int? ExpiresInSeconds { get; set; } public string ErrorMessage { get; set; } } + + private class AuthenticationSessionRequest + { + public Guid DeploymentId { get; set; } + public Guid ActivationId { get; set; } + public long TimeStamp { get; set; } + public AuthenticationSessionScope Scope { get; set; } + public string UserId { get; set; } + public string UserName { get; set; } + public string UserEmail { get; set; } + public string UserPhone { get; set; } + public string ReturnUrl { get; set; } + } + + private class AuthenticationSessionResponse + { + public bool Success { get; set; } + public string Endpoint { get; set; } + public string ErrorMessage { get; set; } + } } }