- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes. - Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging. - Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR. - Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes. - Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots. - Introduce XiboFeatureManifests for hardcoded feature ACLs per role. - Add docker-compose.dev.yml for local development with PostgreSQL setup.
269 lines
11 KiB
C#
269 lines
11 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace OTSSignsOrchestrator.Desktop.Services;
|
|
|
|
/// <summary>
|
|
/// Stores and retrieves operator JWT and refresh tokens using the OS credential store.
|
|
/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain;
|
|
/// Linux: AES-encrypted file fallback in AppData.
|
|
/// </summary>
|
|
public sealed class TokenStoreService
|
|
{
|
|
private const string ServiceName = "OTSSignsOrchestrator";
|
|
private const string JwtAccount = "operator-jwt";
|
|
private const string RefreshAccount = "operator-refresh";
|
|
|
|
private readonly ILogger<TokenStoreService> _logger;
|
|
|
|
public TokenStoreService(ILogger<TokenStoreService> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public void StoreTokens(string jwt, string refreshToken)
|
|
{
|
|
WriteCredential(JwtAccount, jwt);
|
|
WriteCredential(RefreshAccount, refreshToken);
|
|
_logger.LogDebug("Tokens stored in OS credential store");
|
|
}
|
|
|
|
public string? GetJwt() => ReadCredential(JwtAccount);
|
|
|
|
public string? GetRefreshToken() => ReadCredential(RefreshAccount);
|
|
|
|
public void ClearTokens()
|
|
{
|
|
DeleteCredential(JwtAccount);
|
|
DeleteCredential(RefreshAccount);
|
|
_logger.LogDebug("Tokens cleared from OS credential store");
|
|
}
|
|
|
|
// ── Platform dispatch ────────────────────────────────────────────────────
|
|
|
|
private void WriteCredential(string account, string secret)
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
WindowsCredentialManager.Write(ServiceName, account, secret);
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
MacKeychain.Write(ServiceName, account, secret);
|
|
else
|
|
LinuxEncryptedFile.Write(ServiceName, account, secret);
|
|
}
|
|
|
|
private string? ReadCredential(string account)
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
return WindowsCredentialManager.Read(ServiceName, account);
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
return MacKeychain.Read(ServiceName, account);
|
|
return LinuxEncryptedFile.Read(ServiceName, account);
|
|
}
|
|
|
|
private void DeleteCredential(string account)
|
|
{
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
WindowsCredentialManager.Delete(ServiceName, account);
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
MacKeychain.Delete(ServiceName, account);
|
|
else
|
|
LinuxEncryptedFile.Delete(ServiceName, account);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Windows — advapi32.dll Credential Manager
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
private static class WindowsCredentialManager
|
|
{
|
|
private const int CredTypeGeneric = 1;
|
|
private const int CredPersistLocalMachine = 2;
|
|
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
private struct CREDENTIAL
|
|
{
|
|
public uint Flags;
|
|
public uint Type;
|
|
public string TargetName;
|
|
public string Comment;
|
|
public long LastWritten;
|
|
public uint CredentialBlobSize;
|
|
public IntPtr CredentialBlob;
|
|
public uint Persist;
|
|
public uint AttributeCount;
|
|
public IntPtr Attributes;
|
|
public string TargetAlias;
|
|
public string UserName;
|
|
}
|
|
|
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags);
|
|
|
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
|
|
|
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
private static extern bool CredDeleteW(string target, uint type, uint flags);
|
|
|
|
[DllImport("advapi32.dll")]
|
|
private static extern void CredFree(IntPtr buffer);
|
|
|
|
private static string TargetName(string service, string account) => $"{service}/{account}";
|
|
|
|
public static void Write(string service, string account, string secret)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(secret);
|
|
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
|
try
|
|
{
|
|
var cred = new CREDENTIAL
|
|
{
|
|
Type = CredTypeGeneric,
|
|
TargetName = TargetName(service, account),
|
|
UserName = account,
|
|
CredentialBlob = handle.AddrOfPinnedObject(),
|
|
CredentialBlobSize = (uint)bytes.Length,
|
|
Persist = CredPersistLocalMachine,
|
|
};
|
|
CredWriteW(ref cred, 0);
|
|
}
|
|
finally { handle.Free(); }
|
|
}
|
|
|
|
public static string? Read(string service, string account)
|
|
{
|
|
if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr))
|
|
return null;
|
|
try
|
|
{
|
|
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
|
|
if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null;
|
|
var bytes = new byte[cred.CredentialBlobSize];
|
|
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
|
|
return Encoding.UTF8.GetString(bytes);
|
|
}
|
|
finally { CredFree(credPtr); }
|
|
}
|
|
|
|
public static void Delete(string service, string account)
|
|
=> CredDeleteW(TargetName(service, account), CredTypeGeneric, 0);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// macOS — Security.framework Keychain via /usr/bin/security CLI
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
private static class MacKeychain
|
|
{
|
|
public static void Write(string service, string account, string secret)
|
|
{
|
|
// Delete first to avoid "duplicate" errors on update
|
|
Delete(service, account);
|
|
RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U");
|
|
}
|
|
|
|
public static string? Read(string service, string account)
|
|
{
|
|
var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w");
|
|
return exitCode == 0 ? stdout.Trim() : null;
|
|
}
|
|
|
|
public static void Delete(string service, string account)
|
|
=> RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\"");
|
|
|
|
private static (int exitCode, string stdout) RunSecurity(string args)
|
|
{
|
|
using var proc = new System.Diagnostics.Process();
|
|
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "/usr/bin/security",
|
|
Arguments = args,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
proc.Start();
|
|
var stdout = proc.StandardOutput.ReadToEnd();
|
|
proc.WaitForExit(5000);
|
|
return (proc.ExitCode, stdout);
|
|
}
|
|
|
|
private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// Linux — AES-256-GCM encrypted file in ~/.local/share
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
private static class LinuxEncryptedFile
|
|
{
|
|
// Machine-specific key derived from machine-id + user name
|
|
private static byte[] DeriveKey()
|
|
{
|
|
var machineId = "linux-default";
|
|
try
|
|
{
|
|
if (File.Exists("/etc/machine-id"))
|
|
machineId = File.ReadAllText("/etc/machine-id").Trim();
|
|
}
|
|
catch { /* fallback */ }
|
|
|
|
var material = $"{machineId}:{Environment.UserName}:{ServiceName}";
|
|
return SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
|
}
|
|
|
|
private static string FilePath(string service, string account)
|
|
{
|
|
var dir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
service, "credentials");
|
|
Directory.CreateDirectory(dir);
|
|
return Path.Combine(dir, $"{account}.enc");
|
|
}
|
|
|
|
public static void Write(string service, string account, string secret)
|
|
{
|
|
var key = DeriveKey();
|
|
var plaintext = Encoding.UTF8.GetBytes(secret);
|
|
var nonce = new byte[12];
|
|
RandomNumberGenerator.Fill(nonce);
|
|
var ciphertext = new byte[plaintext.Length];
|
|
var tag = new byte[16];
|
|
|
|
using var aes = new AesGcm(key, 16);
|
|
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
|
|
|
// File format: [12 nonce][16 tag][ciphertext]
|
|
var output = new byte[12 + 16 + ciphertext.Length];
|
|
nonce.CopyTo(output, 0);
|
|
tag.CopyTo(output, 12);
|
|
ciphertext.CopyTo(output, 28);
|
|
File.WriteAllBytes(FilePath(service, account), output);
|
|
}
|
|
|
|
public static string? Read(string service, string account)
|
|
{
|
|
var path = FilePath(service, account);
|
|
if (!File.Exists(path)) return null;
|
|
|
|
var data = File.ReadAllBytes(path);
|
|
if (data.Length < 28) return null;
|
|
|
|
var nonce = data[..12];
|
|
var tag = data[12..28];
|
|
var ciphertext = data[28..];
|
|
var plaintext = new byte[ciphertext.Length];
|
|
|
|
using var aes = new AesGcm(DeriveKey(), 16);
|
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
|
return Encoding.UTF8.GetString(plaintext);
|
|
}
|
|
|
|
public static void Delete(string service, string account)
|
|
{
|
|
var path = FilePath(service, account);
|
|
if (File.Exists(path)) File.Delete(path);
|
|
}
|
|
}
|
|
}
|