diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs new file mode 100644 index 0000000..a20b04b --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/PairingEndpoints.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using TermRemoteCtl.Agent.Security; + +namespace TermRemoteCtl.Agent.Api; + +public static class PairingEndpoints +{ + private static readonly TimeSpan PairingCodeTtl = TimeSpan.FromMinutes(5); + + public static IEndpointRouteBuilder MapPairingEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/pairing"); + + group.MapPost("/code", async ( + PairingService pairingService, + AuditLog auditLog, + IClock clock, + CancellationToken cancellationToken) => + { + var code = pairingService.CreateCode(PairingCodeTtl); + await auditLog.AppendAsync( + new AuditRecord("pairing_code_created", clock.UtcNow, code.Value, $"expires_at={code.ExpiresAtUtc:O}"), + cancellationToken); + + return Results.Ok(code); + }); + + group.MapPost("/redeem", async ( + [FromBody] RedeemPairingRequest request, + PairingService pairingService, + AuditLog auditLog, + IClock clock, + CancellationToken cancellationToken) => + { + var result = pairingService.Redeem(request.Code, request.DeviceName); + var response = new RedeemPairingResponse(result.Success, result.ErrorCode); + var eventType = result.Success ? "pairing_redeemed" : "pairing_redeem_failed"; + var details = result.Success ? request.DeviceName : result.ErrorCode; + + await auditLog.AppendAsync( + new AuditRecord(eventType, clock.UtcNow, request.DeviceName, details), + cancellationToken); + + return result.Success ? Results.Ok(response) : Results.BadRequest(response); + }); + + return endpoints; + } +} + +public sealed record RedeemPairingRequest(string Code, string DeviceName); + +public sealed record RedeemPairingResponse(bool Success, string ErrorCode); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index 800cabb..93101c4 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -1,9 +1,15 @@ using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Api; using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.Security; var builder = WebApplication.CreateBuilder(args); var agentOptions = builder.Services.AddAgentOptions(builder.Configuration); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Task 2 uses ASP.NET Core's local development certificate so HttpsPort is a truthful HTTPS listener. builder.WebHost.ConfigureKestrel(kestrel => { @@ -16,5 +22,6 @@ agentOptions = app.Services.GetRequiredService>().Value; Directory.CreateDirectory(agentOptions.DataRoot); app.MapGet("/health", () => Results.Json(new { status = "ok" })); +app.MapPairingEndpoints(); app.Run(); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditLog.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditLog.cs new file mode 100644 index 0000000..2ed05f5 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditLog.cs @@ -0,0 +1,38 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; + +namespace TermRemoteCtl.Agent.Security; + +public sealed class AuditLog +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private static readonly Encoding Utf8NoBom = new UTF8Encoding(false); + private readonly string _path; + + public AuditLog(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _path = Path.Combine(options.Value.DataRoot, "audit.log.jsonl"); + } + + public async Task AppendAsync(AuditRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + + var payload = JsonSerializer.Serialize(record, SerializerOptions) + Environment.NewLine; + await using var stream = new FileStream( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await using var writer = new StreamWriter(stream, Utf8NoBom); + await writer.WriteAsync(payload.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditRecord.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditRecord.cs new file mode 100644 index 0000000..8d52ea3 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/AuditRecord.cs @@ -0,0 +1,7 @@ +namespace TermRemoteCtl.Agent.Security; + +public sealed record AuditRecord( + string EventType, + DateTimeOffset OccurredAtUtc, + string SubjectId, + string Details); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingCode.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingCode.cs new file mode 100644 index 0000000..8e28da2 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingCode.cs @@ -0,0 +1,3 @@ +namespace TermRemoteCtl.Agent.Security; + +public sealed record PairingCode(string Value, DateTimeOffset ExpiresAtUtc); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingService.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingService.cs new file mode 100644 index 0000000..885d1c8 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/PairingService.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; + +namespace TermRemoteCtl.Agent.Security; + +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} + +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} + +public sealed class PairingService(IClock clock) +{ + private readonly object _sync = new(); + private PairingCode? _currentCode; + + public PairingCode CreateCode(TimeSpan ttl) + { + if (ttl <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(ttl), "Pairing code TTL must be positive."); + } + + var pairingCode = new PairingCode( + RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"), + clock.UtcNow.Add(ttl)); + + lock (_sync) + { + _currentCode = pairingCode; + } + + return pairingCode; + } + + public (bool Success, string ErrorCode) Redeem(string code, string deviceName) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(deviceName); + + lock (_sync) + { + var currentCode = _currentCode; + + if (currentCode is null || !string.Equals(currentCode.Value, code, StringComparison.Ordinal)) + { + return (false, "not_found"); + } + + if (currentCode.ExpiresAtUtc <= clock.UtcNow) + { + _currentCode = null; + return (false, "expired"); + } + + _currentCode = null; + return (true, string.Empty); + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDevice.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDevice.cs new file mode 100644 index 0000000..f6b7a0f --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDevice.cs @@ -0,0 +1,8 @@ +namespace TermRemoteCtl.Agent.Security; + +public sealed record TrustedDevice( + string DeviceId, + string DeviceName, + string SharedSecretHash, + DateTimeOffset PairedAtUtc, + bool Revoked); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDeviceStore.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDeviceStore.cs new file mode 100644 index 0000000..c6b9491 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Security/TrustedDeviceStore.cs @@ -0,0 +1,41 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TermRemoteCtl.Agent.Configuration; + +namespace TermRemoteCtl.Agent.Security; + +public sealed class TrustedDeviceStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + private readonly string _path; + + public TrustedDeviceStore(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _path = Path.Combine(options.Value.DataRoot, "trusted-devices.json"); + } + + public async Task> LoadAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_path)) + { + return Array.Empty(); + } + + await using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + + var devices = await JsonSerializer.DeserializeAsync>( + stream, + SerializerOptions, + cancellationToken); + + return devices ?? (IReadOnlyList)Array.Empty(); + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Security/PairingServiceTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Security/PairingServiceTests.cs new file mode 100644 index 0000000..978dfd2 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Security/PairingServiceTests.cs @@ -0,0 +1,29 @@ +using TermRemoteCtl.Agent.Security; + +namespace TermRemoteCtl.Agent.Tests.Security; + +public class PairingServiceTests +{ + [Fact] + public void Redeem_Returns_Expired_And_Clears_Current_Code_When_Code_Has_Expired() + { + var clock = new FakeClock(new DateTimeOffset(2026, 03, 27, 02, 00, 00, TimeSpan.Zero)); + var service = new PairingService(clock); + + var code = service.CreateCode(TimeSpan.FromMinutes(5)); + clock.UtcNow = code.ExpiresAtUtc.AddSeconds(1); + + var expiredResult = service.Redeem(code.Value, "Pixel 9"); + var missingResult = service.Redeem(code.Value, "Pixel 9"); + + Assert.False(expiredResult.Success); + Assert.Equal("expired", expiredResult.ErrorCode); + Assert.False(missingResult.Success); + Assert.Equal("not_found", missingResult.ErrorCode); + } + + private sealed class FakeClock(DateTimeOffset utcNow) : IClock + { + public DateTimeOffset UtcNow { get; set; } = utcNow; + } +}