568 lines
16 KiB
C#
568 lines
16 KiB
C#
using System;
|
|
using System.ComponentModel;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
|
|
namespace Bd2LoginUI;
|
|
|
|
internal sealed class MemoryAccessTokenStore
|
|
{
|
|
private string value;
|
|
private string origin;
|
|
private string provider;
|
|
private long expiresAt;
|
|
|
|
public string Get()
|
|
{
|
|
return value ?? string.Empty;
|
|
}
|
|
|
|
public void Set(string token, string tokenOrigin, string tokenProvider, long expiresInSeconds)
|
|
{
|
|
if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(tokenOrigin) ||
|
|
string.IsNullOrEmpty(tokenProvider) || expiresInSeconds <= 0)
|
|
{
|
|
throw new ArgumentException("access token metadata is incomplete", nameof(token));
|
|
}
|
|
value = token;
|
|
origin = tokenOrigin;
|
|
provider = tokenProvider;
|
|
expiresAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiresInSeconds;
|
|
}
|
|
|
|
public bool IsUsable(string tokenOrigin, long safetyWindowSeconds = 30)
|
|
{
|
|
return !string.IsNullOrEmpty(value) && origin == tokenOrigin &&
|
|
!string.IsNullOrEmpty(provider) &&
|
|
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds() + safetyWindowSeconds;
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
value = null;
|
|
origin = null;
|
|
provider = null;
|
|
expiresAt = 0;
|
|
}
|
|
}
|
|
|
|
internal interface IRefreshCredentialStore
|
|
{
|
|
bool IsSupported { get; }
|
|
bool Contains(string origin);
|
|
RefreshCredential Load(string origin);
|
|
void Save(string origin, RefreshCredential credential);
|
|
void Delete(string origin);
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class RefreshCredential
|
|
{
|
|
public int version;
|
|
public string origin;
|
|
public string provider;
|
|
public string refresh_token;
|
|
public long expires_at;
|
|
public string pending_attempt_id;
|
|
public string pending_refresh_token;
|
|
}
|
|
|
|
internal static class PlatformRefreshCredentialStore
|
|
{
|
|
public static IRefreshCredentialStore Create()
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
return new WindowsDpapiRefreshCredentialStore();
|
|
}
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
{
|
|
return new MacOSKeychainRefreshCredentialStore();
|
|
}
|
|
return new UnsupportedRefreshCredentialStore();
|
|
}
|
|
}
|
|
|
|
internal sealed class UnsupportedRefreshCredentialStore : IRefreshCredentialStore
|
|
{
|
|
public bool IsSupported => false;
|
|
|
|
public bool Contains(string origin)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
public RefreshCredential Load(string origin)
|
|
{
|
|
throw new PlatformNotSupportedException("secure refresh credential storage is unavailable on this platform");
|
|
}
|
|
|
|
public void Save(string origin, RefreshCredential credential)
|
|
{
|
|
throw new PlatformNotSupportedException("secure refresh credential storage is unavailable on this platform");
|
|
}
|
|
|
|
public void Delete(string origin)
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class WindowsDpapiRefreshCredentialStore : IRefreshCredentialStore
|
|
{
|
|
private const string Purpose = "BD2.LoginUI.Refresh.v1";
|
|
private const string PreferencePrefix = "BD2OAuthRefreshV1_";
|
|
private const uint CryptProtectUiForbidden = 0x1;
|
|
|
|
public bool IsSupported => true;
|
|
|
|
public bool Contains(string origin)
|
|
{
|
|
return PlayerPrefs.HasKey(PreferenceFor(origin));
|
|
}
|
|
|
|
public RefreshCredential Load(string origin)
|
|
{
|
|
string encoded = PlayerPrefs.GetString(PreferenceFor(origin), string.Empty);
|
|
if (string.IsNullOrEmpty(encoded))
|
|
{
|
|
throw new InvalidDataException("saved Windows credential is empty");
|
|
}
|
|
byte[] cipher;
|
|
try
|
|
{
|
|
cipher = Convert.FromBase64String(encoded);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
throw new InvalidDataException("saved Windows credential is malformed", ex);
|
|
}
|
|
byte[] entropy = Entropy(origin);
|
|
byte[] plain = null;
|
|
try
|
|
{
|
|
plain = Unprotect(cipher, entropy);
|
|
string json = Encoding.UTF8.GetString(plain);
|
|
return JsonUtility.FromJson<RefreshCredential>(json);
|
|
}
|
|
finally
|
|
{
|
|
Clear(cipher);
|
|
Clear(entropy);
|
|
Clear(plain);
|
|
}
|
|
}
|
|
|
|
public void Save(string origin, RefreshCredential credential)
|
|
{
|
|
byte[] plain = Encoding.UTF8.GetBytes(JsonUtility.ToJson(credential));
|
|
byte[] entropy = Entropy(origin);
|
|
byte[] cipher = null;
|
|
try
|
|
{
|
|
cipher = Protect(plain, entropy);
|
|
PlayerPrefs.SetString(PreferenceFor(origin), Convert.ToBase64String(cipher));
|
|
PlayerPrefs.Save();
|
|
}
|
|
finally
|
|
{
|
|
Clear(plain);
|
|
Clear(entropy);
|
|
Clear(cipher);
|
|
}
|
|
}
|
|
|
|
public void Delete(string origin)
|
|
{
|
|
PlayerPrefs.DeleteKey(PreferenceFor(origin));
|
|
PlayerPrefs.Save();
|
|
}
|
|
|
|
private static string PreferenceFor(string origin)
|
|
{
|
|
byte[] input = Encoding.UTF8.GetBytes(origin);
|
|
byte[] digest;
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
digest = sha.ComputeHash(input);
|
|
}
|
|
try
|
|
{
|
|
return PreferencePrefix + Hex(digest);
|
|
}
|
|
finally
|
|
{
|
|
Clear(input);
|
|
Clear(digest);
|
|
}
|
|
}
|
|
|
|
private static byte[] Entropy(string origin)
|
|
{
|
|
return Encoding.UTF8.GetBytes(Purpose + "\0" + origin);
|
|
}
|
|
|
|
private static byte[] Protect(byte[] plain, byte[] entropy)
|
|
{
|
|
DataBlob input = Allocate(plain);
|
|
DataBlob optionalEntropy = Allocate(entropy);
|
|
DataBlob output = default;
|
|
try
|
|
{
|
|
if (!CryptProtectData(ref input, Purpose, ref optionalEntropy, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, out output))
|
|
{
|
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows DPAPI could not protect the refresh credential");
|
|
}
|
|
return Copy(output);
|
|
}
|
|
finally
|
|
{
|
|
ZeroAndFreeHGlobal(ref input);
|
|
ZeroAndFreeHGlobal(ref optionalEntropy);
|
|
LocalFreeBlob(ref output, false);
|
|
}
|
|
}
|
|
|
|
private static byte[] Unprotect(byte[] cipher, byte[] entropy)
|
|
{
|
|
DataBlob input = Allocate(cipher);
|
|
DataBlob optionalEntropy = Allocate(entropy);
|
|
DataBlob output = default;
|
|
IntPtr description = IntPtr.Zero;
|
|
try
|
|
{
|
|
if (!CryptUnprotectData(ref input, out description, ref optionalEntropy, IntPtr.Zero, IntPtr.Zero, CryptProtectUiForbidden, out output))
|
|
{
|
|
throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows DPAPI could not unprotect the refresh credential");
|
|
}
|
|
return Copy(output);
|
|
}
|
|
finally
|
|
{
|
|
ZeroAndFreeHGlobal(ref input);
|
|
ZeroAndFreeHGlobal(ref optionalEntropy);
|
|
LocalFreeBlob(ref output, true);
|
|
if (description != IntPtr.Zero)
|
|
{
|
|
LocalFree(description);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static DataBlob Allocate(byte[] bytes)
|
|
{
|
|
if (bytes == null || bytes.Length == 0)
|
|
{
|
|
return default;
|
|
}
|
|
DataBlob blob = new DataBlob { size = bytes.Length, data = Marshal.AllocHGlobal(bytes.Length) };
|
|
Marshal.Copy(bytes, 0, blob.data, bytes.Length);
|
|
return blob;
|
|
}
|
|
|
|
private static byte[] Copy(DataBlob blob)
|
|
{
|
|
if (blob.size <= 0 || blob.data == IntPtr.Zero)
|
|
{
|
|
throw new InvalidDataException("platform credential store returned an empty value");
|
|
}
|
|
byte[] bytes = new byte[blob.size];
|
|
Marshal.Copy(blob.data, bytes, 0, bytes.Length);
|
|
return bytes;
|
|
}
|
|
|
|
private static void ZeroAndFreeHGlobal(ref DataBlob blob)
|
|
{
|
|
if (blob.data == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
ZeroUnmanaged(blob.data, blob.size);
|
|
Marshal.FreeHGlobal(blob.data);
|
|
blob = default;
|
|
}
|
|
|
|
private static void LocalFreeBlob(ref DataBlob blob, bool clear)
|
|
{
|
|
if (blob.data == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
if (clear)
|
|
{
|
|
ZeroUnmanaged(blob.data, blob.size);
|
|
}
|
|
LocalFree(blob.data);
|
|
blob = default;
|
|
}
|
|
|
|
private static void ZeroUnmanaged(IntPtr data, int length)
|
|
{
|
|
for (int i = 0; i < length; i++)
|
|
{
|
|
Marshal.WriteByte(data, i, 0);
|
|
}
|
|
}
|
|
|
|
private static string Hex(byte[] value)
|
|
{
|
|
StringBuilder builder = new StringBuilder(value.Length * 2);
|
|
foreach (byte item in value)
|
|
{
|
|
builder.Append(item.ToString("x2"));
|
|
}
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static void Clear(byte[] bytes)
|
|
{
|
|
if (bytes != null)
|
|
{
|
|
Array.Clear(bytes, 0, bytes.Length);
|
|
}
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct DataBlob
|
|
{
|
|
public int size;
|
|
public IntPtr data;
|
|
}
|
|
|
|
[DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool CryptProtectData(
|
|
ref DataBlob dataIn,
|
|
string description,
|
|
ref DataBlob optionalEntropy,
|
|
IntPtr reserved,
|
|
IntPtr prompt,
|
|
uint flags,
|
|
out DataBlob dataOut);
|
|
|
|
[DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool CryptUnprotectData(
|
|
ref DataBlob dataIn,
|
|
out IntPtr description,
|
|
ref DataBlob optionalEntropy,
|
|
IntPtr reserved,
|
|
IntPtr prompt,
|
|
uint flags,
|
|
out DataBlob dataOut);
|
|
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
private static extern IntPtr LocalFree(IntPtr memory);
|
|
}
|
|
|
|
internal sealed class MacOSKeychainRefreshCredentialStore : IRefreshCredentialStore
|
|
{
|
|
private const int Success = 0;
|
|
private const int ItemNotFound = -25300;
|
|
private const string Account = "refresh-v1";
|
|
|
|
public bool IsSupported => true;
|
|
|
|
public bool Contains(string origin)
|
|
{
|
|
int status = Find(origin, out uint length, out IntPtr data, out IntPtr item);
|
|
ReleaseFound(length, data, item);
|
|
if (status == Success)
|
|
{
|
|
return true;
|
|
}
|
|
if (status == ItemNotFound)
|
|
{
|
|
return false;
|
|
}
|
|
throw StatusException(status);
|
|
}
|
|
|
|
public RefreshCredential Load(string origin)
|
|
{
|
|
int status = Find(origin, out uint length, out IntPtr data, out IntPtr item);
|
|
if (status != Success)
|
|
{
|
|
ReleaseFound(length, data, item);
|
|
throw StatusException(status);
|
|
}
|
|
byte[] bytes = new byte[length];
|
|
try
|
|
{
|
|
Marshal.Copy(data, bytes, 0, bytes.Length);
|
|
return JsonUtility.FromJson<RefreshCredential>(Encoding.UTF8.GetString(bytes));
|
|
}
|
|
finally
|
|
{
|
|
Array.Clear(bytes, 0, bytes.Length);
|
|
ReleaseFound(length, data, item);
|
|
}
|
|
}
|
|
|
|
public void Save(string origin, RefreshCredential credential)
|
|
{
|
|
byte[] value = Encoding.UTF8.GetBytes(JsonUtility.ToJson(credential));
|
|
try
|
|
{
|
|
int status = Find(origin, out uint oldLength, out IntPtr oldData, out IntPtr item);
|
|
if (status == Success)
|
|
{
|
|
if (oldData != IntPtr.Zero)
|
|
{
|
|
SecKeychainItemFreeContent(IntPtr.Zero, oldData);
|
|
}
|
|
try
|
|
{
|
|
status = SecKeychainItemModifyAttributesAndData(item, IntPtr.Zero, (uint)value.Length, value);
|
|
}
|
|
finally
|
|
{
|
|
ReleaseItem(item);
|
|
}
|
|
}
|
|
else if (status == ItemNotFound)
|
|
{
|
|
byte[] service = Service(origin);
|
|
byte[] account = Encoding.UTF8.GetBytes(Account);
|
|
try
|
|
{
|
|
status = SecKeychainAddGenericPassword(IntPtr.Zero, (uint)service.Length, service, (uint)account.Length, account, (uint)value.Length, value, out item);
|
|
ReleaseItem(item);
|
|
}
|
|
finally
|
|
{
|
|
Array.Clear(service, 0, service.Length);
|
|
Array.Clear(account, 0, account.Length);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ReleaseFound(oldLength, oldData, item);
|
|
}
|
|
if (status != Success)
|
|
{
|
|
throw StatusException(status);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Array.Clear(value, 0, value.Length);
|
|
}
|
|
}
|
|
|
|
public void Delete(string origin)
|
|
{
|
|
int status = Find(origin, out uint length, out IntPtr data, out IntPtr item);
|
|
if (data != IntPtr.Zero)
|
|
{
|
|
SecKeychainItemFreeContent(IntPtr.Zero, data);
|
|
}
|
|
if (status == ItemNotFound)
|
|
{
|
|
ReleaseItem(item);
|
|
return;
|
|
}
|
|
if (status != Success)
|
|
{
|
|
ReleaseItem(item);
|
|
throw StatusException(status);
|
|
}
|
|
try
|
|
{
|
|
status = SecKeychainItemDelete(item);
|
|
}
|
|
finally
|
|
{
|
|
ReleaseItem(item);
|
|
}
|
|
if (status != Success)
|
|
{
|
|
throw StatusException(status);
|
|
}
|
|
}
|
|
|
|
private static int Find(string origin, out uint length, out IntPtr data, out IntPtr item)
|
|
{
|
|
byte[] service = Service(origin);
|
|
byte[] account = Encoding.UTF8.GetBytes(Account);
|
|
try
|
|
{
|
|
return SecKeychainFindGenericPassword(IntPtr.Zero, (uint)service.Length, service, (uint)account.Length, account, out length, out data, out item);
|
|
}
|
|
finally
|
|
{
|
|
Array.Clear(service, 0, service.Length);
|
|
Array.Clear(account, 0, account.Length);
|
|
}
|
|
}
|
|
|
|
private static byte[] Service(string origin)
|
|
{
|
|
return Encoding.UTF8.GetBytes("BD2 Login UI OAuth: " + origin);
|
|
}
|
|
|
|
private static void ReleaseFound(uint length, IntPtr data, IntPtr item)
|
|
{
|
|
if (data != IntPtr.Zero)
|
|
{
|
|
SecKeychainItemFreeContent(IntPtr.Zero, data);
|
|
}
|
|
ReleaseItem(item);
|
|
}
|
|
|
|
private static void ReleaseItem(IntPtr item)
|
|
{
|
|
if (item != IntPtr.Zero)
|
|
{
|
|
CFRelease(item);
|
|
}
|
|
}
|
|
|
|
private static Exception StatusException(int status)
|
|
{
|
|
return status == ItemNotFound
|
|
? new FileNotFoundException("refresh credential was not found in macOS Keychain")
|
|
: new InvalidOperationException("macOS Keychain operation failed with OSStatus " + status);
|
|
}
|
|
|
|
[DllImport("/System/Library/Frameworks/Security.framework/Security")]
|
|
private static extern int SecKeychainFindGenericPassword(
|
|
IntPtr keychainOrArray,
|
|
uint serviceNameLength,
|
|
byte[] serviceName,
|
|
uint accountNameLength,
|
|
byte[] accountName,
|
|
out uint passwordLength,
|
|
out IntPtr passwordData,
|
|
out IntPtr itemRef);
|
|
|
|
[DllImport("/System/Library/Frameworks/Security.framework/Security")]
|
|
private static extern int SecKeychainAddGenericPassword(
|
|
IntPtr keychain,
|
|
uint serviceNameLength,
|
|
byte[] serviceName,
|
|
uint accountNameLength,
|
|
byte[] accountName,
|
|
uint passwordLength,
|
|
byte[] passwordData,
|
|
out IntPtr itemRef);
|
|
|
|
[DllImport("/System/Library/Frameworks/Security.framework/Security")]
|
|
private static extern int SecKeychainItemModifyAttributesAndData(
|
|
IntPtr itemRef,
|
|
IntPtr attrList,
|
|
uint length,
|
|
byte[] data);
|
|
|
|
[DllImport("/System/Library/Frameworks/Security.framework/Security")]
|
|
private static extern int SecKeychainItemDelete(IntPtr itemRef);
|
|
|
|
[DllImport("/System/Library/Frameworks/Security.framework/Security")]
|
|
private static extern int SecKeychainItemFreeContent(IntPtr attrList, IntPtr data);
|
|
|
|
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
|
|
private static extern void CFRelease(IntPtr item);
|
|
}
|