Initial commit

This commit is contained in:
y a t s
2026-01-25 14:41:59 -05:00
commit 244667457a
8 changed files with 443 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.*
!.gitignore
*.json
*.log
build/
data-dir-*/
tmp/
GPATH
GRTAGS
GTAGS

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# Cerberus
A fast Tartarus solver lib.
For technical details, you can try asking Josh (not me) or reverse engineer the *wonderful* solver solutions offered on Tartarus' no-js page.
Good luck.
<hr>
Donations are always appreciated but never expected nor required:
XMR: `8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn`
[Other crypto (anonpay)](https://trocador.app/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=) ([Tor version](http://trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion/anonpay/?ticker_to=xmr&network_to=Mainnet&address=8BjCARiV2uB2gZTbbiMUetfRxcAYZgVM5fXxjEbpmb2nAu8ND1grazZ1EhMGdRqVerAtvEJeiy7SzA3SLXpg2CtRDtCAFfn&donation=True&description=SockChat+Donation&bgcolor=))

24
UNLICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

141
cerberus.go Normal file
View File

@@ -0,0 +1,141 @@
package cerberus
import (
"context"
"fmt"
"math/rand"
"net/url"
"runtime"
"github.com/minio/sha256-simd"
)
type Challenge struct {
Salt string // Challenge salt from server.
Diff uint32 // Difficulty level.
Steps uint32 // Time limit for answer in minutes.
host *url.URL
}
type Solution struct {
Salt string
Nonce uint32
Redirect string
Hash []byte
host *url.URL
}
// Brute force nonces until a valid solution is found.
func genHashes(ctx context.Context, c Challenge) <-chan Solution {
var (
out = make(chan Solution, 1)
sha = sha256.New()
nonce = rand.Uint32()
redirect = "/" // TODO: figure out non-homepage redirects.
)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
default:
}
sha.Write(fmt.Append(nil, c.Salt, nonce))
sol := Solution{
Salt: c.Salt,
Nonce: nonce,
Redirect: redirect,
Hash: sha.Sum(nil),
}
// Ensure we don't hang if out channel is full on ctx close.
select {
case <-ctx.Done():
return
case out <- sol:
}
// Reset hasher input for next iteration.
sha.Reset()
nonce++
}
}()
return out
}
// Given difficulty is measured in number of leading 0 bits.
func checkZeros(diff uint32, hash []byte) bool {
var (
rem = diff % 8 // Remainder after dividing diff (given in bits) to bytes.
nbytes = (diff - rem) / 8 // Amount of 0x0 bytes we can divide difficulty bits into.
mask uint8 // Mask to check remaining bits.
)
lh := uint32(len(hash))
// Check bounds for the loops found below.
if lh < nbytes || (rem > 0 && lh < nbytes+1) {
return false
}
// First, we count the number of leading 0x0 bytes.
for i := range nbytes {
if b := hash[i]; b != 0x0 {
return false
}
}
// If we don't have any more bits to check, return.
if rem == 0 {
return true
}
// Create bitmask by setting a bit to 1 for each remaining bit to check.
// The mask is built from right-to-left and then shifted to the LHS of the octet.
for range rem {
mask <<= 1
mask += 1
}
// Shift 1s we just added to the LHS of the octet.
mask <<= 8 - rem
return hash[nbytes]&mask == 0x0
}
// Solve Challenge c. Returns Solution that can be submitted.
func Solve(ctx context.Context, c Challenge) (Solution, error) {
sol := make(chan Solution, 1)
go func() {
// A reasonable hardware-based job limiter.
// Use 1 worker per thread at most.
threads := runtime.NumCPU()
for i := 0; i < threads; i++ {
go func() {
hf := genHashes(ctx, c)
// Loop until answer has been found.
// Should break when hash worker terminates.
for h := range hf {
if checkZeros(c.Diff, h.Hash) {
h.Salt = c.Salt
// Set host url to submit this to.
h.host = c.host
sol <- h
}
}
}()
}
}()
select {
case <-ctx.Done():
return Solution{}, ctx.Err()
case s := <-sol:
return s, nil
}
}

100
cerberus_test.go Normal file
View File

@@ -0,0 +1,100 @@
package cerberus
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
"testing"
"golang.org/x/net/proxy"
)
const _TEST_HOST = "kiwifarms.jp"
const _TEST_ONION = "kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion"
type errBadZeroCheck struct {
Diff uint32
Hash []byte
}
func (e *errBadZeroCheck) Error() string {
return fmt.Sprintf("Zero check failed. Diff: %d, Hash: %+v\n", e.Diff, e.Hash)
}
func solveTest(ctx context.Context, hc http.Client, host string) error {
connType := "clearnet"
if strings.HasSuffix(host, ".onion") {
connType = "tor"
}
log.Printf("Fetching new %s challenge...", connType)
c, err := NewChallenge(hc, host)
if err != nil {
return err
}
log.Printf("Challenge: %s, Difficulty: %d, Steps: %d\n", c.Salt, c.Diff, c.Steps)
s, err := Solve(ctx, c)
if err != nil {
return err
}
log.Printf("Solution hash: %x, nonce: %d\n", s.Hash, s.Nonce)
a, err := Submit(hc, s)
if err != nil {
return err
}
log.Printf("Response: %s\n\n", a)
return nil
}
func newProxyTransport() *http.Transport {
pcd := proxy.FromEnvironment().(proxy.ContextDialer)
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.DialContext = pcd.DialContext
return tr
}
func TestSubmit(t *testing.T) {
ctx := t.Context()
hc := http.Client{}
err := solveTest(ctx, hc, _TEST_HOST)
if err != nil {
t.Error(err)
}
hc.Transport = newProxyTransport()
var dnsErr *net.DNSError
err = solveTest(ctx, hc, _TEST_ONION)
if err != nil {
if errors.As(err, &dnsErr) {
log.Println("Unable to resolve .onion domain. Make sure ALL_PROXY is set and tor is running.")
log.Println("Skipping...")
} else {
t.Error(err)
}
}
}
func TestCheckZeros(t *testing.T) {
d, h := uint32(17), []byte{0, 0, 64, 128, 42}
if !checkZeros(d, h) {
t.Error(errBadZeroCheck{d, h})
}
// This should fail (i.e. be false).
d, h = uint32(3), []byte{33, 130, 222, 88}
if checkZeros(d, h) {
t.Error(errBadZeroCheck{d, h})
}
}

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module github.com/y-a-t-s/cerberus
go 1.25.5
require (
github.com/minio/sha256-simd v1.0.1
golang.org/x/net v0.49.0
)
require (
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
golang.org/x/sys v0.40.0 // indirect
)

9
go.sum Normal file
View File

@@ -0,0 +1,9 @@
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

129
http.go Normal file
View File

@@ -0,0 +1,129 @@
package cerberus
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
var (
ErrNoRedirect = errors.New("No redirect to challenge page.")
ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.")
)
func NewChallenge(hc http.Client, host string) (Challenge, error) {
u, err := parseHost(host)
if err != nil {
return Challenge{}, err
}
// Update host url in case we get redirected across domains.
hc.CheckRedirect = func(req *http.Request, via []*http.Request) error {
rh := req.URL.Host
if rh != u.Host && strings.HasPrefix(rh, "kiwifarms") {
u.Host = rh
}
return nil
}
resp, err := hc.Get(u.String())
if err != nil {
return Challenge{}, err
}
defer resp.Body.Close()
// Check for 203 status
if resp.StatusCode != 203 {
return Challenge{}, ErrNoRedirect
}
// Kept separate from the return because of the defer.
c, err := parseTags(resp.Body)
if err != nil {
return Challenge{}, err
}
c.host = u
return c, nil
}
func Submit(hc http.Client, s Solution) (string, error) {
resp, err := postSolution(hc, s)
if err != nil {
return "", err
}
defer resp.Body.Close()
return resp.Header.Get("Set-Cookie"), nil
}
func postSolution(hc http.Client, s Solution) (*http.Response, error) {
// Ensure the POST url parses properly before passing the string.
u, err := url.Parse(fmt.Sprintf("%s://%s/.ttrs/challenge", s.host.Scheme, s.host.Hostname()))
if err != nil {
return nil, err
}
return hc.PostForm(u.String(), url.Values{
"salt": []string{s.Salt},
"redirect": []string{s.Redirect},
"nonce": []string{fmt.Sprint(s.Nonce)},
})
}
func parseHost(addr string) (*url.URL, error) {
// Guess https as protocol if one wasn't provided and hope it parses.
if !strings.Contains(addr, "://") {
addr = "https://" + addr
}
u, err := url.Parse(addr)
if err != nil {
return nil, err
}
return u, nil
}
func parseTags(r io.Reader) (Challenge, error) {
c := Challenge{}
z := html.NewTokenizer(r)
for i := z.Next(); i != html.ErrorToken; i = z.Next() {
tk := z.Token()
if tk.DataAtom == atom.Html {
for _, a := range tk.Attr {
switch a.Key {
case "data-ttrs-challenge":
c.Salt = a.Val
case "data-ttrs-difficulty":
diff, err := strconv.Atoi(a.Val)
if err != nil {
return c, ErrParseFailed
}
c.Diff = uint32(diff)
case "data-ttrs-steps":
steps, err := strconv.Atoi(a.Val)
if err != nil {
return c, ErrParseFailed
}
c.Steps = uint32(steps)
}
}
}
}
if c.Salt == "" {
return c, ErrParseFailed
}
return c, nil
}