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>
89 lines
3.2 KiB
C#
89 lines
3.2 KiB
C#
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;
|
|
}
|