From 2442c6fc41db880f704fcf0b334a8a8d7a47d9f5 Mon Sep 17 00:00:00 2001 From: kim Date: Tue, 22 Apr 2025 16:06:51 +0100 Subject: [PATCH] start adding proof of work middleware --- internal/middleware/challenge.html | 158 ++++++++++++++++++++ internal/middleware/nollamas.go | 227 +++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 internal/middleware/challenge.html create mode 100644 internal/middleware/nollamas.go diff --git a/internal/middleware/challenge.html b/internal/middleware/challenge.html new file mode 100644 index 000000000..e2ae3fe08 --- /dev/null +++ b/internal/middleware/challenge.html @@ -0,0 +1,158 @@ + + + + + Verifying... + + + + + + + +
+
+ + + +
+
+ + + diff --git a/internal/middleware/nollamas.go b/internal/middleware/nollamas.go new file mode 100644 index 000000000..87c42aae4 --- /dev/null +++ b/internal/middleware/nollamas.go @@ -0,0 +1,227 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "hash" + "net/http" + "strconv" + "time" + + "codeberg.org/gruf/go-byteutil" + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +//go:embed challenge.html +var challengeHTML []byte + +func NoLLaMas() gin.HandlerFunc { + var nollamas nollamas + return nollamas.Serve +} + +// i.e. outputted hash slice length. +const hashLen = sha256.BlockSize + +// i.e. hex.EncodedLen(hashLen). +const encodedHashLen = 2 * hashLen + +func newHash() hash.Hash { return sha256.New() } + +type nollamas struct { + seed []byte // securely hashed instance private key + ttl time.Duration + diff uint8 +} + +func (m *nollamas) Serve(c *gin.Context) { + if c.Request.Method != http.MethodGet { + // Only interested in protecting + // crawlable 'GET' endpoints. + c.Next() + return + } + + if _, ok := c.Get(oauth.SessionAuthorizedToken); ok { + // Don't guard against requests + // providing valid OAuth tokens. + c.Next() + return + } + + // Get new hasher. + hash := newHash() + + // Reset hash. + hash.Reset() + + // Generate a unique token for + // this request only valid for + // a period of now +- m.ttl. + token := m.token(c, hash) + + // For unique challenge string just use a + // portion of their unique '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)/2] + + // Check for a provided success token. + cookie, _ := c.Cookie("gts-nollamas") + + if len(cookie) == 0 || len(cookie) > encodedHashLen { + // If they provide no cookie, or + // obviously wrong cookie, just + // present them with new challenge. + m.renderChallenge(c, challenge) + return + } + + // Check whether passed cookie + // is the expected success token. + if subtle.ConstantTimeCompare( + byteutil.S2B(token), + byteutil.S2B(cookie), + ) == 1 { + + // They passed us a valid, expected + // token. They already passed checks. + c.Next() + return + } + + // Check headers to see if is in-progress challenge. + nonce := c.Request.Header.Get("X-NoLLaMas-Solution") + if nonce == "" { + + // No attempted solution, just + // present them with new challenge. + m.renderChallenge(c, challenge) + return + } + + // Reset hash. + hash.Reset() + + // Hash and encode input challenge with + // proposed nonce as a possible solution. + _, _ = hash.Write(byteutil.S2B(challenge)) + _, _ = hash.Write(byteutil.S2B(nonce)) + solution := hex.AppendEncode(nil, hash.Sum(nil)) + + // Check that the first 'diff' + // many chars are indeed zeroes. + for i := range m.diff { + if subtle.ConstantTimeByteEq(solution[i], '0') == 0 { + + // They failed challenge, + // present them fail page. + m.renderFail(c) + return + } + } + + // They passed the challenge! Set success + // token cookie and allow them to continue. + c.SetCookie("gts-nollamas", token, int(m.ttl/time.Second), + "", "", false, false) + c.Next() +} + +func (m *nollamas) renderChallenge(c *gin.Context, challenge string) { + // Don't pass to further + // handlers, they only get + // our challenge page. + c.Abort() + + // Set the challenge we expect them to use in header. + c.Request.Header.Set("X-NoLLaMas-Challenge", challenge) + c.Request.Header.Set("X-NoLLaMas-Difficulty", strconv.FormatUint(uint64(m.diff), 10)) + + // Write the challenge HTML response to client. + apiutil.Data(c, http.StatusOK, "text/html", challengeHTML) +} + +func (m *nollamas) renderFail(c *gin.Context) { + // Don't pass to further + // handlers, they only get + // our failure page. + c.Abort() + + apiutil.Data(c, http.StatusOK, apiutil.AppJSON, []byte(`{"error": "failed nollamas challenge"}`)) +} + +func (m *nollamas) token(c *gin.Context, hash hash.Hash) string { + // Use our safe, unique input seed which + // is already hashed, but will get rehashed. + // This ensures we don't leak private keys, + // but also we have cryptographically safe + // deterministic tokens for comparisons. + _, _ = hash.Write(m.seed) + + // Include difficulty level in + // hash input data so if config + // changes then token invalidates. + _, _ = hash.Write([]byte{m.diff}) + + // Also seed the generated input with + // current time rounded to TTL, so with + // our single comparison handles expiries. + now := time.Now().Round(m.ttl).Unix() + _, _ = hash.Write([]byte{ + byte(now >> 56), + byte(now >> 48), + byte(now >> 40), + byte(now >> 32), + byte(now >> 24), + byte(now >> 16), + byte(now >> 8), + byte(now), + }) + + // Finally append unique client request data. + userAgent := c.Request.Header.Get("User-Agent") + _, _ = hash.Write(byteutil.S2B(userAgent)) + clientIP := c.ClientIP() + _, _ = hash.Write(byteutil.S2B(clientIP)) + + // Return hex encoded hash output. + return hex.EncodeToString(hash.Sum(nil)) +} + +// appendTime will append time as seconds in binary. +// func appendTime(b []byte, t time.Time) []byte { +// sec := t.Unix() +// return append(b, +// byte(sec>>56), +// byte(sec>>48), +// byte(sec>>40), +// byte(sec>>32), +// byte(sec>>24), +// byte(sec>>16), +// byte(sec>>8), +// byte(sec), +// ) +// }