challenges: prevent unbounded growth of stored cookies by bundling all state onto a single JWT token

This commit is contained in:
WeebDataHoarder
2025-05-03 17:30:39 +02:00
parent 2cb5972371
commit 0e62f80f9b
19 changed files with 273 additions and 177 deletions

View File

@@ -29,6 +29,7 @@ func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Reques
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Connection", "close") w.Header().Set("Connection", "close")
data.ResponseHeaders(w)
w.WriteHeader(a.Code) w.WriteHeader(a.Code)
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error())) _, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))

View File

@@ -42,6 +42,8 @@ type CodeSettings struct {
type Code int type Code int
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) { func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
challenge.RequestDataFromContext(r.Context()).ResponseHeaders(w)
w.WriteHeader(int(a)) w.WriteHeader(int(a))
return false, nil return false, nil
} }

View File

@@ -2,7 +2,6 @@ package cookie
import ( import (
"git.gammaspectra.live/git/go-away/lib/challenge" "git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/ast"
"net/http" "net/http"
"time" "time"
@@ -18,18 +17,15 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
reg.Class = challenge.ClassBlocking reg.Class = challenge.ClassBlocking
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult { reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true) data := challenge.RequestDataFromContext(r.Context())
if err != nil { data.IssueChallengeToken(reg, key, nil, expiry, true)
return challenge.VerifyResultFail
}
utils.SetCookie(challenge.RequestDataFromContext(r.Context()).CookiePrefix+reg.Name, token, expiry, w, r)
uri, err := challenge.RedirectUrl(r, reg) uri, err := challenge.RedirectUrl(r, reg)
if err != nil { if err != nil {
return challenge.VerifyResultFail return challenge.VerifyResultFail
} }
data.ResponseHeaders(w)
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect) http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
return challenge.VerifyResultNone return challenge.VerifyResultNone
} }

View File

@@ -1,6 +1,7 @@
package challenge package challenge
import ( import (
"bytes"
http_cel "codeberg.org/gone/http-cel" http_cel "codeberg.org/gone/http-cel"
"context" "context"
"crypto/rand" "crypto/rand"
@@ -9,10 +10,13 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.gammaspectra.live/git/go-away/utils" "git.gammaspectra.live/git/go-away/utils"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/traits" "github.com/google/cel-go/common/types/traits"
"maps" "maps"
unsaferand "math/rand/v2"
"net/http" "net/http"
"net/netip" "net/netip"
"net/textproto" "net/textproto"
@@ -42,9 +46,13 @@ type RequestData struct {
Time time.Time Time time.Time
ChallengeVerify map[Id]VerifyResult ChallengeVerify map[Id]VerifyResult
ChallengeState map[Id]VerifyState ChallengeState map[Id]VerifyState
ChallengeMap TokenChallengeMap
challengeMapModified bool
RemoteAddress netip.AddrPort RemoteAddress netip.AddrPort
State StateInterface State StateInterface
CookiePrefix string cookieName string
issuedChallenge string
ExtraHeaders http.Header ExtraHeaders http.Header
@@ -84,6 +92,11 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
} }
q := r.URL.Query() q := r.URL.Query()
if q.Has(QueryArgChallenge) {
data.issuedChallenge = q.Get(QueryArgChallenge)
}
// delete query parameters that were set by go-away // delete query parameters that were set by go-away
for k := range q { for k := range q {
if strings.HasPrefix(k, QueryArgPrefix) { if strings.HasPrefix(k, QueryArgPrefix) {
@@ -95,19 +108,12 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header)) data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
data.opts = make(map[string]string) data.opts = make(map[string]string)
sum := sha256.New()
sum.Write([]byte(r.Host))
sum.Write([]byte{0})
sum.Write(data.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
sum.Write(state.PublicKey())
sum.Write([]byte{0})
data.CookiePrefix = utils.CookiePrefix + hex.EncodeToString(sum.Sum(nil)[:6]) + "-"
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data)) r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
r = utils.SetRemoteAddress(r, data.RemoteAddress) r = utils.SetRemoteAddress(r, data.RemoteAddress)
data.r = r data.r = r
data.cookieName = utils.DefaultCookiePrefix + hex.EncodeToString(data.cookieHostKey()) + "-state"
return r, &data return r, &data
} }
@@ -194,18 +200,70 @@ func (d *RequestData) BackendHost() (http.Handler, string) {
return d.State.GetBackend(host), host return d.State.GetBackend(host), host
} }
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) { func (d *RequestData) ClearChallengeToken(reg *Registration) {
q := r.URL.Query() delete(d.ChallengeMap, reg.Name)
var issuedChallenge string d.challengeMapModified = true
if q.Has(QueryArgChallenge) { }
issuedChallenge = q.Get(QueryArgChallenge)
func (d *RequestData) IssueChallengeToken(reg *Registration, key Key, result []byte, until time.Time, ok bool) {
d.ChallengeMap[reg.Name] = TokenChallenge{
Key: key[:],
Result: result,
Ok: ok,
Expiry: jwt.NumericDate(until.Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
} }
for _, reg := range d.State.GetChallenges() { d.challengeMapModified = true
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r) }
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
var ErrTokenExpired = errors.New("token: expired")
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
if token.Expiry.Time().Compare(time.Now()) < 0 {
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
}
if token.NotBefore.Time().Compare(time.Now()) > 0 {
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
}
if bytes.Compare(expectedKey[:], token.Key) != 0 {
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
}
if reg.Verify != nil {
if unsaferand.Float64() < reg.VerifyProbability {
// random spot check
if ok, err := reg.Verify(expectedKey, token.Result, d.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 !token.Ok {
return VerifyResultNotOK, VerifyStateBrief, nil
}
return VerifyResultOK, VerifyStateBrief, nil
}
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
token, ok := d.ChallengeMap[reg.Name]
if !ok {
verifyResult = VerifyResultFail
verifyState = VerifyStateNone
} else {
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
if err != nil && !errors.Is(err, http.ErrNoCookie) { if err != nil && !errors.Is(err, http.ErrNoCookie) {
// clear invalid cookie // clear invalid state
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r) d.ClearChallengeToken(reg)
} }
// prevent evaluating the challenge if not solved // prevent evaluating the challenge if not solved
@@ -213,20 +271,46 @@ func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request)
out, _, err := reg.Condition.Eval(d) out, _, err := reg.Condition.Eval(d)
// verify eligibility // verify eligibility
if err != nil { if err != nil {
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name) d.State.Logger(d.r).Error(err.Error(), "challenge", reg.Name)
} else if out != nil && out.Type() == types.BoolType { } else if out != nil && out.Type() == types.BoolType {
if out.Equal(types.True) != types.True { if out.Equal(types.True) != types.True {
// skip challenge match due to precondition! // skip challenge match due to precondition!
verifyResult = VerifyResultSkip verifyResult = VerifyResultSkip
continue return verifyResult, verifyState, err
}
} }
} }
} }
if !verifyResult.Ok() && issuedChallenge == reg.Name { if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
// we issued the challenge, must skip to prevent loops // we issued the challenge, must skip to prevent loops
verifyResult = VerifyResultSkip verifyResult = VerifyResultSkip
} }
return verifyResult, verifyState, err
}
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
challengeMap, err := d.verifyChallengeState()
if err != nil {
if !errors.Is(err, http.ErrNoCookie) {
//clear invalid cookie and continue
utils.ClearCookie(d.cookieName, w, r)
}
challengeMap = make(TokenChallengeMap)
}
d.ChallengeMap = challengeMap
for _, reg := range d.State.GetChallenges() {
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
if err != nil {
// clear invalid state
d.ClearChallengeToken(reg)
}
d.ChallengeVerify[reg.Id()] = verifyResult d.ChallengeVerify[reg.Id()] = verifyResult
d.ChallengeState[reg.Id()] = verifyState d.ChallengeState[reg.Id()] = verifyState
} }
@@ -240,13 +324,22 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
return d.ChallengeVerify[id].Ok() return d.ChallengeVerify[id].Ok()
} }
func (d *RequestData) ResponseHeaders(headers http.Header) { func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
// send these to client so we consistently get the headers // send these to client so we consistently get the headers
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform") //w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform") //w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
if d.State.Settings().MainName != "" { if d.State.Settings().MainName != "" {
headers.Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion)) w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
}
if d.challengeMapModified {
expiration := d.Expiration(DefaultDuration)
if token, err := d.issueChallengeState(expiration); err == nil {
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
} else {
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
}
} }
} }
@@ -282,3 +375,111 @@ func (d *RequestData) RequestHeaders(headers http.Header) {
maps.Copy(headers, d.ExtraHeaders) maps.Copy(headers, d.ExtraHeaders)
} }
type Token struct {
State TokenChallengeMap `json:"state"`
Expiry jwt.NumericDate `json:"exp,omitempty"`
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
}
type TokenChallengeMap map[string]TokenChallenge
type TokenChallenge struct {
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 (d *RequestData) verifyChallengeState() (TokenChallengeMap, error) {
cookie, err := d.r.Cookie(d.cookieName)
if err != nil {
return nil, err
}
if cookie == nil {
return nil, http.ErrNoCookie
}
encryptedToken, err := jwt.ParseSignedAndEncrypted(cookie.Value,
[]jose.KeyAlgorithm{jose.DIRECT},
[]jose.ContentEncryption{jose.A256GCM},
[]jose.SignatureAlgorithm{jose.EdDSA},
)
if err != nil {
return nil, err
}
signedToken, err := encryptedToken.Decrypt(d.cookieKey())
if err != nil {
return nil, err
}
var i Token
err = signedToken.Claims(d.State.PublicKey(), &i)
if err != nil {
return nil, err
}
if i.Expiry.Time().Compare(time.Now()) < 0 {
return nil, ErrTokenExpired
}
if i.NotBefore.Time().Compare(time.Now()) > 0 {
return nil, errors.New("token not valid yet")
}
return i.State, nil
}
func (d *RequestData) issueChallengeState(until time.Time) (string, error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.EdDSA,
Key: d.State.PrivateKey(),
}, nil)
if err != nil {
return "", err
}
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
Algorithm: jose.DIRECT,
Key: d.cookieKey(),
}, (&jose.EncrypterOptions{
Compression: jose.DEFLATE,
}).WithContentType("JWT"))
if err != nil {
return "", err
}
return jwt.SignedAndEncrypted(signer, encrypter).Claims(Token{
State: d.ChallengeMap,
Expiry: jwt.NumericDate(until.Unix()),
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
}).Serialize()
}
func (d *RequestData) cookieKey() []byte {
sum := sha256.New()
sum.Write([]byte(d.r.Host))
sum.Write([]byte{0})
sum.Write(d.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
sum.Write(d.State.PrivateKey())
sum.Write([]byte{0})
// version/compressor
sum.Write([]byte("1.0/DEFLATE"))
sum.Write([]byte{0})
return sum.Sum(nil)
}
func (d *RequestData) cookieHostKey() []byte {
sum := sha256.New()
sum.Write([]byte(d.r.Host))
sum.Write([]byte{0})
sum.Write(d.NetworkPrefix().AsSlice())
sum.Write([]byte{0})
return sum.Sum(nil)[:6]
}

View File

@@ -125,18 +125,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
} }
if result.Bad() { if result.Bad() {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false) data.IssueChallengeToken(reg, key, nil, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK return challenge.VerifyResultNotOK
} else { } else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true) data.IssueChallengeToken(reg, key, nil, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK return challenge.VerifyResultOK
} }
} }

View File

@@ -138,6 +138,8 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
} }
reqUri.RawQuery = q.Encode() reqUri.RawQuery = q.Encode()
data.ResponseHeaders(w)
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect) http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
return return
} }
@@ -147,6 +149,7 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect) state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
return return
} }
data.ResponseHeaders(w)
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect) http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
} }
@@ -175,18 +178,12 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
if err != nil { if err != nil {
return err return err
} else if !verifyResult.Ok() { } else if !verifyResult.Ok() {
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
state.ChallengeFailed(r, reg, nil, redirect, nil) state.ChallengeFailed(r, reg, nil, redirect, nil)
responseFunc(state, data, w, r, verifyResult, nil, redirect) responseFunc(state, data, w, r, verifyResult, nil, redirect)
return nil return nil
} }
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true) data.IssueChallengeToken(reg, key, []byte(token), expiration, true)
if err != nil {
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
} else {
utils.SetCookie(data.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
}
data.ChallengeVerify[reg.id] = verifyResult data.ChallengeVerify[reg.id] = verifyResult
state.ChallengePassed(r, reg, redirect, nil) state.ChallengePassed(r, reg, redirect, nil)
@@ -194,7 +191,6 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
return nil return nil
}() }()
if err != nil { if err != nil {
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
state.ChallengeFailed(r, reg, err, redirect, nil) state.ChallengeFailed(r, reg, err, redirect, nil)
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect) responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
return return

View File

@@ -5,7 +5,6 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"git.gammaspectra.live/git/go-away/lib/challenge" "git.gammaspectra.live/git/go-away/lib/challenge"
"git.gammaspectra.live/git/go-away/utils"
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/ast"
"io" "io"
@@ -140,18 +139,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
data := challenge.RequestDataFromContext(r.Context()) data := challenge.RequestDataFromContext(r.Context())
if response.StatusCode != params.HttpCode { if response.StatusCode != params.HttpCode {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false) data.IssueChallengeToken(reg, key, sum, expiry, false)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultNotOK return challenge.VerifyResultNotOK
} else { } else {
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true) data.IssueChallengeToken(reg, key, sum, expiry, true)
if err != nil {
return challenge.VerifyResultFail
}
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
return challenge.VerifyResultOK return challenge.VerifyResultOK
} }
} }

View File

@@ -66,7 +66,7 @@ func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until ti
hasher.Write([]byte{0}) hasher.Write([]byte{0})
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix()) _ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
hasher.Write([]byte{0}) hasher.Write([]byte{0})
hasher.Write(state.PublicKey()) hasher.Write(state.PrivateKeyFingerprint())
hasher.Write([]byte{0}) hasher.Write([]byte{0})
sum := Key(hasher.Sum(nil)) sum := Key(hasher.Sum(nil))

View File

@@ -110,6 +110,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
} }
verifyResult, _ := verifier(key, []byte(token), r) verifyResult, _ := verifier(key, []byte(token), r)
data.ResponseHeaders(w)
if !verifyResult.Ok() { if !verifyResult.Ok() {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
} else { } else {

View File

@@ -1,18 +1,12 @@
package challenge package challenge
import ( import (
"bytes"
http_cel "codeberg.org/gone/http-cel" http_cel "codeberg.org/gone/http-cel"
"crypto/ed25519"
"errors"
"fmt" "fmt"
"git.gammaspectra.live/git/go-away/lib/policy" "git.gammaspectra.live/git/go-away/lib/policy"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/goccy/go-yaml/ast" "github.com/goccy/go-yaml/ast"
"github.com/google/cel-go/cel" "github.com/google/cel-go/cel"
"io" "io"
"math/rand/v2"
"net/http" "net/http"
"path" "path"
"strings" "strings"
@@ -145,104 +139,10 @@ type Registration struct {
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error) 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 { func (reg Registration) Id() Id {
return reg.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(RequestDataFromContext(r.Context()).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 type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
var Runtimes = make(map[string]FillRegistration) var Runtimes = make(map[string]FillRegistration)

View File

@@ -45,6 +45,9 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
//TODO: add other types inside css that need to be loaded! //TODO: add other types inside css that need to be loaded!
w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Content-Length", "0") w.Header().Set("Content-Length", "0")
data.ResponseHeaders(w)
if !verifyResult.Ok() { if !verifyResult.Ok() {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
} else { } else {

View File

@@ -23,7 +23,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
//TODO: log //TODO: log
panic(err) panic(err)
} }
data.ResponseHeaders(w)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
err = scriptTemplate.Execute(w, map[string]any{ err = scriptTemplate.Execute(w, map[string]any{

View File

@@ -89,6 +89,7 @@ type StateInterface interface {
RegisterCondition(operator string, conditions ...string) (cel.Program, error) RegisterCondition(operator string, conditions ...string) (cel.Program, error)
Client() *http.Client Client() *http.Client
PrivateKeyFingerprint() []byte
PrivateKey() ed25519.PrivateKey PrivateKey() ed25519.PrivateKey
PublicKey() ed25519.PublicKey PublicKey() ed25519.PublicKey

View File

@@ -267,10 +267,13 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
cookies := r.Cookies() cookies := r.Cookies()
r.Header.Del("Cookie") r.Header.Del("Cookie")
for _, c := range cookies { for _, c := range cookies {
if !strings.HasPrefix(c.Name, utils.CookiePrefix) { if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
r.AddCookie(c) r.AddCookie(c)
} }
} }
// set response headers
data.ResponseHeaders(w)
} }
for _, rule := range state.rules { for _, rule := range state.rules {
@@ -323,7 +326,5 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
data.EvaluateChallenges(w, r) data.EvaluateChallenges(w, r)
data.ResponseHeaders(w.Header())
state.Mux.ServeHTTP(w, r) state.Mux.ServeHTTP(w, r)
} }

View File

@@ -26,6 +26,10 @@ func (state *State) PrivateKey() ed25519.PrivateKey {
return state.privateKey return state.privateKey
} }
func (state *State) PrivateKeyFingerprint() []byte {
return state.privateKeyFingerprint
}
func (state *State) PublicKey() ed25519.PublicKey { func (state *State) PublicKey() ed25519.PublicKey {
return state.publicKey return state.publicKey
} }

View File

@@ -29,7 +29,6 @@ type RuleState struct {
} }
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) { func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
fp := sha256.Sum256(state.PrivateKey())
hasher := sha256.New() hasher := sha256.New()
if parent != nil { if parent != nil {
hasher.Write([]byte(parent.Name)) hasher.Write([]byte(parent.Name))
@@ -38,7 +37,7 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
} }
hasher.Write([]byte(r.Name)) hasher.Write([]byte(r.Name))
hasher.Write([]byte{0}) hasher.Write([]byte{0})
hasher.Write(fp[:]) hasher.Write(state.PrivateKeyFingerprint())
sum := hasher.Sum(nil) sum := hasher.Sum(nil)
rule := RuleState{ rule := RuleState{

View File

@@ -37,6 +37,7 @@ type State struct {
publicKey ed25519.PublicKey publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey privateKey ed25519.PrivateKey
privateKeyFingerprint []byte
opt settings.Settings opt settings.Settings
settings policy.StateSettings settings policy.StateSettings
@@ -101,6 +102,9 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
} }
} }
fp := sha256.Sum256(state.privateKey)
state.privateKeyFingerprint = fp[:]
if templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"] == nil { if templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"] == nil {
if data, err := os.ReadFile(state.opt.ChallengeTemplate); err == nil && len(data) > 0 { if data, err := os.ReadFile(state.opt.ChallengeTemplate); err == nil && len(data) > 0 {

View File

@@ -107,6 +107,7 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
if err != nil { if err != nil {
state.ErrorPage(w, r, http.StatusInternalServerError, err, "") state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
} else { } else {
data.ResponseHeaders(w)
w.WriteHeader(status) w.WriteHeader(status)
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }
@@ -141,6 +142,7 @@ func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int
// nested errors! // nested errors!
panic(err2) panic(err2)
} else { } else {
data.ResponseHeaders(w)
w.WriteHeader(status) w.WriteHeader(status)
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }

View File

@@ -6,7 +6,7 @@ import (
"time" "time"
) )
var CookiePrefix = ".go-away-" var DefaultCookiePrefix = ".go-away-"
// getValidHost Gets a valid host for an http.Cookie Domain field // getValidHost Gets a valid host for an http.Cookie Domain field
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521 // TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521