Complete cross-platform solution supporting Windows and macOS for PDF digital signatures.
DigitalSignerApp/
├── DigitalSignerApp.sln
├── DigitalSignerApp/
│ ├── DigitalSignerApp.csproj
│ ├── Program.cs
│ ├── App.axaml
│ ├── App.axaml.cs
│ ├── ViewModels/
│ │ ├── ViewModelBase.cs
│ │ ├── MainWindowViewModel.cs
│ │ └── CertificateSelectionViewModel.cs
│ ├── Views/
│ │ ├── MainWindow.axaml
│ │ ├── MainWindow.axaml.cs
│ │ ├── CertificateSelectionDialog.axaml
│ │ └── CertificateSelectionDialog.axaml.cs
│ ├── Services/
│ │ ├── Interfaces/
│ │ │ ├── ICertificateService.cs
│ │ │ ├── ISignerService.cs
│ │ │ └── IWebSocketServer.cs
│ │ ├── WebSocketServer.cs
│ │ ├── CertificateService.cs
│ │ ├── WindowsCertificateService.cs
│ │ ├── MacOSCertificateService.cs
│ │ ├── PAdESSignerService.cs
│ │ └── CAdESSignerService.cs
│ ├── Models/
│ │ ├── SignRequest.cs
│ │ ├── SignResponse.cs
│ │ └── AppSettings.cs
│ └── Utils/
│ ├── CertificateUtils.cs
│ ├── PlatformHelper.cs
│ └── SingleInstanceManager.cs
├── DigitalSignerApp.Desktop/
│ ├── DigitalSignerApp.Desktop.csproj
│ └── Program.cs
└── assets/
├── app.ico
└── app.icns
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalSignerApp", "DigitalSignerApp\DigitalSignerApp.csproj", "{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalSignerApp.Desktop", "DigitalSignerApp.Desktop\DigitalSignerApp.Desktop.csproj", "{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}.Debug|Any CPU.Build.0 = Debug|Any CPU
{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}.Release|Any CPU.ActiveCfg = Release|Any CPU
{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}.Release|Any CPU.Build.0 = Release|Any CPU
{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}.Debug|Any CPU.Build.0 = Debug|Any CPU
{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}.Release|Any CPU.ActiveCfg = Release|Any CPU
{YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia UI -->
<PackageReference Include="Avalonia" Version="11.0.6" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.6" />
<!-- iText7 for PAdES signing -->
<PackageReference Include="itext7" Version="8.0.2" />
<PackageReference Include="itext7.bouncy-castle-adapter" Version="8.0.2" />
<!-- BouncyCastle for CAdES -->
<PackageReference Include="BouncyCastle.Cryptography" Version="2.2.1" />
<!-- WebSocket Server -->
<PackageReference Include="Fleck" Version="1.2.0" />
<!-- JSON Serialization (using System.Text.Json instead of Newtonsoft) -->
<PackageReference Include="System.Text.Json" Version="8.0.0" />
<!-- Logging -->
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<!-- Dependency Injection -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<!-- CommunityToolkit for MVVM -->
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
</ItemGroup>
<ItemGroup>
<!-- Compile XAML files -->
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
</Project><Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>..\assets\app.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- macOS specific -->
<CFBundleName>DigitalSignerApp</CFBundleName>
<CFBundleDisplayName>Digital Signer App</CFBundleDisplayName>
<CFBundleIdentifier>com.yourcompany.digitalsigner</CFBundleIdentifier>
<CFBundleVersion>1.0.0</CFBundleVersion>
<CFBundleShortVersionString>1.0.0</CFBundleShortVersionString>
<CFBundleIconFile>app.icns</CFBundleIconFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalSignerApp\DigitalSignerApp.csproj" />
</ItemGroup>
<!-- Windows manifest for single instance -->
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<None Update="app.manifest">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>using System;
using Avalonia;
using DigitalSignerApp;
using DigitalSignerApp.Utils;
namespace DigitalSignerApp.Desktop;
class Program
{
[STAThread]
public static void Main(string[] args)
{
// Check for single instance
using var singleInstance = new SingleInstanceManager("DigitalSignerApp");
if (!singleInstance.TryAcquire())
{
Console.WriteLine("Application is already running.");
singleInstance.NotifyExistingInstance();
return;
}
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="DigitalSignerApp"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
</application>
</compatibility>
</assembly>using System.Text.Json.Serialization;
namespace DigitalSignerApp.Models;
public class SignRequest
{
[JsonPropertyName("requestId")]
public string RequestId { get; set; } = Guid.NewGuid().ToString();
[JsonPropertyName("action")]
public string Action { get; set; } = string.Empty;
[JsonPropertyName("pdfBase64")]
public string? PdfBase64 { get; set; }
[JsonPropertyName("dataBase64")]
public string? DataBase64 { get; set; }
[JsonPropertyName("signatureType")]
public string SignatureType { get; set; } = "pades";
[JsonPropertyName("reason")]
public string? Reason { get; set; }
[JsonPropertyName("location")]
public string? Location { get; set; }
[JsonPropertyName("certificateThumbprint")]
public string? CertificateThumbprint { get; set; }
[JsonPropertyName("visibleSignature")]
public bool VisibleSignature { get; set; } = false;
[JsonPropertyName("signaturePosition")]
public SignaturePosition? SignaturePosition { get; set; }
[JsonPropertyName("tsaUrl")]
public string? TsaUrl { get; set; }
[JsonPropertyName("includeTimestamp")]
public bool IncludeTimestamp { get; set; } = false;
}
public class SignaturePosition
{
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
[JsonPropertyName("x")]
public float X { get; set; }
[JsonPropertyName("y")]
public float Y { get; set; }
[JsonPropertyName("width")]
public float Width { get; set; } = 200;
[JsonPropertyName("height")]
public float Height { get; set; } = 60;
}using System.Text.Json.Serialization;
namespace DigitalSignerApp.Models;
public class SignResponse
{
[JsonPropertyName("requestId")]
public string RequestId { get; set; } = string.Empty;
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("errorCode")]
public string? ErrorCode { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
[JsonPropertyName("signedPdfBase64")]
public string? SignedPdfBase64 { get; set; }
[JsonPropertyName("signatureBase64")]
public string? SignatureBase64 { get; set; }
[JsonPropertyName("certificateInfo")]
public CertificateInfo? CertificateInfo { get; set; }
[JsonPropertyName("certificates")]
public List<CertificateInfo>? Certificates { get; set; }
[JsonPropertyName("signatureType")]
public string? SignatureType { get; set; }
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[JsonPropertyName("version")]
public string Version { get; set; } = "1.0.0";
public static SignResponse Error(string requestId, string message, string? errorCode = null)
{
return new SignResponse
{
RequestId = requestId,
Success = false,
Message = message,
ErrorCode = errorCode
};
}
public static SignResponse Ok(string requestId, string? message = null)
{
return new SignResponse
{
RequestId = requestId,
Success = true,
Message = message
};
}
}
public class CertificateInfo
{
[JsonPropertyName("thumbprint")]
public string Thumbprint { get; set; } = string.Empty;
[JsonPropertyName("subject")]
public string Subject { get; set; } = string.Empty;
[JsonPropertyName("issuer")]
public string Issuer { get; set; } = string.Empty;
[JsonPropertyName("notBefore")]
public DateTime NotBefore { get; set; }
[JsonPropertyName("notAfter")]
public DateTime NotAfter { get; set; }
[JsonPropertyName("serialNumber")]
public string SerialNumber { get; set; } = string.Empty;
[JsonPropertyName("isValid")]
public bool IsValid { get; set; }
[JsonPropertyName("commonName")]
public string CommonName { get; set; } = string.Empty;
[JsonPropertyName("keyType")]
public string KeyType { get; set; } = string.Empty;
[JsonPropertyName("keySize")]
public int KeySize { get; set; }
[JsonPropertyName("isHardwareToken")]
public bool IsHardwareToken { get; set; }
}namespace DigitalSignerApp.Models;
public class AppSettings
{
public int WebSocketPort { get; set; } = 9876;
public bool AutoStart { get; set; } = true;
public bool MinimizeToTray { get; set; } = true;
public bool StartMinimized { get; set; } = false;
public string? DefaultTsaUrl { get; set; }
public bool EnableSecureWebSocket { get; set; } = false;
public string? SslCertificatePath { get; set; }
public string? SslCertificatePassword { get; set; }
public LogLevel LogLevel { get; set; } = LogLevel.Information;
public int MaxLogFileSizeMB { get; set; } = 10;
public int MaxLogRetentionDays { get; set; } = 30;
}
public enum LogLevel
{
Verbose,
Debug,
Information,
Warning,
Error,
Fatal
}using System.Runtime.InteropServices;
namespace DigitalSignerApp.Utils;
public static class PlatformHelper
{
public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public static bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
public static string GetAppDataPath()
{
string basePath;
if (IsWindows)
{
basePath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
else if (IsMacOS)
{
basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Application Support");
}
else
{
basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".config");
}
var appPath = Path.Combine(basePath, "DigitalSignerApp");
Directory.CreateDirectory(appPath);
return appPath;
}
public static string GetLogPath()
{
var logPath = Path.Combine(GetAppDataPath(), "logs");
Directory.CreateDirectory(logPath);
return logPath;
}
public static string GetSettingsPath()
{
return Path.Combine(GetAppDataPath(), "settings.json");
}
}using System.IO.Pipes;
using System.Runtime.InteropServices;
namespace DigitalSignerApp.Utils;
public class SingleInstanceManager : IDisposable
{
private readonly string _appName;
private Mutex? _mutex;
private CancellationTokenSource? _pipeServerCts;
public event EventHandler? OnSecondInstanceStarted;
public SingleInstanceManager(string appName)
{
_appName = appName;
}
public bool TryAcquire()
{
var mutexName = GetMutexName();
_mutex = new Mutex(true, mutexName, out bool createdNew);
if (createdNew)
{
StartPipeServer();
return true;
}
return false;
}
public void NotifyExistingInstance()
{
try
{
using var client = new NamedPipeClientStream(".", GetPipeName(), PipeDirection.Out);
client.Connect(1000);
using var writer = new StreamWriter(client);
writer.WriteLine("ACTIVATE");
writer.Flush();
}
catch
{
// Ignore errors when notifying
}
}
private void StartPipeServer()
{
_pipeServerCts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
while (!_pipeServerCts.Token.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
GetPipeName(),
PipeDirection.In,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_pipeServerCts.Token);
using var reader = new StreamReader(server);
var message = await reader.ReadLineAsync();
if (message == "ACTIVATE")
{
OnSecondInstanceStarted?.Invoke(this, EventArgs.Empty);
}
}
catch (OperationCanceledException)
{
break;
}
catch
{
await Task.Delay(100);
}
}
});
}
private string GetMutexName()
{
return PlatformHelper.IsWindows
? $"Global\\{_appName}_Mutex"
: $"{_appName}_Mutex";
}
private string GetPipeName()
{
return $"{_appName}_Pipe";
}
public void Dispose()
{
_pipeServerCts?.Cancel();
_pipeServerCts?.Dispose();
_mutex?.ReleaseMutex();
_mutex?.Dispose();
}
}using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using DigitalSignerApp.Models;
namespace DigitalSignerApp.Utils;
public static partial class CertificateUtils
{
[GeneratedRegex(@"CN=([^,]+)", RegexOptions.Compiled)]
private static partial Regex CommonNameRegex();
public static string ExtractCommonName(string distinguishedName)
{
var match = CommonNameRegex().Match(distinguishedName);
return match.Success ? match.Groups[1].Value.Trim() : distinguishedName;
}
public static CertificateInfo ToCertificateInfo(X509Certificate2 cert)
{
var (keyType, keySize) = GetKeyInfo(cert);
return new CertificateInfo
{
Thumbprint = cert.Thumbprint,
Subject = cert.Subject,
Issuer = cert.Issuer,
NotBefore = cert.NotBefore,
NotAfter = cert.NotAfter,
SerialNumber = cert.SerialNumber,
IsValid = IsValidNow(cert),
CommonName = ExtractCommonName(cert.Subject),
KeyType = keyType,
KeySize = keySize,
IsHardwareToken = IsHardwareToken(cert)
};
}
public static bool IsValidNow(X509Certificate2 cert)
{
var now = DateTime.Now;
return cert.NotBefore <= now && now <= cert.NotAfter;
}
public static bool IsHardwareToken(X509Certificate2 cert)
{
if (!cert.HasPrivateKey)
return false;
try
{
// Try to detect if this is a hardware-backed key
var rsa = cert.GetRSAPrivateKey();
if (rsa != null)
{
// Hardware tokens typically don't allow key export
try
{
_ = rsa.ExportParameters(true);
return false; // If we can export, it's not hardware-backed
}
catch
{
return true; // Can't export = likely hardware
}
}
var ecdsa = cert.GetECDsaPrivateKey();
if (ecdsa != null)
{
try
{
_ = ecdsa.ExportParameters(true);
return false;
}
catch
{
return true;
}
}
}
catch
{
return true;
}
return false;
}
public static (string keyType, int keySize) GetKeyInfo(X509Certificate2 cert)
{
try
{
var rsa = cert.GetRSAPublicKey();
if (rsa != null)
{
return ("RSA", rsa.KeySize);
}
var ecdsa = cert.GetECDsaPublicKey();
if (ecdsa != null)
{
return ("ECDSA", ecdsa.KeySize);
}
}
catch
{
// Ignore
}
return ("Unknown", 0);
}
public static bool HasDigitalSignatureUsage(X509Certificate2 cert)
{
foreach (var extension in cert.Extensions)
{
if (extension is X509KeyUsageExtension keyUsage)
{
return (keyUsage.KeyUsages & X509KeyUsageFlags.DigitalSignature) != 0 ||
(keyUsage.KeyUsages & X509KeyUsageFlags.NonRepudiation) != 0;
}
}
// If no key usage extension, assume it can be used for signing
return true;
}
}using System.Security.Cryptography.X509Certificates;
using DigitalSignerApp.Models;
namespace DigitalSignerApp.Services.Interfaces;
public interface ICertificateService
{
Task<List<X509Certificate2>> GetSigningCertificatesAsync();
Task<X509Certificate2?> GetCertificateByThumbprintAsync(string thumbprint);
List<CertificateInfo> GetCertificateInfoList();
X509Chain BuildCertificateChain(X509Certificate2 certificate);
bool ValidateCertificate(X509Certificate2 certificate, out List<string> errors);
}using System.Security.Cryptography.X509Certificates;
using DigitalSignerApp.Models;
namespace DigitalSignerApp.Services.Interfaces;
public interface IPAdESSignerService
{
Task<SignResponse> SignPdfAsync(byte[] pdfBytes, X509Certificate2 certificate, SignRequest request);
Task<SignResponse> SignPdfWithTimestampAsync(byte[] pdfBytes, X509Certificate2 certificate, SignRequest request, string tsaUrl);
}
public interface ICAdESSignerService
{
Task<SignResponse> CreateDetachedSignatureAsync(byte[] data, X509Certificate2 certificate, SignRequest request);
SignResponse VerifyDetachedSignature(byte[] data, byte[] signature);
}namespace DigitalSignerApp.Services.Interfaces;
public interface IWebSocketServer : IDisposable
{
event EventHandler<string>? OnLogMessage;
event EventHandler<int>? OnConnectionCountChanged;
bool IsRunning { get; }
int ConnectionCount { get; }
void Start();
void Stop();
}using System.Security.Cryptography.X509Certificates;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services.Interfaces;
using DigitalSignerApp.Utils;
using Microsoft.Extensions.Logging;
namespace DigitalSignerApp.Services;
public abstract class CertificateServiceBase : ICertificateService
{
protected readonly ILogger _logger;
protected CertificateServiceBase(ILogger logger)
{
_logger = logger;
}
public abstract Task<List<X509Certificate2>> GetSigningCertificatesAsync();
public virtual async Task<X509Certificate2?> GetCertificateByThumbprintAsync(string thumbprint)
{
var certs = await GetSigningCertificatesAsync();
return certs.FirstOrDefault(c =>
c.Thumbprint.Equals(thumbprint, StringComparison.OrdinalIgnoreCase));
}
public List<CertificateInfo> GetCertificateInfoList()
{
var certs = GetSigningCertificatesAsync().GetAwaiter().GetResult();
return certs
.Select(CertificateUtils.ToCertificateInfo)
.OrderBy(c => c.CommonName)
.ToList();
}
public X509Chain BuildCertificateChain(X509Certificate2 certificate)
{
var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(30);
try
{
chain.Build(certificate);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build certificate chain");
}
return chain;
}
public bool ValidateCertificate(X509Certificate2 certificate, out List<string> errors)
{
errors = new List<string>();
if (!certificate.HasPrivateKey)
{
errors.Add("Certificate does not have a private key");
}
if (!CertificateUtils.IsValidNow(certificate))
{
errors.Add($"Certificate is not valid. Valid from {certificate.NotBefore} to {certificate.NotAfter}");
}
if (!CertificateUtils.HasDigitalSignatureUsage(certificate))
{
errors.Add("Certificate does not have digital signature key usage");
}
return errors.Count == 0;
}
protected bool IsValidSigningCertificate(X509Certificate2 cert)
{
if (!cert.HasPrivateKey)
return false;
if (!CertificateUtils.IsValidNow(cert))
return false;
return CertificateUtils.HasDigitalSignatureUsage(cert);
}
}using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
namespace DigitalSignerApp.Services;
public class WindowsCertificateService : CertificateServiceBase
{
public WindowsCertificateService(ILogger<WindowsCertificateService> logger)
: base(logger)
{
}
public override Task<List<X509Certificate2>> GetSigningCertificatesAsync()
{
return Task.Run(() =>
{
var certificates = new List<X509Certificate2>();
// Search in CurrentUser store
try
{
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var cert in store.Certificates)
{
if (IsValidSigningCertificate(cert))
{
certificates.Add(cert);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading CurrentUser certificate store");
}
// Search in LocalMachine store
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var cert in store.Certificates)
{
if (IsValidSigningCertificate(cert) &&
!certificates.Any(c => c.Thumbprint == cert.Thumbprint))
{
certificates.Add(cert);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not access LocalMachine certificate store");
}
_logger.LogInformation("Found {Count} signing certificates", certificates.Count);
return certificates;
});
}
public override async Task<X509Certificate2?> GetCertificateByThumbprintAsync(string thumbprint)
{
// First try the base implementation
var cert = await base.GetCertificateByThumbprintAsync(thumbprint);
if (cert != null)
return cert;
// Try direct store lookup (more efficient)
try
{
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint,
validOnly: false);
if (certs.Count > 0)
return certs[0];
}
catch (Exception ex)
{
_logger.LogError(ex, "Error finding certificate by thumbprint");
}
return null;
}
}using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace DigitalSignerApp.Services;
public partial class MacOSCertificateService : CertificateServiceBase
{
public MacOSCertificateService(ILogger<MacOSCertificateService> logger)
: base(logger)
{
}
public override async Task<List<X509Certificate2>> GetSigningCertificatesAsync()
{
var certificates = new List<X509Certificate2>();
// Method 1: Use X509Store (works for login keychain)
try
{
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
foreach (var cert in store.Certificates)
{
if (IsValidSigningCertificate(cert))
{
certificates.Add(cert);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading keychain via X509Store");
}
// Method 2: Use security command for more complete access
try
{
var keychainCerts = await GetCertificatesFromKeychainAsync();
foreach (var cert in keychainCerts)
{
if (IsValidSigningCertificate(cert) &&
!certificates.Any(c => c.Thumbprint == cert.Thumbprint))
{
certificates.Add(cert);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not access keychain via security command");
}
_logger.LogInformation("Found {Count} signing certificates", certificates.Count);
return certificates;
}
private async Task<List<X509Certificate2>> GetCertificatesFromKeychainAsync()
{
var certificates = new List<X509Certificate2>();
try
{
// List all identities (certificates with private keys)
var startInfo = new ProcessStartInfo
{
FileName = "/usr/bin/security",
Arguments = "find-identity -v -p codesigning",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
return certificates;
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
// Parse output for certificate hashes
var matches = IdentityRegex().Matches(output);
foreach (Match match in matches)
{
var hash = match.Groups[1].Value;
var cert = await ExportCertificateFromKeychainAsync(hash);
if (cert != null)
{
certificates.Add(cert);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing keychain identities");
}
return certificates;
}
private async Task<X509Certificate2?> ExportCertificateFromKeychainAsync(string hash)
{
try
{
var tempFile = Path.GetTempFileName();
try
{
var startInfo = new ProcessStartInfo
{
FileName = "/usr/bin/security",
Arguments = $"find-certificate -c \"{hash}\" -p",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
return null;
var pemData = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (string.IsNullOrEmpty(pemData))
return null;
// Parse PEM certificate
var certData = Convert.FromBase64String(
pemData
.Replace("-----BEGIN CERTIFICATE-----", "")
.Replace("-----END CERTIFICATE-----", "")
.Replace("\n", "")
.Replace("\r", "")
.Trim());
return new X509Certificate2(certData);
}
finally
{
if (File.Exists(tempFile))
File.Delete(tempFile);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not export certificate {Hash}", hash);
return null;
}
}
[GeneratedRegex(@"\d+\)\s+([A-F0-9]{40})\s+\"([^\"]+)\"", RegexOptions.Compiled)]
private static partial Regex IdentityRegex();
}using System.Security.Cryptography.X509Certificates;
using iText.Kernel.Pdf;
using iText.Signatures;
using iText.Bouncycastle.Crypto;
using iText.Bouncycastle.X509;
using iText.Commons.Bouncycastle.Cert;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services.Interfaces;
using DigitalSignerApp.Utils;
using iText.Kernel.Geom;
using Microsoft.Extensions.Logging;
namespace DigitalSignerApp.Services;
public class PAdESSignerService : IPAdESSignerService
{
private readonly ICertificateService _certificateService;
private readonly ILogger<PAdESSignerService> _logger;
public PAdESSignerService(
ICertificateService certificateService,
ILogger<PAdESSignerService> logger)
{
_certificateService = certificateService;
_logger = logger;
}
public async Task<SignResponse> SignPdfAsync(
byte[] pdfBytes,
X509Certificate2 certificate,
SignRequest request)
{
return await Task.Run(() =>
{
try
{
_logger.LogInformation("Starting PAdES signing for request {RequestId}", request.RequestId);
using var inputStream = new MemoryStream(pdfBytes);
using var outputStream = new MemoryStream();
var chain = GetCertificateChain(certificate);
var pk = GetPrivateKey(certificate);
var signer = new PrivateKeySignature(pk, DigestAlgorithms.SHA256);
using var pdfReader = new PdfReader(inputStream);
var properties = new StampingProperties();
var pdfSigner = new PdfSigner(pdfReader, outputStream, properties);
ConfigureSignatureAppearance(pdfSigner, request);
pdfSigner.SetFieldName($"Sig_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}".Substring(0, 32));
pdfSigner.SignDetached(
signer,
chain,
null,
null,
null,
0,
PdfSigner.CryptoStandard.CADES);
_logger.LogInformation("PAdES signing completed for request {RequestId}", request.RequestId);
return new SignResponse
{
RequestId = request.RequestId,
Success = true,
SignedPdfBase64 = Convert.ToBase64String(outputStream.ToArray()),
SignatureType = "PAdES-B",
CertificateInfo = CertificateUtils.ToCertificateInfo(certificate),
Message = "PDF signed successfully with PAdES"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PAdES signing failed for request {RequestId}", request.RequestId);
return SignResponse.Error(request.RequestId, $"Signing failed: {ex.Message}", "SIGNING_ERROR");
}
});
}
public async Task<SignResponse> SignPdfWithTimestampAsync(
byte[] pdfBytes,
X509Certificate2 certificate,
SignRequest request,
string tsaUrl)
{
return await Task.Run(() =>
{
try
{
_logger.LogInformation("Starting PAdES-T signing for request {RequestId}", request.RequestId);
using var inputStream = new MemoryStream(pdfBytes);
using var outputStream = new MemoryStream();
var chain = GetCertificateChain(certificate);
var pk = GetPrivateKey(certificate);
var signer = new PrivateKeySignature(pk, DigestAlgorithms.SHA256);
using var pdfReader = new PdfReader(inputStream);
var pdfSigner = new PdfSigner(pdfReader, outputStream, new StampingProperties());
ConfigureSignatureAppearance(pdfSigner, request);
pdfSigner.SetFieldName($"Sig_{DateTime.Now:yyyyMMddHHmmss}_{Guid.NewGuid():N}".Substring(0, 32));
ITSAClient tsaClient = new TSAClientBouncyCastle(tsaUrl);
pdfSigner.SignDetached(
signer,
chain,
null,
null,
tsaClient,
0,
PdfSigner.CryptoStandard.CADES);
_logger.LogInformation("PAdES-T signing completed for request {RequestId}", request.RequestId);
return new SignResponse
{
RequestId = request.RequestId,
Success = true,
SignedPdfBase64 = Convert.ToBase64String(outputStream.ToArray()),
SignatureType = "PAdES-T",
CertificateInfo = CertificateUtils.ToCertificateInfo(certificate),
Message = "PDF signed successfully with PAdES-T (timestamp)"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "PAdES-T signing failed for request {RequestId}", request.RequestId);
return SignResponse.Error(request.RequestId, $"Signing with timestamp failed: {ex.Message}", "SIGNING_ERROR");
}
});
}
private void ConfigureSignatureAppearance(PdfSigner pdfSigner, SignRequest request)
{
var appearance = pdfSigner.GetSignatureAppearance();
if (!string.IsNullOrEmpty(request.Reason))
appearance.SetReason(request.Reason);
if (!string.IsNullOrEmpty(request.Location))
appearance.SetLocation(request.Location);
appearance.SetSignatureCreator("DigitalSignerApp");
if (request.VisibleSignature && request.SignaturePosition != null)
{
var pos = request.SignaturePosition;
var rect = new Rectangle(pos.X, pos.Y, pos.Width, pos.Height);
appearance.SetPageRect(rect);
appearance.SetPageNumber(pos.Page);
appearance.SetRenderingMode(PdfSignatureAppearance.RenderingMode.DESCRIPTION);
}
}
private IX509Certificate[] GetCertificateChain(X509Certificate2 certificate)
{
var chain = _certificateService.BuildCertificateChain(certificate);
var bouncyCastleCerts = new List<IX509Certificate>();
foreach (var element in chain.ChainElements)
{
try
{
var bcCert = new X509CertificateParser()
.ReadCertificate(element.Certificate.RawData);
bouncyCastleCerts.Add(new X509CertificateBC(bcCert));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse chain element certificate");
}
}
if (bouncyCastleCerts.Count == 0)
{
var bcCert = new X509CertificateParser()
.ReadCertificate(certificate.RawData);
bouncyCastleCerts.Add(new X509CertificateBC(bcCert));
}
return bouncyCastleCerts.ToArray();
}
private IPrivateKey GetPrivateKey(X509Certificate2 certificate)
{
var rsa = certificate.GetRSAPrivateKey();
if (rsa != null)
{
try
{
var rsaParams = rsa.ExportParameters(true);
var bcRsaParams = new Org.BouncyCastle.Crypto.Parameters.RsaPrivateCrtKeyParameters(
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.Modulus),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.Exponent),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.D),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.P),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.Q),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.DP),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.DQ),
new Org.BouncyCastle.Math.BigInteger(1, rsaParams.InverseQ));
return new PrivateKeyBC(bcRsaParams);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not export RSA key, attempting CSP access");
// For hardware tokens, we need a different approach
throw new InvalidOperationException(
"Hardware token detected. Please ensure the token is properly connected and PIN is entered.");
}
}
var ecdsa = certificate.GetECDsaPrivateKey();
if (ecdsa != null)
{
try
{
var ecParams = ecdsa.ExportParameters(true);
var domainParams = ECNamedCurveTable.GetByName(GetCurveName(ecParams));
if (domainParams != null)
{
var ecPoint = domainParams.Curve.CreatePoint(
new Org.BouncyCastle.Math.BigInteger(1, ecParams.Q.X),
new Org.BouncyCastle.Math.BigInteger(1, ecParams.Q.Y));
var bcEcParams = new Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters(
new Org.BouncyCastle.Math.BigInteger(1, ecParams.D),
new Org.BouncyCastle.Crypto.Parameters.ECDomainParameters(
domainParams.Curve, domainParams.G, domainParams.N, domainParams.H));
return new PrivateKeyBC(bcEcParams);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not export ECDSA key");
}
}
throw new InvalidOperationException("Could not extract private key from certificate");
}
private string GetCurveName(System.Security.Cryptography.ECParameters ecParams)
{
return ecParams.Curve.Oid?.FriendlyName switch
{
"nistP256" or "ECDSA_P256" => "P-256",
"nistP384" or "ECDSA_P384" => "P-384",
"nistP521" or "ECDSA_P521" => "P-521",
_ => "P-256"
};
}
}using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services.Interfaces;
using DigitalSignerApp.Utils;
using Microsoft.Extensions.Logging;
using Org.BouncyCastle.Cms;
using Org.BouncyCastle.X509.Store;
using BcX509 = Org.BouncyCastle.X509;
namespace DigitalSignerApp.Services;
public class CAdESSignerService : ICAdESSignerService
{
private readonly ICertificateService _certificateService;
private readonly ILogger<CAdESSignerService> _logger;
public CAdESSignerService(
ICertificateService certificateService,
ILogger<CAdESSignerService> logger)
{
_certificateService = certificateService;
_logger = logger;
}
public async Task<SignResponse> CreateDetachedSignatureAsync(
byte[] data,
X509Certificate2 certificate,
SignRequest request)
{
return await Task.Run(() => CreateDetachedSignature(data, certificate, request));
}
private SignResponse CreateDetachedSignature(
byte[] data,
X509Certificate2 certificate,
SignRequest request)
{
try
{
_logger.LogInformation("Creating CAdES detached signature for request {RequestId}", request.RequestId);
var contentInfo = new ContentInfo(data);
var signedCms = new SignedCms(contentInfo, detached: true);
var signer = new CmsSigner(SubjectIdentifierType.IssuerAndSerialNumber, certificate)
{
DigestAlgorithm = new Oid("2.16.840.1.101.3.4.2.1", "SHA256"),
IncludeOption = X509IncludeOption.WholeChain
};
// Add signing time attribute
signer.SignedAttributes.Add(new Pkcs9SigningTime(DateTime.UtcNow));
// Add content type attribute
signer.SignedAttributes.Add(
new AsnEncodedData(new Oid("1.2.840.113549.1.9.3"),
new byte[] { 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x01 }));
// Compute signature - triggers PIN prompt for hardware tokens
signedCms.ComputeSignature(signer, silent: false);
byte[] signature = signedCms.Encode();
_logger.LogInformation("CAdES signature created for request {RequestId}", request.RequestId);
return new SignResponse
{
RequestId = request.RequestId,
Success = true,
SignatureBase64 = Convert.ToBase64String(signature),
SignatureType = "CAdES-BES",
CertificateInfo = CertificateUtils.ToCertificateInfo(certificate),
Message = "CAdES detached signature created successfully"
};
}
catch (CryptographicException ex) when (ex.Message.Contains("cancelled") ||
ex.Message.Contains("canceled"))
{
_logger.LogWarning("User cancelled PIN entry for request {RequestId}", request.RequestId);
return SignResponse.Error(request.RequestId, "Signing cancelled by user", "USER_CANCELLED");
}
catch (Exception ex)
{
_logger.LogError(ex, "CAdES signing failed for request {RequestId}", request.RequestId);
return SignResponse.Error(request.RequestId, $"Signing failed: {ex.Message}", "SIGNING_ERROR");
}
}
public SignResponse VerifyDetachedSignature(byte[] data, byte[] signature)
{
try
{
_logger.LogInformation("Verifying CAdES detached signature");
var contentInfo = new ContentInfo(data);
var signedCms = new SignedCms(contentInfo, detached: true);
signedCms.Decode(signature);
signedCms.CheckSignature(verifySignatureOnly: false);
var signerInfo = signedCms.SignerInfos[0];
var signerCert = signerInfo.Certificate;
// Extract signing time
DateTime? signingTime = null;
foreach (var attr in signerInfo.SignedAttributes)
{
if (attr.Oid.Value == "1.2.840.113549.1.9.5") // signingTime OID
{
var pkcs9Time = new Pkcs9SigningTime(attr.Values[0].RawData);
signingTime = pkcs9Time.SigningTime;
break;
}
}
_logger.LogInformation("Signature verification successful");
return new SignResponse
{
Success = true,
Message = signingTime.HasValue
? $"Signature is valid. Signed at: {signingTime.Value:u}"
: "Signature is valid",
CertificateInfo = signerCert != null
? CertificateUtils.ToCertificateInfo(signerCert)
: null
};
}
catch (CryptographicException ex)
{
_logger.LogWarning(ex, "Signature verification failed");
return SignResponse.Error("", $"Signature verification failed: {ex.Message}", "VERIFICATION_FAILED");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during signature verification");
return SignResponse.Error("", $"Error: {ex.Message}", "VERIFICATION_ERROR");
}
}
}using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services.Interfaces;
using Fleck;
using Microsoft.Extensions.Logging;
namespace DigitalSignerApp.Services;
public class SigningWebSocketServer : IWebSocketServer
{
private WebSocketServer? _server;
private readonly ConcurrentDictionary<Guid, IWebSocketConnection> _connections = new();
private readonly ICertificateService _certificateService;
private readonly IPAdESSignerService _padesSignerService;
private readonly ICAdESSignerService _cadesSignerService;
private readonly ILogger<SigningWebSocketServer> _logger;
private readonly AppSettings _settings;
private readonly JsonSerializerOptions _jsonOptions;
public event EventHandler<string>? OnLogMessage;
public event EventHandler<int>? OnConnectionCountChanged;
public event Func<List<X509Certificate2>, Task<X509Certificate2?>>? OnCertificateSelectionRequired;
public bool IsRunning { get; private set; }
public int ConnectionCount => _connections.Count;
public SigningWebSocketServer(
ICertificateService certificateService,
IPAdESSignerService padesSignerService,
ICAdESSignerService cadesSignerService,
AppSettings settings,
ILogger<SigningWebSocketServer> logger)
{
_certificateService = certificateService;
_padesSignerService = padesSignerService;
_cadesSignerService = cadesSignerService;
_settings = settings;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
public void Start()
{
if (IsRunning)
{
Log("Server is already running");
return;
}
try
{
var protocol = _settings.EnableSecureWebSocket ? "wss" : "ws";
var url = $"{protocol}://127.0.0.1:{_settings.WebSocketPort}";
_server = new WebSocketServer(url);
if (_settings.EnableSecureWebSocket && !string.IsNullOrEmpty(_settings.SslCertificatePath))
{
_server.Certificate = new X509Certificate2(
_settings.SslCertificatePath,
_settings.SslCertificatePassword);
}
_server.RestartAfterListenError = true;
_server.Start(ConfigureSocket);
IsRunning = true;
Log($"WebSocket server started on {url}");
_logger.LogInformation("WebSocket server started on {Url}", url);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start WebSocket server");
Log($"Failed to start server: {ex.Message}");
throw;
}
}
public void Stop()
{
if (!IsRunning)
return;
try
{
// Close all connections
foreach (var connection in _connections.Values)
{
try
{
connection.Close();
}
catch { }
}
_connections.Clear();
_server?.Dispose();
_server = null;
IsRunning = false;
Log("WebSocket server stopped");
_logger.LogInformation("WebSocket server stopped");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping WebSocket server");
}
}
private void ConfigureSocket(IWebSocketConnection socket)
{
var connectionId = Guid.NewGuid();
socket.OnOpen = () =>
{
_connections[connectionId] = socket;
Log($"Client connected: {connectionId}");
OnConnectionCountChanged?.Invoke(this, ConnectionCount);
// Send welcome message
var welcome = new SignResponse
{
Success = true,
Message = "Connected to DigitalSignerApp",
Version = "1.0.0",
Timestamp = DateTime.UtcNow
};
SendResponse(socket, welcome);
};
socket.OnClose = () =>
{
_connections.TryRemove(connectionId, out _);
Log($"Client disconnected: {connectionId}");
OnConnectionCountChanged?.Invoke(this, ConnectionCount);
};
socket.OnMessage = async message =>
{
await HandleMessageAsync(socket, message);
};
socket.OnError = ex =>
{
_logger.LogError(ex, "WebSocket error for connection {ConnectionId}", connectionId);
Log($"WebSocket error: {ex.Message}");
};
}
private async Task HandleMessageAsync(IWebSocketConnection socket, string message)
{
SignResponse response;
try
{
var request = JsonSerializer.Deserialize<SignRequest>(message, _jsonOptions);
if (request == null)
{
response = SignResponse.Error("", "Invalid request format", "INVALID_REQUEST");
SendResponse(socket, response);
return;
}
Log($"Request: {request.Action} (ID: {request.RequestId})");
_logger.LogInformation("Processing request {Action} with ID {RequestId}",
request.Action, request.RequestId);
response = request.Action?.ToLower() switch
{
"list_certificates" => HandleListCertificates(request),
"sign_pades" => await HandleSignPAdESAsync(request),
"sign_cades" => await HandleSignCAdESAsync(request),
"sign" => await HandleAutoSignAsync(request),
"verify_cades" => HandleVerifyCAdES(request),
"ping" => SignResponse.Ok(request.RequestId, "pong"),
"health" => HandleHealthCheck(request),
_ => SignResponse.Error(request.RequestId, $"Unknown action: {request.Action}", "UNKNOWN_ACTION")
};
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Invalid JSON received");
response = SignResponse.Error("", "Invalid JSON format", "JSON_ERROR");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing request");
response = SignResponse.Error("", $"Internal error: {ex.Message}", "INTERNAL_ERROR");
}
SendResponse(socket, response);
}
private SignResponse HandleListCertificates(SignRequest request)
{
try
{
var certificates = _certificateService.GetCertificateInfoList();
return new SignResponse
{
RequestId = request.RequestId,
Success = true,
Certificates = certificates,
Message = $"Found {certificates.Count} signing certificate(s)"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing certificates");
return SignResponse.Error(request.RequestId, ex.Message, "CERTIFICATE_ERROR");
}
}
private async Task<SignResponse> HandleSignPAdESAsync(SignRequest request)
{
var pdfData = request.PdfBase64 ?? request.DataBase64;
if (string.IsNullOrEmpty(pdfData))
{
return SignResponse.Error(request.RequestId, "PDF data is required", "MISSING_DATA");
}
var certificate = await GetCertificateAsync(request.CertificateThumbprint);
if (certificate == null)
{
return SignResponse.Error(request.RequestId, "No certificate selected or found", "NO_CERTIFICATE");
}
try
{
var pdfBytes = Convert.FromBase64String(pdfData);
if (request.IncludeTimestamp && !string.IsNullOrEmpty(request.TsaUrl ?? _settings.DefaultTsaUrl))
{
return await _padesSignerService.SignPdfWithTimestampAsync(
pdfBytes,
certificate,
request,
request.TsaUrl ?? _settings.DefaultTsaUrl!);
}
return await _padesSignerService.SignPdfAsync(pdfBytes, certificate, request);
}
catch (FormatException)
{
return SignResponse.Error(request.RequestId, "Invalid Base64 data", "INVALID_BASE64");
}
}
private async Task<SignResponse> HandleSignCAdESAsync(SignRequest request)
{
var data = request.DataBase64 ?? request.PdfBase64;
if (string.IsNullOrEmpty(data))
{
return SignResponse.Error(request.RequestId, "Data to sign is required", "MISSING_DATA");
}
var certificate = await GetCertificateAsync(request.CertificateThumbprint);
if (certificate == null)
{
return SignResponse.Error(request.RequestId, "No certificate selected or found", "NO_CERTIFICATE");
}
try
{
var dataBytes = Convert.FromBase64String(data);
return await _cadesSignerService.CreateDetachedSignatureAsync(dataBytes, certificate, request);
}
catch (FormatException)
{
return SignResponse.Error(request.RequestId, "Invalid Base64 data", "INVALID_BASE64");
}
}
private async Task<SignResponse> HandleAutoSignAsync(SignRequest request)
{
return request.SignatureType?.ToLower() switch
{
"cades" => await HandleSignCAdESAsync(request),
_ => await HandleSignPAdESAsync(request)
};
}
private SignResponse HandleVerifyCAdES(SignRequest request)
{
if (string.IsNullOrEmpty(request.DataBase64) || string.IsNullOrEmpty(request.PdfBase64))
{
return SignResponse.Error(request.RequestId,
"Both data and signature are required for verification", "MISSING_DATA");
}
try
{
var data = Convert.FromBase64String(request.DataBase64);
var signature = Convert.FromBase64String(request.PdfBase64);
var result = _cadesSignerService.VerifyDetachedSignature(data, signature);
result.RequestId = request.RequestId;
return result;
}
catch (FormatException)
{
return SignResponse.Error(request.RequestId, "Invalid Base64 data", "INVALID_BASE64");
}
}
private SignResponse HandleHealthCheck(SignRequest request)
{
return new SignResponse
{
RequestId = request.RequestId,
Success = true,
Message = "Service is healthy",
Timestamp = DateTime.UtcNow,
Version = "1.0.0"
};
}
private async Task<X509Certificate2?> GetCertificateAsync(string? thumbprint)
{
if (!string.IsNullOrEmpty(thumbprint))
{
return await _certificateService.GetCertificateByThumbprintAsync(thumbprint);
}
if (OnCertificateSelectionRequired != null)
{
var certificates = await _certificateService.GetSigningCertificatesAsync();
if (certificates.Count == 0)
{
Log("No signing certificates found");
return null;
}
if (certificates.Count == 1)
{
return certificates[0];
}
return await OnCertificateSelectionRequired.Invoke(certificates);
}
var certs = await _certificateService.GetSigningCertificatesAsync();
return certs.FirstOrDefault();
}
private void SendResponse(IWebSocketConnection socket, SignResponse response)
{
try
{
var json = JsonSerializer.Serialize(response, _jsonOptions);
socket.Send(json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending response");
}
}
private void Log(string message)
{
var logMessage = $"[{DateTime.Now:HH:mm:ss}] {message}";
OnLogMessage?.Invoke(this, logMessage);
}
public void Dispose()
{
Stop();
}
}using CommunityToolkit.Mvvm.ComponentModel;
namespace DigitalSignerApp.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services.Interfaces;
namespace DigitalSignerApp.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly IWebSocketServer _webSocketServer;
private readonly ICertificateService _certificateService;
private readonly AppSettings _settings;
[ObservableProperty]
private bool _isServerRunning;
[ObservableProperty]
private int _connectionCount;
[ObservableProperty]
private string _statusText = "Stopped";
[ObservableProperty]
private ObservableCollection<string> _logMessages = new();
public int Port => _settings.WebSocketPort;
public MainWindowViewModel(
IWebSocketServer webSocketServer,
ICertificateService certificateService,
AppSettings settings)
{
_webSocketServer = webSocketServer;
_certificateService = certificateService;
_settings = settings;
_webSocketServer.OnLogMessage += (_, msg) =>
{
LogMessages.Add(msg);
// Keep only last 1000 messages
while (LogMessages.Count > 1000)
{
LogMessages.RemoveAt(0);
}
};
_webSocketServer.OnConnectionCountChanged += (_, count) =>
{
ConnectionCount = count;
};
}
[RelayCommand]
private void StartServer()
{
try
{
_webSocketServer.Start();
IsServerRunning = true;
StatusText = "Running";
}
catch (Exception ex)
{
LogMessages.Add($"[ERROR] Failed to start server: {ex.Message}");
}
}
[RelayCommand]
private void StopServer()
{
_webSocketServer.Stop();
IsServerRunning = false;
StatusText = "Stopped";
}
[RelayCommand]
private void ClearLog()
{
LogMessages.Clear();
}
[RelayCommand]
private async Task ViewCertificates()
{
try
{
var certs = await _certificateService.GetSigningCertificatesAsync();
LogMessages.Add($"Found {certs.Count} signing certificates");
foreach (var cert in certs)
{
var info = Utils.CertificateUtils.ToCertificateInfo(cert);
LogMessages.Add($" - {info.CommonName} (Valid until: {info.NotAfter:d})");
}
}
catch (Exception ex)
{
LogMessages.Add($"[ERROR] Failed to list certificates: {ex.Message}");
}
}
public void Initialize()
{
if (_settings.AutoStart)
{
StartServer();
}
}
public void Shutdown()
{
StopServer();
}
}using System.Collections.ObjectModel;
using System.Security.Cryptography.X509Certificates;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DigitalSignerApp.Models;
using DigitalSignerApp.Utils;
namespace DigitalSignerApp.ViewModels;
public partial class CertificateSelectionViewModel : ViewModelBase
{
private readonly List<X509Certificate2> _certificates;
[ObservableProperty]
private ObservableCollection<CertificateDisplayItem> _certificateItems = new();
[ObservableProperty]
private CertificateDisplayItem? _selectedItem;
[ObservableProperty]
private string _detailsText = "";
public X509Certificate2? SelectedCertificate { get; private set; }
public bool WasConfirmed { get; private set; }
public CertificateSelectionViewModel(List<X509Certificate2> certificates)
{
_certificates = certificates;
foreach (var cert in certificates)
{
CertificateItems.Add(new CertificateDisplayItem(cert));
}
if (CertificateItems.Any())
{
SelectedItem = CertificateItems[0];
}
}
partial void OnSelectedItemChanged(CertificateDisplayItem? value)
{
if (value != null)
{
DetailsText = $"Subject: {value.Subject}\n" +
$"Serial Number: {value.SerialNumber}\n" +
$"Thumbprint: {value.Thumbprint}\n" +
$"Key Type: {value.KeyType}";
}
else
{
DetailsText = "";
}
}
[RelayCommand]
private void Confirm()
{
if (SelectedItem != null)
{
SelectedCertificate = _certificates.First(c => c.Thumbprint == SelectedItem.Thumbprint);
WasConfirmed = true;
}
}
[RelayCommand]
private void Cancel()
{
WasConfirmed = false;
}
}
public class CertificateDisplayItem
{
private readonly X509Certificate2 _certificate;
public CertificateDisplayItem(X509Certificate2 certificate)
{
_certificate = certificate;
var info = CertificateUtils.ToCertificateInfo(certificate);
CommonName = info.CommonName;
Issuer = CertificateUtils.ExtractCommonName(info.Issuer);
NotAfter = info.NotAfter;
IsValid = info.IsValid;
Thumbprint = info.Thumbprint;
Subject = info.Subject;
SerialNumber = info.SerialNumber;
KeyType = $"{info.KeyType} {info.KeySize}";
IsHardwareToken = info.IsHardwareToken;
}
public string CommonName { get; }
public string Issuer { get; }
public DateTime NotAfter { get; }
public bool IsValid { get; }
public string Thumbprint { get; }
public string Subject { get; }
public string SerialNumber { get; }
public string KeyType { get; }
public bool IsHardwareToken { get; }
public string StatusText => IsValid ? "Valid" : "Invalid";
public string TokenText => IsHardwareToken ? "🔐" : "";
}<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DigitalSignerApp.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://DigitalSignerApp/Styles/AppStyles.axaml"/>
</Application.Styles>
</Application>using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using DigitalSignerApp.Models;
using DigitalSignerApp.Services;
using DigitalSignerApp.Services.Interfaces;
using DigitalSignerApp.Utils;
using DigitalSignerApp.ViewModels;
using DigitalSignerApp.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
namespace DigitalSignerApp;
public partial class App : Application
{
private IServiceProvider? _serviceProvider;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
ConfigureServices();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var mainWindow = new MainWindow
{
DataContext = _serviceProvider!.GetRequiredService<MainWindowViewModel>()
};
// Configure certificate selection callback
var wsServer = _serviceProvider!.GetRequiredService<SigningWebSocketServer>();
wsServer.OnCertificateSelectionRequired += async (certificates) =>
{
return await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
var dialog = new CertificateSelectionDialog(certificates);
dialog.ShowDialog(mainWindow);
if (dialog.DialogResult == true)
{
return dialog.SelectedCertificate;
}
return null;
});
};
desktop.MainWindow = mainWindow;
desktop.ShutdownRequested += (_, _) =>
{
var vm = mainWindow.DataContext as MainWindowViewModel;
vm?.Shutdown();
};
}
base.OnFrameworkInitializationCompleted();
}
private void ConfigureServices()
{
var services = new ServiceCollection();
// Configuration
var settings = LoadSettings();
services.AddSingleton(settings);
// Logging
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(PlatformHelper.GetLogPath(), "app-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: settings.MaxLogRetentionDays)
.CreateLogger();
services.AddLogging(builder =>
{
builder.AddSerilog(dispose: true);
});
// Services - Platform specific
if (PlatformHelper.IsWindows)
{
services.AddSingleton<ICertificateService, WindowsCertificateService>();
}
else if (PlatformHelper.IsMacOS)
{
services.AddSingleton<ICertificateService, MacOSCertificateService>();
}
else
{
// Linux fallback
services.AddSingleton<ICertificateService, WindowsCertificateService>();
}
services.AddSingleton<IPAdESSignerService, PAdESSignerService>();
services.AddSingleton<ICAdESSignerService, CAdESSignerService>();
services.AddSingleton<SigningWebSocketServer>();
services.AddSingleton<IWebSocketServer>(sp => sp.GetRequiredService<SigningWebSocketServer>());
// ViewModels
services.AddTransient<MainWindowViewModel>();
_serviceProvider = services.BuildServiceProvider();
}
private AppSettings LoadSettings()
{
var settingsPath = PlatformHelper.GetSettingsPath();
if (File.Exists(settingsPath))
{
try
{
var json = File.ReadAllText(settingsPath);
return System.Text.Json.JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
var settings = new AppSettings();
SaveSettings(settings);
return settings;
}
private void SaveSettings(AppSettings settings)
{
try
{
var json = System.Text.Json.JsonSerializer.Serialize(settings, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(PlatformHelper.GetSettingsPath(), json);
}
catch
{
// Ignore save errors
}
}
}<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DigitalSignerApp.ViewModels"
x:Class="DigitalSignerApp.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Digital Signer App"
Width="800" Height="600"
MinWidth="600" MinHeight="400"
WindowStartupLocation="CenterScreen">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Digital Signer App"
FontSize="24"
FontWeight="Bold"/>
<TextBlock Text="PDF signing service with PAdES and CAdES support"
Foreground="Gray"
Margin="0,4,0,0"/>
</StackPanel>
<!-- Status Panel -->
<Border Grid.Row="1"
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
CornerRadius="8"
Padding="16"
Margin="0,0,0,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal">
<Ellipse Width="12" Height="12"
Fill="{Binding IsServerRunning, Converter={StaticResource BoolToColorConverter}}"
Margin="0,0,8,0"/>
<TextBlock Text="{Binding StatusText}"
VerticalAlignment="Center"
FontWeight="SemiBold"/>
</StackPanel>
<TextBlock Text="{Binding Port, StringFormat='Port: {0}'}"
Foreground="Gray"
Margin="20,4,0,0"/>
<TextBlock Text="{Binding ConnectionCount, StringFormat='Connections: {0}'}"
Foreground="Gray"
Margin="20,2,0,0"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="Start Server"
Command="{Binding StartServerCommand}"
IsEnabled="{Binding !IsServerRunning}"
Classes="Primary"/>
<Button Content="Stop Server"
Command="{Binding StopServerCommand}"
IsEnabled="{Binding IsServerRunning}"/>
</StackPanel>
</Grid>
</Border>
<!-- Log Panel -->
<Border Grid.Row="2"
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
CornerRadius="8"
Padding="8">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Activity Log"
FontWeight="SemiBold"
Margin="8,0,0,8"/>
<ScrollViewer Grid.Row="1"
Name="LogScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding LogMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"
FontFamily="Consolas, Menlo, monospace"
FontSize="11"
Margin="0,1"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- Bottom Actions -->
<StackPanel Grid.Row="3"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,16,0,0">
<Button Content="View Certificates"
Command="{Binding ViewCertificatesCommand}"/>
<Button Content="Clear Log"
Command="{Binding ClearLogCommand}"/>
<Button Content="Minimize to Tray"
Click="MinimizeToTray_Click"/>
</StackPanel>
</Grid>
</Window>using Avalonia.Controls;
using Avalonia.Interactivity;
using DigitalSignerApp.ViewModels;
namespace DigitalSignerApp.Views;
public partial class MainWindow : Window
{
private bool _isExiting;
public MainWindow()
{
InitializeComponent();
Loaded += OnLoaded;
Closing += OnClosing;
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
{
vm.Initialize();
}
}
private void OnClosing(object? sender, WindowClosingEventArgs e)
{
if (!_isExiting)
{
e.Cancel = true;
Hide();
}
}
private void MinimizeToTray_Click(object? sender, RoutedEventArgs e)
{
Hide();
}
public void ExitApplication()
{
_isExiting = true;
Close();
}
public void ShowAndActivate()
{
Show();
Activate();
WindowState = WindowState.Normal;
}
}<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DigitalSignerApp.ViewModels"
x:Class="DigitalSignerApp.Views.CertificateSelectionDialog"
x:DataType="vm:CertificateSelectionViewModel"
Title="Select Certificate"
Width="650" Height="450"
MinWidth="500" MinHeight="350"
WindowStartupLocation="CenterOwner"
CanResize="True">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Select a certificate for digital signature:"
FontWeight="SemiBold"
Margin="0,0,0,12"/>
<DataGrid Grid.Row="1"
ItemsSource="{Binding CertificateItems}"
SelectedItem="{Binding SelectedItem}"
AutoGenerateColumns="False"
IsReadOnly="True"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
SelectionMode="Single"
GridLinesVisibility="Horizontal"
BorderThickness="1"
CornerRadius="4">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding CommonName}"
Width="200"/>
<DataGridTextColumn Header="Issuer"
Binding="{Binding Issuer}"
Width="150"/>
<DataGridTextColumn Header="Valid Until"
Binding="{Binding NotAfter, StringFormat={}{0:d}}"
Width="100"/>
<DataGridTextColumn Header="Status"
Binding="{Binding StatusText}"
Width="70"/>
<DataGridTextColumn Header=""
Binding="{Binding TokenText}"
Width="30"/>
</DataGrid.Columns>
</DataGrid>
<Border Grid.Row="2"
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
CornerRadius="4"
Padding="8"
Margin="0,12,0,0"
MinHeight="60">
<TextBlock Text="{Binding DetailsText}"
TextWrapping="Wrap"
FontSize="11"
Foreground="Gray"/>
</Border>
<StackPanel Grid.Row="3"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
Margin="0,16,0,0">
<Button Content="Select"
Command="{Binding ConfirmCommand}"
IsDefault="True"
Classes="Primary"
Click="SelectButton_Click"/>
<Button Content="Cancel"
Command="{Binding CancelCommand}"
IsCancel="True"
Click="CancelButton_Click"/>
</StackPanel>
</Grid>
</Window>using System.Security.Cryptography.X509Certificates;
using Avalonia.Controls;
using Avalonia.Interactivity;
using DigitalSignerApp.ViewModels;
namespace DigitalSignerApp.Views;
public partial class CertificateSelectionDialog : Window
{
private readonly CertificateSelectionViewModel _viewModel;
public bool? DialogResult { get; private set; }
public X509Certificate2? SelectedCertificate => _viewModel.SelectedCertificate;
public CertificateSelectionDialog(List<X509Certificate2> certificates)
{
_viewModel = new CertificateSelectionViewModel(certificates);
DataContext = _viewModel;
InitializeComponent();
}
private void SelectButton_Click(object? sender, RoutedEventArgs e)
{
if (_viewModel.WasConfirmed)
{
DialogResult = true;
Close();
}
}
private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Bool to Color Converter -->
<Style>
<Style.Resources>
<SolidColorBrush x:Key="GreenBrush" Color="#10B981"/>
<SolidColorBrush x:Key="RedBrush" Color="#EF4444"/>
</Style.Resources>
</Style>
<!-- Primary Button Style -->
<Style Selector="Button.Primary">
<Setter Property="Background" Value="#3B82F6"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Button.Primary:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2563EB"/>
</Style>
<!-- Button Base Style -->
<Style Selector="Button">
<Setter Property="Padding" Value="16,8"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
</Styles>// Add this converter class
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace DigitalSignerApp.Converters;
public class BoolToColorConverter : IValueConverter
{
public static readonly BoolToColorConverter Instance = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool b)
{
return b ? new SolidColorBrush(Color.Parse("#10B981")) : new SolidColorBrush(Color.Parse("#EF4444"));
}
return new SolidColorBrush(Colors.Gray);
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}Update App.axaml to include the converter:
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:DigitalSignerApp.Converters"
x:Class="DigitalSignerApp.App"
RequestedThemeVariant="Default">
<Application.Resources>
<converters:BoolToColorConverter x:Key="BoolToColorConverter"/>
</Application.Resources>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>/**
* Cross-platform Digital Signer Client
* Compatible with Windows and macOS signing service
*/
class DigitalSignerClient {
constructor(options = {}) {
this.port = options.port || 9876;
this.secure = options.secure || false;
this.timeout = options.timeout || 300000; // 5 minutes for PIN entry
this.ws = null;
this.pendingRequests = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 3;
this.reconnectDelay = options.reconnectDelay || 1000;
// Event callbacks
this.onStatusChange = null;
this.onError = null;
this.onLog = null;
}
get isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
async connect() {
if (this.isConnected) {
return;
}
return new Promise((resolve, reject) => {
const protocol = this.secure ? 'wss' : 'ws';
const url = `${protocol}://127.0.0.1:${this.port}`;
this.log(`Connecting to ${url}...`);
try {
this.ws = new WebSocket(url);
} catch (error) {
reject(new Error(`Failed to create WebSocket: ${error.message}`));
return;
}
const connectionTimeout = setTimeout(() => {
this.ws?.close();
reject(new Error('Connection timeout'));
}, 10000);
this.ws.onopen = () => {
clearTimeout(connectionTimeout);
this.reconnectAttempts = 0;
this.log('Connected to Digital Signer');
this.onStatusChange?.('connected');
resolve();
};
this.ws.onclose = (event) => {
this.log(`Disconnected: ${event.code} ${event.reason}`);
this.onStatusChange?.('disconnected');
this.handleDisconnect();
};
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout);
this.log(`WebSocket error: ${error}`);
this.onError?.(error);
reject(new Error('WebSocket connection failed. Is the Digital Signer app running?'));
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
});
}
disconnect() {
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent auto-reconnect
this.ws?.close();
this.ws = null;
}
async reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.onError?.(new Error('Max reconnection attempts reached'));
return false;
}
this.reconnectAttempts++;
this.log(`Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
await this.sleep(this.reconnectDelay * this.reconnectAttempts);
try {
await this.connect();
return true;
} catch {
return false;
}
}
handleDisconnect() {
// Reject all pending requests
for (const [requestId, pending] of this.pendingRequests) {
pending.reject(new Error('Connection lost'));
}
this.pendingRequests.clear();
}
handleMessage(data) {
try {
const response = JSON.parse(data);
const pending = this.pendingRequests.get(response.requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingRequests.delete(response.requestId);
if (response.success) {
pending.resolve(response);
} else {
const error = new Error(response.message || 'Operation failed');
error.code = response.errorCode;
pending.reject(error);
}
} else {
// Welcome message or broadcast
this.log(`Received: ${response.message || 'notification'}`);
}
} catch (error) {
this.log(`Failed to parse message: ${error.message}`);
}
}
sendRequest(request) {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
reject(new Error('Not connected to Digital Signer'));
return;
}
const requestId = this.generateRequestId();
request.requestId = requestId;
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(requestId)) {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout - user may have cancelled PIN entry'));
}
}, this.timeout);
this.pendingRequests.set(requestId, { resolve, reject, timeoutId });
try {
this.ws.send(JSON.stringify(request));
this.log(`Sent request: ${request.action}`);
} catch (error) {
clearTimeout(timeoutId);
this.pendingRequests.delete(requestId);
reject(error);
}
});
}
// API Methods
async ping() {
return this.sendRequest({ action: 'ping' });
}
async health() {
return this.sendRequest({ action: 'health' });
}
async listCertificates() {
return this.sendRequest({ action: 'list_certificates' });
}
async signPAdES(pdfBase64, options = {}) {
return this.sendRequest({
action: 'sign_pades',
pdfBase64,
reason: options.reason,
location: options.location,
certificateThumbprint: options.thumbprint,
visibleSignature: options.visibleSignature || false,
signaturePosition: options.signaturePosition,
includeTimestamp: options.includeTimestamp || false,
tsaUrl: options.tsaUrl
});
}
async signCAdES(dataBase64, options = {}) {
return this.sendRequest({
action: 'sign_cades',
dataBase64,
certificateThumbprint: options.thumbprint
});
}
async verifyCAdES(dataBase64, signatureBase64) {
return this.sendRequest({
action: 'verify_cades',
dataBase64,
pdfBase64: signatureBase64
});
}
// Utility methods
generateRequestId() {
return 'req_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
log(message) {
const timestamp = new Date().toISOString();
console.log(`[DigitalSigner ${timestamp}] ${message}`);
this.onLog?.(message);
}
// File helpers
static async fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
static downloadBase64(base64, filename, mimeType = 'application/pdf') {
const link = document.createElement('a');
link.href = `data:${mimeType};base64,${base64}`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
static base64ToBlob(base64, mimeType = 'application/pdf') {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
}
}
// Export for different module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = DigitalSignerClient;
}
if (typeof window !== 'undefined') {
window.DigitalSignerClient = DigitalSignerClient;
}#!/bin/bash
# Build for current platform
dotnet build -c Release
# Publish for macOS
dotnet publish DigitalSignerApp.Desktop/DigitalSignerApp.Desktop.csproj \
-c Release \
-r osx-x64 \
--self-contained true \
-p:PublishSingleFile=true \
-o ./publish/macos-x64
# Publish for macOS ARM
dotnet publish DigitalSignerApp.Desktop/DigitalSignerApp.Desktop.csproj \
-c Release \
-r osx-arm64 \
--self-contained true \
-p:PublishSingleFile=true \
-o ./publish/macos-arm64
echo "Build complete!"# Build for current platform
dotnet build -c Release
# Publish for Windows x64
dotnet publish DigitalSignerApp.Desktop/DigitalSignerApp.Desktop.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o ./publish/windows-x64
Write-Host "Build complete!"| Category | Improvement |
|---|---|
| Cross-Platform | Uses Avalonia UI instead of WPF for Windows + macOS support |
| Architecture | Dependency Injection with Microsoft.Extensions.DependencyInjection |
| Logging | Serilog with file rolling and retention |
| Configuration | JSON-based settings with automatic persistence |
| Error Handling | Structured error codes and messages |
| Security | WSS support for encrypted WebSocket connections |
| Certificate Access | Platform-specific implementations (Windows Store + macOS Keychain) |
| MVVM | CommunityToolkit.Mvvm for clean view/viewmodel separation |
| JSON | System.Text.Json instead of Newtonsoft (better performance) |
| Single Instance | Cross-platform mutex and named pipe implementation |
| Async/Await | Proper async patterns throughout |
| Testability | Interface-based services for easy mocking |
| User Experience | Hardware token indicator, key type display |