Lay out groundwork for multiple iterations/steps

Add checking for bad solutions.
Do http requests with contexts
Minor fixes with managing steps counter.
This commit is contained in:
y a t s
2026-02-04 14:10:03 -05:00
parent ee9379dcbe
commit 6d199a103f
4 changed files with 160 additions and 94 deletions

View File

@@ -2,7 +2,7 @@
A fast [Tartarus](https://usips.org/products/tartarus/) solver lib. A fast [Tartarus](https://usips.org/products/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. For technical details, you can try asking Josh (not me) or reverse engineer the solver solutions offered on Tartarus' no-js page. Good luck.
<hr> <hr>

View File

@@ -13,63 +13,35 @@ import (
type Challenge struct { type Challenge struct {
Salt string // Challenge salt from server. Salt string // Challenge salt from server.
Diff uint32 // Difficulty level. Diff uint32 // Difficulty level.
Steps uint32 // Not 100% sure how this is used yet. Steps int8 // Each step consists of a Challenge and a Solution. More than 1 may be required.
host *url.URL // Stored as a signed int to get past underflow issues later on.
}
type Solution struct {
Salt string
Nonce uint32
Redirect string // To be set manually by caller.
Hash []byte
host *url.URL host *url.URL
} }
// Brute force nonces until a valid solution is found. // Convenience wrapper, equivalent to Solve(ctx, c).
func genHashes(ctx context.Context, c Challenge) <-chan Solution { func (c Challenge) Solve(ctx context.Context) (Solution, error) {
var ( return Solve(ctx, c)
out = make(chan Solution, 1) }
sha = sha256.New()
nonce = rand.Uint32()
)
go func() { type Solution struct {
defer close(out) Hash []byte // Not required in POST, but provided for reference.
Salt string // Challenge salt.
Nonce uint32 // Solution nonce. This is the "answer" to the problem.
Redirect string // Relative path to redirect to after Solution is accepted.
for { Steps int8 // Steps, as described in Challenge.
select {
case <-ctx.Done():
return
default:
}
sha.Write(fmt.Append(nil, c.Salt, nonce)) // TODO: Maybe make the redirect shit auto, idk.
sol := Solution{ host *url.URL
Salt: c.Salt,
Nonce: nonce,
Redirect: "/", // Use sensible default for placeholder.
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. // Given difficulty is measured in number of leading 0 bits.
func checkZeros(diff uint32, hash []byte) bool { func checkZeros(diff uint32, hash []byte) bool {
// I am using this big ugly check to avoid needing to hardcode the max difficulty (32, by the looks of it).
// Otherwise, it's a simple < comparison to (1 << (32 - diff))
var ( var (
rem = diff % 8 // Remainder after dividing diff (given in bits) to bytes. 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. nbytes = (diff - rem) / 8 // Amount of 0x0 bytes we can divide difficulty bits into.
@@ -77,9 +49,8 @@ func checkZeros(diff uint32, hash []byte) bool {
mask uint8 // Mask to check remaining bits. mask uint8 // Mask to check remaining bits.
) )
lh := uint32(len(hash))
// Check bounds for the loops found below. // Check bounds for the loops found below.
if lh < nbytes || (rem > 0 && lh < nbytes+1) { if lh := uint32(len(hash)); lh < nbytes || (rem > 0 && lh < nbytes+1) {
return false return false
} }
@@ -106,28 +77,76 @@ func checkZeros(diff uint32, hash []byte) bool {
return hash[nbytes]&mask == 0x0 return hash[nbytes]&mask == 0x0
} }
// Brute force nonces until a valid solution is found.
func genHashes(ctx context.Context, c Challenge) <-chan Solution {
var (
out = make(chan Solution, 1) // Probably unnecessary buffering for questionable efficiency.
sha = sha256.New()
nonce = rand.Uint32()
)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
default:
}
sha.Write(fmt.Append(nil, c.Salt, nonce))
sol := Solution{
Hash: sha.Sum(nil),
Salt: c.Salt,
Nonce: nonce,
Redirect: "/", // Use sensible default for placeholder, for now.
Steps: c.Steps - 1,
}
// 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
}
// Solve Challenge c. Returns Solution that can be submitted. // Solve Challenge c. Returns Solution that can be submitted.
func Solve(ctx context.Context, c Challenge) (Solution, error) { func Solve(ctx context.Context, c Challenge) (Solution, error) {
sol := make(chan Solution, 1) sol := make(chan Solution, 1)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
worker := 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
}
}
}
go func() { go func() {
// A reasonable hardware-based job limiter. // A reasonable hardware-based job limiter.
// Use 1 worker per thread at most. // Use 1 worker per thread at most.
threads := runtime.NumCPU() threads := runtime.NumCPU()
for i := 0; i < threads; i++ { for range threads {
go func() { go worker()
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
}
}
}()
} }
}() }()

View File

@@ -32,7 +32,7 @@ func solveTest(ctx context.Context, hc http.Client, host string) error {
} }
log.Printf("Fetching new %s challenge...", connType) log.Printf("Fetching new %s challenge...", connType)
c, err := NewChallenge(hc, host) c, err := NewChallenge(ctx, hc, host)
if err != nil { if err != nil {
return err return err
} }
@@ -42,13 +42,15 @@ func solveTest(ctx context.Context, hc http.Client, host string) error {
if err != nil { if err != nil {
return err return err
} }
log.Printf("Solution hash: %x, nonce: %d\n", s.Hash, s.Nonce) log.Printf("Solution hash: %x, nonce: %d, remaining steps: %d\n", s.Hash, s.Nonce, s.Steps)
a, err := Submit(hc, s) resp, err := Submit(ctx, hc, s, "")
if err != nil { if err != nil {
return err return err
} }
log.Printf("Response: %s\n\n", a) defer resp.Body.Close()
log.Printf("Response: %s\n\n", resp.Header.Get("Set-Cookie"))
return nil return nil
} }
@@ -65,17 +67,13 @@ func newProxyTransport() *http.Transport {
func TestSubmit(t *testing.T) { func TestSubmit(t *testing.T) {
ctx := t.Context() ctx := t.Context()
hc := http.Client{} err := solveTest(ctx, http.Client{}, _TEST_HOST)
err := solveTest(ctx, hc, _TEST_HOST)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
hc.Transport = newProxyTransport()
var dnsErr *net.DNSError var dnsErr *net.DNSError
err = solveTest(ctx, hc, _TEST_ONION) err = solveTest(ctx, http.Client{Transport: newProxyTransport()}, _TEST_ONION)
if err != nil { if err != nil {
if errors.As(err, &dnsErr) { if errors.As(err, &dnsErr) {
log.Println("Unable to resolve .onion domain. Make sure ALL_PROXY is set and tor is running.") log.Println("Unable to resolve .onion domain. Make sure ALL_PROXY is set and tor is running.")

93
http.go
View File

@@ -1,6 +1,7 @@
package cerberus package cerberus
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -18,7 +19,16 @@ var (
ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.") ErrParseFailed = errors.New("Failed to parse challenge from HTML data tags.")
) )
func NewChallenge(hc http.Client, host string) (Challenge, error) { type ErrInvalidSolution struct {
s Solution
}
func (e *ErrInvalidSolution) Error() string {
return fmt.Sprintf("Received 400 status when submitting solution: %+v", e.s)
}
// Request new Tartarus challenge from provided host.
func NewChallenge(ctx context.Context, hc http.Client, host string) (Challenge, error) {
u, err := parseHost(host) u, err := parseHost(host)
if err != nil { if err != nil {
return Challenge{}, err return Challenge{}, err
@@ -34,13 +44,18 @@ func NewChallenge(hc http.Client, host string) (Challenge, error) {
return nil return nil
} }
resp, err := hc.Get(u.String()) req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return Challenge{}, err
}
resp, err := hc.Do(req)
if err != nil { if err != nil {
return Challenge{}, err return Challenge{}, err
} }
defer resp.Body.Close() defer resp.Body.Close()
// Check for 203 status // Check for 203 status. A 203 indicates a redirect to a challenge page.
if resp.StatusCode != 203 { if resp.StatusCode != 203 {
return Challenge{}, ErrNoRedirect return Challenge{}, ErrNoRedirect
} }
@@ -55,28 +70,39 @@ func NewChallenge(hc http.Client, host string) (Challenge, error) {
return c, nil return c, nil
} }
func Submit(hc http.Client, s Solution) (string, error) { // Submit a Solution for a Challenge.
resp, err := postSolution(hc, s) //
if err != nil { // If redirect is empty, "/" is used as a sensible default.
return "", err // Auth cookies get set automatically in http.Client's CookieJar.
// The *http.Response is provided to support more advanced setups. The auth token can also be found in its Body.
func Submit(ctx context.Context, hc http.Client, s Solution, redirect string) (*http.Response, error) {
if redirect != "" {
s.Redirect = redirect
} }
defer resp.Body.Close()
return resp.Header.Get("Set-Cookie"), nil resp, err := postSolution(ctx, hc, s)
}
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 { if err != nil {
return nil, err return nil, err
} }
return hc.PostForm(u.String(), url.Values{ if s.Steps > 0 {
"salt": []string{s.Salt}, c, err := NewChallenge(ctx, hc, s.host.String())
"redirect": []string{s.Redirect}, if err != nil {
"nonce": []string{fmt.Sprint(s.Nonce)}, return nil, err
}) }
// maybe useful later. idk.
// c.Steps = s.Steps
s, err := Solve(ctx, c)
if err != nil {
return nil, err
}
return Submit(ctx, hc, s, redirect)
}
return resp, nil
} }
func parseHost(addr string) (*url.URL, error) { func parseHost(addr string) (*url.URL, error) {
@@ -85,12 +111,35 @@ func parseHost(addr string) (*url.URL, error) {
addr = "https://" + addr addr = "https://" + addr
} }
u, err := url.Parse(addr) return url.Parse(addr)
}
func postSolution(ctx context.Context, 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 { if err != nil {
return nil, err return nil, err
} }
return u, nil reqBody := strings.NewReader(fmt.Sprintf(`salt=%s&redirect=%s&nonce=%d`, s.Salt, url.PathEscape(s.Redirect), s.Nonce))
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), reqBody)
if err != nil {
return nil, err
}
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
// TODO: Additionally verify failure from response JSON. Maybe include resp body in err type.
// Rejected solution response: status=400 body={"success":false,"reason":"invalid_solution","action":"retry"}
if resp.StatusCode == 400 {
defer resp.Body.Close()
return nil, &ErrInvalidSolution{s}
}
return resp, nil
} }
func parseTags(r io.Reader) (Challenge, error) { func parseTags(r io.Reader) (Challenge, error) {
@@ -115,7 +164,7 @@ func parseTags(r io.Reader) (Challenge, error) {
if err != nil { if err != nil {
return c, ErrParseFailed return c, ErrParseFailed
} }
c.Steps = uint32(steps) c.Steps = int8(steps)
} }
} }
} }