summaryrefslogtreecommitdiff
path: root/internal/middleware
diff options
context:
space:
mode:
Diffstat (limited to 'internal/middleware')
-rw-r--r--internal/middleware/nollamas.go240
-rw-r--r--internal/middleware/nollamas_test.go33
2 files changed, 163 insertions, 110 deletions
diff --git a/internal/middleware/nollamas.go b/internal/middleware/nollamas.go
index 914469a24..6191ebe3b 100644
--- a/internal/middleware/nollamas.go
+++ b/internal/middleware/nollamas.go
@@ -26,6 +26,7 @@ import (
"hash"
"io"
"net/http"
+ "strconv"
"time"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
@@ -35,6 +36,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/oauth"
+ "codeberg.org/gruf/go-bitutil"
"codeberg.org/gruf/go-byteutil"
"github.com/gin-gonic/gin"
)
@@ -60,49 +62,79 @@ func NoLLaMas(
return func(*gin.Context) {}
}
- seed := make([]byte, 32)
+ var seed [32]byte
// Read random data for the token seed.
- _, err := io.ReadFull(rand.Reader, seed)
+ _, err := io.ReadFull(rand.Reader, seed[:])
if err != nil {
panic(err)
}
// Configure nollamas.
var nollamas nollamas
- nollamas.seed = seed
+ nollamas.entropy = seed
nollamas.ttl = time.Hour
- nollamas.diff = config.GetAdvancedScraperDeterrenceDifficulty()
+ nollamas.rounds = config.GetAdvancedScraperDeterrenceDifficulty()
nollamas.getInstanceV1 = getInstanceV1
nollamas.policy = cookiePolicy
return nollamas.Serve
}
+// i.e. hash slice length.
+const hashLen = sha256.Size
+
+// i.e. hex.EncodedLen(hashLen).
+const encodedHashLen = 2 * hashLen
+
// hashWithBufs encompasses a hash along
// with the necessary buffers to generate
// a hashsum and then encode that sum.
type hashWithBufs struct {
hash hash.Hash
- hbuf []byte
- ebuf []byte
+ hbuf [hashLen]byte
+ ebuf [encodedHashLen]byte
+}
+
+// write is a passthrough to hash.Hash{}.Write().
+func (h *hashWithBufs) write(b []byte) {
+ _, _ = h.hash.Write(b)
+}
+
+// writeString is a passthrough to hash.Hash{}.Write([]byte(s)).
+func (h *hashWithBufs) writeString(s string) {
+ _, _ = h.hash.Write(byteutil.S2B(s))
+}
+
+// EncodedSum returns the hex encoded sum of hash.Sum().
+func (h *hashWithBufs) EncodedSum() string {
+ _ = h.hash.Sum(h.hbuf[:0])
+ hex.Encode(h.ebuf[:], h.hbuf[:])
+ return string(h.ebuf[:])
+}
+
+// Reset will reset hash and buffers.
+func (h *hashWithBufs) Reset() {
+ h.ebuf = [encodedHashLen]byte{}
+ h.hbuf = [hashLen]byte{}
+ h.hash.Reset()
}
type nollamas struct {
// our instance cookie policy.
policy apiutil.CookiePolicy
- // unique token seed
+ // unique entropy
// to prevent hashes
// being guessable
- seed []byte
+ entropy [32]byte
// success cookie TTL
ttl time.Duration
- // algorithm difficulty knobs.
- // diff determines the number
- // of leading zeroes required.
- diff uint8
+ // rounds determines roughly how
+ // many hash-encode rounds each
+ // client is required to complete.
+ rounds uint32
// extra fields required for
// our template rendering.
@@ -134,18 +166,8 @@ func (m *nollamas) Serve(c *gin.Context) {
return
}
- // i.e. outputted hash slice length.
- const hashLen = sha256.Size
-
- // i.e. hex.EncodedLen(hashLen).
- const encodedHashLen = 2 * hashLen
-
- // Prepare hash + buffers.
- hash := hashWithBufs{
- hash: sha256.New(),
- hbuf: make([]byte, 0, hashLen),
- ebuf: make([]byte, encodedHashLen),
- }
+ // Prepare new hash with buffers.
+ hash := hashWithBufs{hash: sha256.New()}
// Extract client fingerprint data.
userAgent := c.GetHeader("User-Agent")
@@ -153,15 +175,7 @@ func (m *nollamas) Serve(c *gin.Context) {
// Generate a unique token for this request,
// only valid for a period of now +- m.ttl.
- token := m.token(&hash, userAgent, clientIP)
-
- // For unique challenge string just use a
- // single portion of their 'success' token.
- // SHA256 is not yet cracked, this is not an
- // application of a hash requiring serious
- // cryptographic security and it rotates on
- // a TTL basis, so it should be fine.
- challenge := token[:len(token)/4]
+ token := m.getToken(&hash, userAgent, clientIP)
// Check for a provided success token.
cookie, _ := c.Cookie("gts-nollamas")
@@ -169,8 +183,8 @@ func (m *nollamas) Serve(c *gin.Context) {
// Check whether passed cookie
// is the expected success token.
if subtle.ConstantTimeCompare(
- byteutil.S2B(token),
byteutil.S2B(cookie),
+ byteutil.S2B(token),
) == 1 {
// They passed us a valid, expected
@@ -185,10 +199,15 @@ func (m *nollamas) Serve(c *gin.Context) {
// handlers from being called.
c.Abort()
+ // Generate challenge for this unique (yet deterministic) token,
+ // returning seed, wanted 'challenge' result and expected solution.
+ seed, challenge, solution := m.getChallenge(&hash, token)
+
// Prepare new log entry.
l := log.WithContext(ctx).
WithField("userAgent", userAgent).
- WithField("challenge", challenge)
+ WithField("seed", seed).
+ WithField("rounds", solution)
// Extract and parse query.
query := c.Request.URL.Query()
@@ -196,32 +215,28 @@ func (m *nollamas) Serve(c *gin.Context) {
// Check query to see if an in-progress
// challenge solution has been provided.
nonce := query.Get("nollamas_solution")
- if nonce == "" || len(nonce) > 20 {
+ if nonce == "" {
- // noting that here, 20 is
- // max integer string len.
- //
- // An invalid solution string, just
- // present them with new challenge.
+ // No solution given, likely new client!
+ // Simply present them with challenge.
+ m.renderChallenge(c, seed, challenge)
l.Info("posing new challenge")
- m.renderChallenge(c, challenge)
return
}
- // Reset the hash.
- hash.hash.Reset()
-
- // Check challenge+nonce as possible solution.
- if !m.checkChallenge(&hash, challenge, nonce) {
+ // Check nonce matches expected.
+ if subtle.ConstantTimeCompare(
+ byteutil.S2B(solution),
+ byteutil.S2B(nonce),
+ ) != 1 {
- // They failed challenge,
- // re-present challenge page.
- l.Info("invalid solution provided")
- m.renderChallenge(c, challenge)
+ // Their nonce failed, re-challenge them.
+ m.renderChallenge(c, challenge, solution)
+ l.Infof("invalid solution provided: %s", nonce)
return
}
- l.Infof("challenge passed: %s", nonce)
+ l.Info("challenge passed")
// Drop solution query and encode.
query.Del("nollamas_solution")
@@ -233,7 +248,7 @@ func (m *nollamas) Serve(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, c.Request.URL.RequestURI())
}
-func (m *nollamas) renderChallenge(c *gin.Context, challenge string) {
+func (m *nollamas) renderChallenge(c *gin.Context, seed, challenge string) {
// Fetch current instance information for templating vars.
instance, errWithCode := m.getInstanceV1(c.Request.Context())
if errWithCode != nil {
@@ -252,8 +267,8 @@ func (m *nollamas) renderChallenge(c *gin.Context, challenge string) {
"/assets/Fork-Awesome/css/fork-awesome.min.css",
},
Extra: map[string]any{
- "challenge": challenge,
- "difficulty": m.diff,
+ "seed": seed,
+ "challenge": challenge,
},
Javascript: []apiutil.JavascriptEntry{
{
@@ -264,23 +279,25 @@ func (m *nollamas) renderChallenge(c *gin.Context, challenge string) {
})
}
-func (m *nollamas) token(hash *hashWithBufs, userAgent, clientIP string) string {
- // Use our unique seed to seed hash,
+// getToken generates a unique yet deterministic token for given HTTP request
+// details, seeded by runtime generated entropy data and ttl rounded timestamp.
+func (m *nollamas) getToken(hash *hashWithBufs, userAgent, clientIP string) string {
+
+ // Reset before
+ // using hash.
+ hash.Reset()
+
+ // Use our unique entropy to seed hash,
// to ensure we have cryptographically
// unique, yet deterministic, tokens
// generated for a given http client.
- hash.hash.Write(m.seed)
-
- // Include difficulty level in
- // hash input data so if config
- // changes then token invalidates.
- hash.hash.Write([]byte{m.diff})
+ hash.write(m.entropy[:])
// Also seed the generated input with
// current time rounded to TTL, so our
// single comparison handles expiries.
now := time.Now().Round(m.ttl).Unix()
- hash.hash.Write([]byte{
+ hash.write([]byte{
byte(now >> 56),
byte(now >> 48),
byte(now >> 40),
@@ -291,37 +308,78 @@ func (m *nollamas) token(hash *hashWithBufs, userAgent, clientIP string) string
byte(now),
})
- // Finally, append unique client request data.
- hash.hash.Write(byteutil.S2B(userAgent))
- hash.hash.Write(byteutil.S2B(clientIP))
+ // Append client request data.
+ hash.writeString(userAgent)
+ hash.writeString(clientIP)
- // Return hex encoded hash output.
- hash.hbuf = hash.hash.Sum(hash.hbuf[:0])
- hex.Encode(hash.ebuf, hash.hbuf)
- return string(hash.ebuf)
+ // Return hex encoded hash.
+ return hash.EncodedSum()
}
-func (m *nollamas) checkChallenge(hash *hashWithBufs, challenge, nonce string) bool {
- // Hash and encode input challenge with
- // proposed nonce as a possible solution.
- hash.hash.Write(byteutil.S2B(challenge))
- hash.hash.Write(byteutil.S2B(nonce))
- hash.hbuf = hash.hash.Sum(hash.hbuf[:0])
- hex.Encode(hash.ebuf, hash.hbuf)
- solution := hash.ebuf
-
- // Compiler bound-check hint.
- if len(solution) < int(m.diff) {
- panic(gtserror.New("BCE"))
- }
+// getChallenge prepares a new challenge given the deterministic input token for this request.
+// it will return an input seed string, a challenge string which is the end result the client
+// should be looking for, and the solution for this such that challenge = hex(sha256(seed + solution)).
+// the solution will always be a string-encoded 64bit integer calculated from m.rounds + random jitter.
+func (m *nollamas) getChallenge(hash *hashWithBufs, token string) (seed, challenge, solution string) {
- // Check that the first 'diff'
- // many chars are indeed zeroes.
- for i := range m.diff {
- if solution[i] != '0' {
- return false
- }
+ // For their unique seed string just use a
+ // single portion of their 'success' token.
+ // SHA256 is not yet cracked, this is not an
+ // application of a hash requiring serious
+ // cryptographic security and it rotates on
+ // a TTL basis, so it should be fine.
+ seed = token[:len(token)/4]
+
+ // BEFORE resetting the hash, get the last
+ // two bytes of NON-hex-encoded data from
+ // token generation to use for random jitter.
+ // This is taken from the end of the hash as
+ // this is the "unseen" end part of token.
+ //
+ // (if we used hex-encoded data it would
+ // only ever be '0-9' or 'a-z' ASCII chars).
+ //
+ // Security-wise, same applies as-above.
+ jitter := int16(hash.hbuf[len(hash.hbuf)-2]) |
+ int16(hash.hbuf[len(hash.hbuf)-1])<<8
+
+ var rounds int64
+ switch {
+ // For some small percentage of
+ // clients we purposely low-ball
+ // their rounds required, to make
+ // it so gaming it with a starting
+ // nonce value may suddenly fail.
+ case jitter%37 == 0:
+ rounds = int64(m.rounds/10) + int64(jitter/10)
+ case jitter%31 == 0:
+ rounds = int64(m.rounds/5) + int64(jitter/5)
+ case jitter%29 == 0:
+ rounds = int64(m.rounds/3) + int64(jitter/3)
+ case jitter%13 == 0:
+ rounds = int64(m.rounds/2) + int64(jitter/2)
+
+ // Determine an appropriate number of hash rounds
+ // we want the client to perform on input seed. This
+ // is determined as configured m.rounds +- jitter.
+ // This will be the 'solution' to create 'challenge'.
+ default:
+ rounds = int64(m.rounds) + int64(jitter) //nolint:gosec
}
- return true
+ // Encode (positive) determined hash rounds as string.
+ solution = strconv.FormatInt(bitutil.Abs64(rounds), 10)
+
+ // Reset before
+ // using hash.
+ hash.Reset()
+
+ // Calculate the expected result
+ // of hex(sha256(seed + solution)),
+ // i.e. the proposed 'challenge'.
+ hash.writeString(seed)
+ hash.writeString(solution)
+ challenge = hash.EncodedSum()
+
+ return
}
diff --git a/internal/middleware/nollamas_test.go b/internal/middleware/nollamas_test.go
index ffe8cbbc8..f6b8e0e02 100644
--- a/internal/middleware/nollamas_test.go
+++ b/internal/middleware/nollamas_test.go
@@ -95,41 +95,39 @@ func testNoLLaMasMiddleware(t *testing.T, e *gin.Engine, userAgent string) {
panic(err)
}
+ var seed string
var challenge string
- var difficulty uint64
// Parse output body and find the challenge / difficulty.
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
switch {
+ case strings.HasPrefix(line, "data-nollamas-seed=\""):
+ line = line[20:]
+ line = line[:len(line)-1]
+ seed = line
case strings.HasPrefix(line, "data-nollamas-challenge=\""):
line = line[25:]
line = line[:len(line)-1]
challenge = line
- case strings.HasPrefix(line, "data-nollamas-difficulty=\""):
- line = line[26:]
- line = line[:len(line)-1]
- var err error
- difficulty, err = strconv.ParseUint(line, 10, 8)
- assert.NoError(t, err)
}
}
// Ensure valid posed challenge.
- assert.NotZero(t, difficulty)
assert.NotEmpty(t, challenge)
+ assert.NotEmpty(t, seed)
// Prepare a test request for gin engine.
r = httptest.NewRequest("GET", "/", nil)
r.Header.Set("User-Agent", userAgent)
rw = httptest.NewRecorder()
+ t.Logf("seed=%s", seed)
+ t.Logf("challenge=%s", challenge)
+
// Now compute and set solution query paramater.
- solution := computeSolution(challenge, difficulty)
+ solution := computeSolution(seed, challenge)
r.URL.RawQuery = "nollamas_solution=" + solution
-
- t.Logf("challenge=%s", challenge)
- t.Logf("difficulty=%d", difficulty)
t.Logf("solution=%s", solution)
// Pass req through
@@ -152,17 +150,14 @@ func testNoLLaMasMiddleware(t *testing.T, e *gin.Engine, userAgent string) {
}
// computeSolution does the functional equivalent of our nollamas workerTask.js.
-func computeSolution(challenge string, diff uint64) string {
-outer:
+func computeSolution(seed, challenge string) string {
for i := 0; ; i++ {
solution := strconv.Itoa(i)
- combined := challenge + solution
+ combined := seed + solution
hash := sha256.Sum256(byteutil.S2B(combined))
encoded := hex.EncodeToString(hash[:])
- for i := range diff {
- if encoded[i] != '0' {
- continue outer
- }
+ if encoded != challenge {
+ continue
}
return solution
}