Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/AccessToken.cs
T
RichardandClaude Opus 5.5 775c4dbc8e 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>
2026-10-03 18:44:58 +02:00

88 lines
4.3 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Http;
namespace ClawdDotNet.Manager.Web;
/// <summary>
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
///
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> Die Instanz
/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
/// nicht: Alles unter <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
/// werden kann.</para>
///
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
/// In den Dateien steht nichts, was jemand nicht wissen dürfte.</para>
///
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
/// </summary>
public sealed class AccessToken(byte[] expected)
{
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
public const int MinimumLength = 20;
public static AccessToken For(string token)
=> new(Encoding.UTF8.GetBytes(token));
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
public static bool NeedsToken(PathString path)
=> (path.StartsWithSegments("/api") || path.StartsWithSegments("/events"))
// #764: Exchanging a one-time code is the login itself - like the page that asks
// for the token, it cannot require one. The code is the proof (ShellLogin).
&& !path.Equals(LoginExchangePath, StringComparison.OrdinalIgnoreCase);
/// <summary>#764: Where the WebUI trades a one-time code from the shell for the access token.</summary>
public const string LoginExchangePath = "/api/login/exchange";
/// <summary>
/// Holt das Token aus der Anfrage.
///
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
/// keine eigene Baustelle.</para>
///
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
/// davor und entscheidet dort, was protokolliert wird.</para>
/// </summary>
public static string? Read(HttpRequest request)
{
var header = request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
if (request.Path.StartsWithSegments("/events")
&& request.Query.TryGetValue("token", out var fromQuery))
return fromQuery.ToString();
return null;
}
public bool Matches(string? candidate)
{
if (string.IsNullOrEmpty(candidate)) return false;
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
// verglichen.
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate), expected);
}
}