Files
go-away/lib/challenge/register.go
WeebDataHoarder ead41055ca Condition, rules, state and action refactor / rewrite
Add nested rules
Add backend action, allow wildcard in backends
Remove poison from tree, update README with action table

Allow defining pass/fail actions on challenge,

Remove redirect/referer parameters on backend pass

Set challenge cookie tied to host

Rewrite DNSBL condition into a challenge

Allow passing an arbitrary path for assets to js challenges

Optimize programs exhaustively on compilation

Activation instead of map for CEL context, faster map access, new network override

Return valid host on cookie setting in case Host is an IP address.
bug: does not work with IPv6, see https://github.com/golang/go/issues/65521

Apply TLS fingerprinter on GetConfigForClient instead of GetCertificate

Cleanup go-away cookies before passing to backend

Code action for specifically replying with an HTTP code
2025-04-23 20:35:20 +02:00

253 lines
6.6 KiB
Go

package challenge
import (
"bytes"
"crypto/ed25519"
"errors"
"fmt"
"git.gammaspectra.live/git/go-away/lib/condition"
"git.gammaspectra.live/git/go-away/lib/policy"
"git.gammaspectra.live/git/go-away/utils"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/goccy/go-yaml/ast"
"github.com/google/cel-go/cel"
"io"
"math/rand/v2"
"net/http"
"path"
"strings"
"time"
)
type Register map[Id]*Registration
func (r Register) Get(id Id) (*Registration, bool) {
c, ok := r[id]
return c, ok
}
func (r Register) GetByName(name string) (*Registration, Id, bool) {
for id, c := range r {
if c.Name == name {
return c, id, true
}
}
return nil, 0, false
}
var idCounter Id
// DefaultDuration TODO: adjust
const DefaultDuration = time.Hour * 24 * 7
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
runtime, ok := Runtimes[pol.Runtime]
if !ok {
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
}
reg := &Registration{
Name: name,
Path: path.Join(state.UrlPath(), "challenge", name),
Duration: pol.Duration,
}
if reg.Duration == 0 {
reg.Duration = DefaultDuration
}
// allow nesting
var conditions []string
for _, cond := range pol.Conditions {
if replacer != nil {
cond = replacer.Replace(cond)
}
conditions = append(conditions, cond)
}
if len(conditions) > 0 {
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
if err != nil {
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
}
reg.Condition, err = condition.Program(state.ProgramEnv(), ast)
if err != nil {
return nil, 0, fmt.Errorf("error compiling program: %v", err)
}
}
if _, oldId, ok := r.GetByName(reg.Name); ok {
reg.id = oldId
} else {
idCounter++
reg.id = idCounter
}
err := runtime(state, reg, pol.Parameters)
if err != nil {
return nil, 0, fmt.Errorf("error filling registration: %v", err)
}
r[reg.id] = reg
return reg, reg.id, nil
}
func (r Register) Add(c *Registration) Id {
if _, oldId, ok := r.GetByName(c.Name); ok {
c.id = oldId
r[oldId] = c
return oldId
} else {
idCounter++
c.id = idCounter
r[idCounter] = c
return idCounter
}
}
type Registration struct {
// id The assigned internal identifier
id Id
// Name The unique name for this challenge
Name string
// Class whether this challenge is transparent or otherwise
Class Class
// Condition A CEL condition which is passed the same environment as general rules.
// If nil, always true
// If non-nil, must return true for this challenge to be allowed to be executed
Condition cel.Program
// Path The url path that this challenge is hosted under for the Handler to be called.
Path string
// Duration How long this challenge will be valid when passed
Duration time.Duration
// Handler An HTTP handler for all requests coming on the Path
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
// Recommended to use http.ServeMux
Handler http.Handler
// Verify Verify an issued token
Verify VerifyFunc
VerifyProbability float64
// IssueChallenge Issues a challenge to a request.
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
// TODO: have this return error as well
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
// Object used to handle state or similar
// Can be nil if no state is needed
// If non-nil must implement io.Closer even if there's nothing to do
Object io.Closer
}
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
type Token struct {
Name string `json:"name"`
Key []byte `json:"key"`
Result []byte `json:"result,omitempty"`
Ok bool `json:"ok"`
Expiry jwt.NumericDate `json:"exp,omitempty"`
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
}
func (reg Registration) Id() Id {
return reg.id
}
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.EdDSA,
Key: privateKey,
}, nil)
if err != nil {
return "", err
}
token, err = jwt.Signed(signer).Claims(Token{
Name: reg.Name,
Key: key[:],
Result: result,
Ok: ok,
Expiry: jwt.NumericDate(until.Unix()),
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
}).Serialize()
if err != nil {
return "", err
}
return token, nil
}
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
var ErrTokenExpired = errors.New("token: expired")
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
cookie, err := r.Cookie(utils.CookiePrefix + reg.Name)
if err != nil {
return VerifyResultNone, VerifyStateNone, err
}
if cookie == nil {
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
}
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
if err != nil {
return VerifyResultFail, VerifyStateNone, err
}
var i Token
err = token.Claims(publicKey, &i)
if err != nil {
return VerifyResultFail, VerifyStateNone, err
}
if i.Name != reg.Name {
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
}
if i.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
}
if i.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey[:], i.Key) != 0 {
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
}
if reg.Verify != nil {
if rand.Float64() < reg.VerifyProbability {
// random spot check
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
return VerifyResultFail, VerifyStateFull, err
} else if ok == VerifyResultNotOK {
return VerifyResultNotOK, VerifyStateFull, nil
} else if !ok.Ok() {
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
} else {
return ok, VerifyStateFull, nil
}
}
}
if !i.Ok {
return VerifyResultNotOK, VerifyStateBrief, nil
}
return VerifyResultOK, VerifyStateBrief, nil
}
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
var Runtimes = make(map[string]FillRegistration)