Stufe 2 (#764): Einmal-Anmeldung fuer die Huelle ueber den Manager-Socket

Der Manager hoert zusaetzlich auf einem lokalen Socket (0600 bzw. Pipe nur
fuer den eigenen Benutzer). Dort gibt es nur /api/shell/*, ueber TCP gibt
es diese Pfade nicht. Ein Einmal-Code (256 Bit, einmal, hoechstens 60 s)
wird in der WebUI aus dem Fragment gegen das Token getauscht und sofort
aus der Adresse entfernt. Tests: zweimal, zu spaet, ueber TCP - alle
scheitern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-03 18:44:58 +02:00
co-authored by Claude Opus 5.5
parent c3927292fd
commit 775c4dbc8e
9 changed files with 434 additions and 18 deletions
+88
View File
@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// #764: One-time codes that sign the shell's browser window in.
///
/// <para><b>Why not hand the shell the access token.</b> The shell would have to put it into
/// the address it opens, and an address ends up in histories, logs and window titles. A code
/// that works once and for at most a minute is worth nothing afterwards. The token itself only
/// travels in the answer to the exchange, the same way it travels when someone types it in.</para>
///
/// <para><b>Who gets a code:</b> only a request over the manager's local socket
/// (<see cref="ShellTransport"/>), which the file system restricts to the user running the
/// manager. Over TCP the endpoint does not exist.</para>
/// </summary>
public sealed class ShellLogin(TimeProvider? clock = null)
{
public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(60);
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
/// <summary>Codes by their hash — the code itself is not kept, so a memory dump does not hold usable ones.</summary>
private readonly ConcurrentDictionary<string, DateTimeOffset> _open = new();
public string Issue()
{
Sweep();
var code = Base64Url(RandomNumberGenerator.GetBytes(32));
_open[Hash(code)] = _clock.GetUtcNow() + Lifetime;
return code;
}
/// <summary>
/// True once for a code that was issued and has not expired. The code is gone afterwards
/// either way: a second use, or a use after expiry, fails.
/// </summary>
public bool Redeem(string? code)
{
if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
return false;
return _open.TryRemove(Hash(code.Trim()), out var expires) && _clock.GetUtcNow() <= expires;
}
private void Sweep()
{
var now = _clock.GetUtcNow();
foreach (var (key, expires) in _open)
{
if (expires < now) _open.TryRemove(key, out _);
}
}
private static string Hash(string code)
=> Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(code)));
private static string Base64Url(byte[] bytes)
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// #764: Marks connections that came in over the manager's local socket — the same pattern as
/// the instance's <c>LocalTransport</c>: a mark set by the listener, not a guess from a missing
/// IP address.
/// </summary>
public sealed class ShellTransport
{
private static readonly ShellTransport Instance = new();
private ShellTransport() { }
/// <summary>The only paths the socket serves. Everything else stays behind the token.</summary>
public const string PathPrefix = "/api/shell";
public static Func<ConnectionDelegate, ConnectionDelegate> Mark
=> next => connection =>
{
connection.Features.Set(Instance);
return next(connection);
};
public static bool Applies(HttpContext context)
=> context.Features.Get<ShellTransport>() is not null;
}