diff --git a/README.md b/README.md index 8ca7c76..d30778c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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.
diff --git a/cerberus.go b/cerberus.go index 247fdab..6a30e43 100644 --- a/cerberus.go +++ b/cerberus.go @@ -13,63 +13,35 @@ import ( type Challenge struct { Salt string // Challenge salt from server. Diff uint32 // Difficulty level. - Steps uint32 // Not 100% sure how this is used yet. - host *url.URL -} + Steps int8 // Each step consists of a Challenge and a Solution. More than 1 may be required. + // 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 } -// 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() - ) +// Convenience wrapper, equivalent to Solve(ctx, c). +func (c Challenge) Solve(ctx context.Context) (Solution, error) { + return Solve(ctx, c) +} - go func() { - defer close(out) +type Solution struct { + 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 { - select { - case <-ctx.Done(): - return - default: - } + Steps int8 // Steps, as described in Challenge. - sha.Write(fmt.Append(nil, c.Salt, nonce)) + // TODO: Maybe make the redirect shit auto, idk. - sol := Solution{ - 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 + host *url.URL } // Given difficulty is measured in number of leading 0 bits. 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 ( 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. @@ -77,9 +49,8 @@ func checkZeros(diff uint32, hash []byte) bool { 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) { + if lh := uint32(len(hash)); lh < nbytes || (rem > 0 && lh < nbytes+1) { return false } @@ -106,28 +77,76 @@ func checkZeros(diff uint32, hash []byte) bool { 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. func Solve(ctx context.Context, c Challenge) (Solution, error) { 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() { // 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 - } - } - }() + for range threads { + go worker() } }() diff --git a/cerberus_test.go b/cerberus_test.go index 92c1de8..8fad15c 100644 --- a/cerberus_test.go +++ b/cerberus_test.go @@ -32,7 +32,7 @@ func solveTest(ctx context.Context, hc http.Client, host string) error { } log.Printf("Fetching new %s challenge...", connType) - c, err := NewChallenge(hc, host) + c, err := NewChallenge(ctx, hc, host) if err != nil { return err } @@ -42,13 +42,15 @@ func solveTest(ctx context.Context, hc http.Client, host string) error { if err != nil { 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 { 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 } @@ -65,17 +67,13 @@ func newProxyTransport() *http.Transport { func TestSubmit(t *testing.T) { ctx := t.Context() - hc := http.Client{} - - err := solveTest(ctx, hc, _TEST_HOST) + err := solveTest(ctx, http.Client{}, _TEST_HOST) if err != nil { t.Error(err) } - hc.Transport = newProxyTransport() - var dnsErr *net.DNSError - err = solveTest(ctx, hc, _TEST_ONION) + err = solveTest(ctx, http.Client{Transport: newProxyTransport()}, _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.") diff --git a/http.go b/http.go index b779bbc..546cf43 100644 --- a/http.go +++ b/http.go @@ -1,6 +1,7 @@ package cerberus import ( + "context" "errors" "fmt" "io" @@ -18,7 +19,16 @@ var ( 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) if err != nil { return Challenge{}, err @@ -34,13 +44,18 @@ func NewChallenge(hc http.Client, host string) (Challenge, error) { 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 { return Challenge{}, err } 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 { return Challenge{}, ErrNoRedirect } @@ -55,28 +70,39 @@ func NewChallenge(hc http.Client, host string) (Challenge, error) { return c, nil } -func Submit(hc http.Client, s Solution) (string, error) { - resp, err := postSolution(hc, s) - if err != nil { - return "", err +// Submit a Solution for a Challenge. +// +// If redirect is empty, "/" is used as a sensible default. +// 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 -} - -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())) + resp, err := postSolution(ctx, hc, s) 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)}, - }) + if s.Steps > 0 { + c, err := NewChallenge(ctx, hc, s.host.String()) + if err != nil { + 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) { @@ -85,12 +111,35 @@ func parseHost(addr string) (*url.URL, error) { 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 { 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) { @@ -115,7 +164,7 @@ func parseTags(r io.Reader) (Challenge, error) { if err != nil { return c, ErrParseFailed } - c.Steps = uint32(steps) + c.Steps = int8(steps) } } }