munin/internal/auth/keys.go

142 lines
4.8 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// KeyPrefix steht vor jedem API-Key.
//
// Zwei Gründe dafür:
// - Man erkennt auf einen Blick, worum es sich handelt, wenn ein Key irgendwo
// in einer Konfigurationsdatei oder einem Chat auftaucht.
// - Werkzeuge zum Aufspüren versehentlich veröffentlichter Secrets (GitHub
// Secret Scanning und ähnliche) suchen nach genau solchen Präfixen.
const KeyPrefix = "wk_"
// WorkspaceAdminPrefix steht vor jedem Token eines Workspace-Admins
// (AUSBAUSTUFEN.md 4.8).
//
// Das eigene Präfix ist mehr als Kosmetik: APIAuth lässt nur zu, was
// LooksLikeKey erkennt. Ein Workspace-Admin-Token scheitert dort schon an der
// Form und kommt so nie an die MCP-Schnittstelle — der Zugang ist ausdrücklich
// nur für die Weboberfläche gedacht.
const WorkspaceAdminPrefix = "wa_"
// keyBytes ist die Länge des Zufallsanteils eines Keys.
// 32 Byte = 256 Bit — weit jenseits von allem, was sich durchprobieren lässt.
const keyBytes = 32
// Role beschreibt, was ein Key darf.
//
// Bewusst nur drei Stufen: mehr Feingranularität kostet Verständlichkeit und
// wird bei einem internen Werkzeug erfahrungsgemäß nie genutzt.
type Role string
const (
// RoleRead darf lesen und suchen — für Agenten, die nur Wissen abrufen sollen.
RoleRead Role = "read"
// RoleWrite darf zusätzlich Einträge anlegen, ändern und löschen.
RoleWrite Role = "write"
// RoleAdmin darf zusätzlich die Keys des eigenen Projekts verwalten.
RoleAdmin Role = "admin"
)
// Valid prüft, ob eine Rolle einen der bekannten Werte hat.
// Wichtig beim Lesen aus der Datenbank: dort steht Text, und Text kann alles sein.
func (r Role) Valid() bool {
switch r {
case RoleRead, RoleWrite, RoleAdmin:
return true
default:
return false
}
}
// CanWrite sagt, ob diese Rolle Einträge verändern darf.
func (r Role) CanWrite() bool {
return r == RoleWrite || r == RoleAdmin
}
// CanManageKeys sagt, ob diese Rolle die Keys des Projekts verwalten darf.
func (r Role) CanManageKeys() bool {
return r == RoleAdmin
}
// GenerateKey erzeugt einen neuen API-Key im Klartext.
//
// Der Klartext existiert nur genau einmal — direkt nach dem Erzeugen wird er dem
// Nutzer angezeigt und danach nirgends gespeichert (siehe HashKey).
func GenerateKey() (string, error) {
return generateWithPrefix(KeyPrefix)
}
// GenerateWorkspaceAdminToken erzeugt das Token eines Workspace-Admins im
// Klartext. Aufbau und Stärke wie bei GenerateKey, nur mit eigenem Präfix —
// siehe WorkspaceAdminPrefix, warum das wichtig ist.
func GenerateWorkspaceAdminToken() (string, error) {
return generateWithPrefix(WorkspaceAdminPrefix)
}
// generateWithPrefix erzeugt keyBytes Zufallsbytes und stellt das Präfix davor.
func generateWithPrefix(prefix string) (string, error) {
buf := make([]byte, keyBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("Zufallszahlen konnten nicht erzeugt werden: %w", err)
}
return prefix + hex.EncodeToString(buf), nil
}
// HashKey bildet den Speicher-Hash eines API-Keys.
//
// # Warum SHA-256 und nicht bcrypt/Argon2?
//
// Für Passwörter wären langsame Hashverfahren richtig: Menschen wählen Passwörter
// aus einem kleinen, erratbaren Raum, und die künstliche Langsamkeit macht das
// Durchprobieren teuer.
//
// Ein API-Key hier ist aber kein Passwort, sondern 256 Bit aus crypto/rand. Diesen
// Raum kann niemand durchsuchen — auch nicht mit beliebig schneller Hardware. Die
// Langsamkeit brächte also keinen Sicherheitsgewinn, würde aber jeden einzelnen
// Request ausbremsen.
//
// Entscheidend ist der zweite Punkt: Weil SHA-256 schnell und deterministisch ist,
// kann der Hash direkt als indizierte Spalte durchsucht werden — ein Lookup statt
// einer Schleife über alle Keys. Die alte Lösung verglich den Key nacheinander mit
// jedem Team; das skaliert nicht und wurde mit jedem neuen Projekt langsamer.
func HashKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// LooksLikeKey prüft die äußere Form eines Keys, bevor die Datenbank befragt wird.
//
// Das ist keine Sicherheitsprüfung, sondern spart einen Datenbankzugriff bei
// offensichtlichem Unsinn (leerer Header, abgeschnittener Key, Tippfehler).
func LooksLikeKey(key string) bool {
return looksLike(key, KeyPrefix)
}
// LooksLikeWorkspaceAdminToken prüft die äußere Form eines
// Workspace-Admin-Tokens — Gegenstück zu LooksLikeKey.
func LooksLikeWorkspaceAdminToken(token string) bool {
return looksLike(token, WorkspaceAdminPrefix)
}
// looksLike prüft Präfix, Länge und Hex-Form.
func looksLike(key, prefix string) bool {
if !strings.HasPrefix(key, prefix) {
return false
}
rest := key[len(prefix):]
if len(rest) != keyBytes*2 {
return false
}
_, err := hex.DecodeString(rest)
return err == nil
}