Files
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

110 lines
2.9 KiB
Go

package auth
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Header, in denen die Signatur übertragen wird.
const (
HeaderKey = "X-Sultan-Key"
HeaderTimestamp = "X-Sultan-Timestamp"
HeaderNonce = "X-Sultan-Nonce"
HeaderSignature = "X-Sultan-Signature"
)
// ErrInsecureTransport: Request ohne TLS. Weder wird er signiert noch
// akzeptiert.
var ErrInsecureTransport = errors.New("auth: unverschlüsselte Verbindung abgelehnt")
// signatureVersion trennt die signierten Daten von anderen Ed25519-Nutzungen
// desselben Schlüssels und erlaubt spätere Formatänderungen.
const signatureVersion = "SULTAN-ED25519-V1"
// canonical baut die signierten Daten. Jede Zeile ist eindeutig abgegrenzt;
// der Body geht als SHA-256 ein.
func canonical(method, requestURI, timestamp, nonce string, body []byte) []byte {
sum := sha256.Sum256(body)
return []byte(strings.Join([]string{
signatureVersion, method, requestURI, timestamp, nonce, hex.EncodeToString(sum[:]),
}, "\n"))
}
// Signer signiert Requests des Agenten. Sign passt als stream.Client.Prepare.
type Signer struct {
Key ed25519.PrivateKey
now func() time.Time
}
// Sign signiert r (Methode, Pfad+Query, Zeitstempel, frische Nonce, Body).
// Der Body wird dafür gelesen und wiederhergestellt. Nur https-URLs werden
// signiert.
func (s *Signer) Sign(r *http.Request) error {
if len(s.Key) != ed25519.PrivateKeySize {
return ErrInvalidKey
}
if r.URL == nil || r.URL.Scheme != "https" {
return ErrInsecureTransport
}
body, err := readBody(r, -1)
if err != nil {
return err
}
if r.Body != nil {
r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
r.ContentLength = int64(len(body))
}
now := time.Now
if s.now != nil {
now = s.now
}
ts := strconv.FormatInt(now().Unix(), 10)
nonce := newNonce()
sig := ed25519.Sign(s.Key, canonical(r.Method, r.URL.RequestURI(), ts, nonce, body))
r.Header.Set(HeaderKey, EncodePublicKey(s.Key.Public().(ed25519.PublicKey)))
r.Header.Set(HeaderTimestamp, ts)
r.Header.Set(HeaderNonce, nonce)
r.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig))
return nil
}
func newNonce() string {
var b [16]byte
rand.Read(b[:])
return base64.RawURLEncoding.EncodeToString(b[:])
}
// readBody liest den Body vollständig (limit < 0: unbegrenzt) und ersetzt
// ihn durch eine Kopie, damit nachfolgende Handler ihn erneut lesen können.
func readBody(r *http.Request, limit int64) ([]byte, error) {
if r.Body == nil || r.Body == http.NoBody {
return nil, nil
}
rd := io.Reader(r.Body)
if limit >= 0 {
rd = io.LimitReader(r.Body, limit+1)
}
body, err := io.ReadAll(rd)
r.Body.Close()
if err != nil {
return nil, err
}
if limit >= 0 && int64(len(body)) > limit {
return nil, ErrBodyTooLarge
}
r.Body = io.NopCloser(bytes.NewReader(body))
return body, nil
}