feat: add pairing and audit foundations

This commit is contained in:
sladro 2026-03-27 11:34:44 +08:00
parent 306e06aca6
commit 1948823bbc
9 changed files with 249 additions and 0 deletions

View File

@ -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);

View File

@ -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<IClock, SystemClock>();
builder.Services.AddSingleton<PairingService>();
builder.Services.AddSingleton<TrustedDeviceStore>();
builder.Services.AddSingleton<AuditLog>();
// 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<IOptions<AgentOptions>>().Value;
Directory.CreateDirectory(agentOptions.DataRoot);
app.MapGet("/health", () => Results.Json(new { status = "ok" }));
app.MapPairingEndpoints();
app.Run();

View File

@ -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<AgentOptions> 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);
}
}

View File

@ -0,0 +1,7 @@
namespace TermRemoteCtl.Agent.Security;
public sealed record AuditRecord(
string EventType,
DateTimeOffset OccurredAtUtc,
string SubjectId,
string Details);

View File

@ -0,0 +1,3 @@
namespace TermRemoteCtl.Agent.Security;
public sealed record PairingCode(string Value, DateTimeOffset ExpiresAtUtc);

View File

@ -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);
}
}
}

View File

@ -0,0 +1,8 @@
namespace TermRemoteCtl.Agent.Security;
public sealed record TrustedDevice(
string DeviceId,
string DeviceName,
string SharedSecretHash,
DateTimeOffset PairedAtUtc,
bool Revoked);

View File

@ -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<AgentOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
_path = Path.Combine(options.Value.DataRoot, "trusted-devices.json");
}
public async Task<IReadOnlyList<TrustedDevice>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_path))
{
return Array.Empty<TrustedDevice>();
}
await using var stream = new FileStream(
_path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous);
var devices = await JsonSerializer.DeserializeAsync<List<TrustedDevice>>(
stream,
SerializerOptions,
cancellationToken);
return devices ?? (IReadOnlyList<TrustedDevice>)Array.Empty<TrustedDevice>();
}
}

View File

@ -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;
}
}