Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Project/Interfaces/IEncryptionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Threading.Tasks;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;

namespace Unity.WalmartAuthRelay.Interfaces;

public interface IEncryptionService
{
Task<string> EncryptAsync(IExecutionContext ctx, IGameApiClient client, string plainText);
Task<string> DecryptAsync(IExecutionContext ctx, IGameApiClient client, string encryptedText);
Task<bool> IsEncryptedAsync(string value);
}
1 change: 1 addition & 0 deletions Project/ModuleConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public void Setup(ICloudCodeConfig config)
config.Dependencies.AddSingleton<IMapperService, MapperService>();
config.Dependencies.AddSingleton<IConfigService, RemoteConfigService>();
config.Dependencies.AddSingleton<ISecretService, SecretManagerService>();
config.Dependencies.AddSingleton<IEncryptionService, AesEncryptionService>();
config.Dependencies.AddSingleton<IPlayerDataService, PlayerDataService>();
config.Dependencies.AddSingleton<IHttpClientFactory, HttpClientFactory>();
config.Dependencies.AddSingleton<IWalmartAuthService, WalmartAuthService>();
Expand Down
136 changes: 136 additions & 0 deletions Project/Services/AesEncryptionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Unity.WalmartAuthRelay.Interfaces;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;

namespace Unity.WalmartAuthRelay.Services;

public class AesEncryptionService : IEncryptionService
{
private readonly ILogger<AesEncryptionService> _logger;
private readonly ISecretService _secretService;

private const string ENCRYPTION_PREFIX = "ENC:";
private const string ENCRYPTION_KEY_NAME = "LCID_ENCRYPTION_KEY";
private const int IV_SIZE = 12; // 96-bit IV for GCM
private const int TAG_SIZE = 16; // 128-bit tag for GCM

public AesEncryptionService(ILogger<AesEncryptionService> logger, ISecretService secretService)
{
_logger = logger;
_secretService = secretService;
}

public async Task<string> EncryptAsync(IExecutionContext ctx, IGameApiClient client, string plainText)
{
if (string.IsNullOrEmpty(plainText))
{
throw new ArgumentException("Plain text cannot be null or empty", nameof(plainText));
}

try
{
var key = await GetEncryptionKeyAsync(ctx, client);
var plainBytes = Encoding.UTF8.GetBytes(plainText);

using var aes = new AesGcm(key, TAG_SIZE);
var iv = new byte[IV_SIZE];
var ciphertext = new byte[plainBytes.Length];
var tag = new byte[TAG_SIZE];

RandomNumberGenerator.Fill(iv);
aes.Encrypt(iv, plainBytes, ciphertext, tag);

// Format: IV + Tag + Ciphertext
var encryptedData = new byte[IV_SIZE + TAG_SIZE + ciphertext.Length];
Buffer.BlockCopy(iv, 0, encryptedData, 0, IV_SIZE);
Buffer.BlockCopy(tag, 0, encryptedData, IV_SIZE, TAG_SIZE);
Buffer.BlockCopy(ciphertext, 0, encryptedData, IV_SIZE + TAG_SIZE, ciphertext.Length);

return ENCRYPTION_PREFIX + Convert.ToBase64String(encryptedData);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to encrypt data");
throw new InvalidOperationException("Encryption failed", ex);
}
}

public async Task<string> DecryptAsync(IExecutionContext ctx, IGameApiClient client, string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText))
{
throw new ArgumentException("Encrypted text cannot be null or empty", nameof(encryptedText));
}

if (!encryptedText.StartsWith(ENCRYPTION_PREFIX))
{
throw new ArgumentException("Invalid encrypted data format", nameof(encryptedText));
}

try
{
var key = await GetEncryptionKeyAsync(ctx, client);
var base64Data = encryptedText.Substring(ENCRYPTION_PREFIX.Length);
var encryptedData = Convert.FromBase64String(base64Data);

if (encryptedData.Length < IV_SIZE + TAG_SIZE)
{
throw new ArgumentException("Invalid encrypted data length", nameof(encryptedText));
}

var iv = new byte[IV_SIZE];
var tag = new byte[TAG_SIZE];
var ciphertext = new byte[encryptedData.Length - IV_SIZE - TAG_SIZE];

Buffer.BlockCopy(encryptedData, 0, iv, 0, IV_SIZE);
Buffer.BlockCopy(encryptedData, IV_SIZE, tag, 0, TAG_SIZE);
Buffer.BlockCopy(encryptedData, IV_SIZE + TAG_SIZE, ciphertext, 0, ciphertext.Length);

using var aes = new AesGcm(key, TAG_SIZE);
var plainBytes = new byte[ciphertext.Length];
aes.Decrypt(iv, ciphertext, tag, plainBytes);

return Encoding.UTF8.GetString(plainBytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to decrypt data");
throw new InvalidOperationException("Decryption failed", ex);
}
}

public Task<bool> IsEncryptedAsync(string value)
{
return Task.FromResult(!string.IsNullOrEmpty(value) && value.StartsWith(ENCRYPTION_PREFIX));
}

private async Task<byte[]> GetEncryptionKeyAsync(IExecutionContext ctx, IGameApiClient client)
{
try
{
var keyBase64 = await _secretService.GetValueWithRetryAsync(ctx, client, ENCRYPTION_KEY_NAME);
if (string.IsNullOrEmpty(keyBase64))
{
throw new InvalidOperationException($"Encryption key '{ENCRYPTION_KEY_NAME}' not found in Secret Manager");
}

var key = Convert.FromBase64String(keyBase64);
if (key.Length != 32) // 256 bits = 32 bytes
{
throw new InvalidOperationException($"Encryption key must be 256 bits (32 bytes), but was {key.Length} bytes");
}

return key;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve encryption key from Secret Manager");
throw;
}
}
}
82 changes: 70 additions & 12 deletions Project/Services/PlayerDataService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
Expand All @@ -14,61 +15,118 @@ namespace Unity.WalmartAuthRelay.Services;
public class PlayerDataService : IPlayerDataService
{
private readonly ILogger<IPlayerDataService> _logger;
private readonly IEncryptionService _encryptionService;

// Name of key within Player Data that stores the user's LCID.
private const string LCID_PLAYER_DATA_KEY = "LCID";

public PlayerDataService(ILogger<IPlayerDataService> logger)
public PlayerDataService(ILogger<IPlayerDataService> logger, IEncryptionService encryptionService)
{
_logger = logger;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_encryptionService = encryptionService ?? throw new ArgumentNullException(nameof(encryptionService));
}

public async Task<string> GetPlayerLcidAsync(IExecutionContext ctx, IGameApiClient client)
{
string playerLcid;

if (ctx.PlayerId == null)
{
var message = "Received null PlayerId from context in StorePlayerLcidAsync()";
var message = "Received null PlayerId from context in GetPlayerLcidAsync()";
_logger.LogError(message);
throw new ApiException(ApiExceptionType.InvalidParameters, message);
}

string storedValue;
try
{
var result = await client.CloudSaveData.GetItemsAsync(ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId,
new List<string>{ LCID_PLAYER_DATA_KEY });
playerLcid = (string)result.Data.Results.First().Value;
storedValue = (string)result.Data.Results.First().Value;
}
catch (ApiException e)
{
_logger.LogError($"Failed to retrieve player's LCID in Player Data: {e.Message}");
throw;
}

return playerLcid;
// Check if the value is already encrypted
if (await _encryptionService.IsEncryptedAsync(storedValue))
{
// Decrypt and return
try
{
var decryptedValue = await _encryptionService.DecryptAsync(ctx, client, storedValue);
_logger.LogDebug("Successfully decrypted LCID for player {PlayerId}", ctx.PlayerId);
return decryptedValue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to decrypt LCID for player {PlayerId}", ctx.PlayerId);
throw;
}
}

// Value is plain text - perform lazy migration
_logger.LogInformation("Performing lazy migration: encrypting plain text LCID for player {PlayerId}", ctx.PlayerId);

try
{
// Store the encrypted version (lazy migration)
await StorePlayerLcidAsync(ctx, client, storedValue);
return storedValue;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to migrate LCID to encrypted format for player {PlayerId}, returning plain text", ctx.PlayerId);
// Return plain text even if migration fails to maintain functionality
return storedValue;
}
}

public async Task<bool> StorePlayerLcidAsync(IExecutionContext ctx, IGameApiClient client, string lcid)
{
bool stored;

if (ctx.PlayerId == null)
{
var message = "Received null PlayerId from context in StorePlayerLcidAsync()";
_logger.LogError(message);
throw new ApiException(ApiExceptionType.InvalidParameters, message);
}

if (string.IsNullOrEmpty(lcid))
{
var message = "LCID cannot be null or empty";
_logger.LogError(message);
throw new ApiException(ApiExceptionType.InvalidParameters, message);
}

string encryptedLcid;
try
{
// Always encrypt the LCID before storing
encryptedLcid = await _encryptionService.EncryptAsync(ctx, client, lcid);
_logger.LogDebug("Successfully encrypted LCID for player {PlayerId}", ctx.PlayerId);
}
catch (Exception ex)
{
var message = $"Failed to encrypt LCID for player {ctx.PlayerId}: {ex.Message}";
_logger.LogError(ex, message);
throw new InvalidOperationException(message, ex);
}

bool stored;
try
{
var result = await client.CloudSaveData.SetItemAsync(ctx, ctx.AccessToken, ctx.ProjectId, ctx.PlayerId,
new SetItemBody(LCID_PLAYER_DATA_KEY, lcid));
new SetItemBody(LCID_PLAYER_DATA_KEY, encryptedLcid));
stored = result.StatusCode == HttpStatusCode.OK;

if (stored)
{
_logger.LogDebug("Successfully stored encrypted LCID for player {PlayerId}", ctx.PlayerId);
}
}
catch (ApiException e)
{
var message = $"Failed to store player's LCID in Player Data: {e.Message}";
var message = $"Failed to store encrypted LCID in Player Data: {e.Message}";
_logger.LogError(message);
throw;
}
Expand Down
Loading