feat: add pairing and audit foundations
This commit is contained in:
parent
306e06aca6
commit
1948823bbc
@ -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);
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
namespace TermRemoteCtl.Agent.Security;
|
||||
|
||||
public sealed record AuditRecord(
|
||||
string EventType,
|
||||
DateTimeOffset OccurredAtUtc,
|
||||
string SubjectId,
|
||||
string Details);
|
||||
@ -0,0 +1,3 @@
|
||||
namespace TermRemoteCtl.Agent.Security;
|
||||
|
||||
public sealed record PairingCode(string Value, DateTimeOffset ExpiresAtUtc);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
namespace TermRemoteCtl.Agent.Security;
|
||||
|
||||
public sealed record TrustedDevice(
|
||||
string DeviceId,
|
||||
string DeviceName,
|
||||
string SharedSecretHash,
|
||||
DateTimeOffset PairedAtUtc,
|
||||
bool Revoked);
|
||||
@ -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>();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user