diff --git a/README.md b/README.md index 0fcae43..8d9943c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # hashcash -HTTP Hashcash impremented in Go. \ No newline at end of file +HTTP Hashcash implemented in Go. + +Explanation at https://therootcompany.com/blog/http-hashcash/ + +Go docs at https://godoc.org/git.rootprojects.org/root/hashcash diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75885ff --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.rootprojects.org/root/hashcash + +go 1.15 diff --git a/hashcash.go b/hashcash.go new file mode 100644 index 0000000..b80b367 --- /dev/null +++ b/hashcash.go @@ -0,0 +1,284 @@ +package hashcash + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "strconv" + "strings" + "time" +) + +// ErrParse is returned when fewer than 6 or more than 7 segments are split +var ErrParse = errors.New("could not split the hashcash parts") + +// ErrInvalidTag is returned when the Hashcash version is unsupported +var ErrInvalidTag = errors.New("expected tag to be 'H'") + +// ErrInvalidDifficulty is returned when the difficulty is outside of the acceptable range +var ErrInvalidDifficulty = errors.New("the number of bits of difficulty is too low or too high") + +// ErrInvalidDate is returned when the date cannot be parsed as a positive int64 +var ErrInvalidDate = errors.New("invalid date") + +// ErrExpired is returned when the current time is past that of ExpiresAt +var ErrExpired = errors.New("expired hashcash") + +// ErrInvalidSubject is returned when the subject is invalid or does not match that passed to Verify() +var ErrInvalidSubject = errors.New("the subject is invalid or rejected") + +// ErrInvalidNonce is returned when the nonce +//var ErrInvalidNonce = errors.New("the nonce has been used or is invalid") + +// ErrUnsupportedAlgorithm is returned when the given algorithm is not supported +var ErrUnsupportedAlgorithm = errors.New("the given algorithm is invalid or not supported") + +// ErrInvalidSolution is returned when the given hashcash is not properly solved +var ErrInvalidSolution = errors.New("the given solution is not valid") + +// MaxDifficulty is the upper bound for all Solve() operations +var MaxDifficulty = 26 + +// Sep is the separator character to use +var Sep = ":" + +// no milliseconds +//var isoTS = "2006-01-02T15:04:05Z" + +// Hashcash represents a parsed Hashcash string +type Hashcash struct { + Tag string `json:"tag"` // Always "H" for "HTTP" + Difficulty int `json:"difficulty"` // Number of "partial pre-image" (zero) bits in the hashed code + ExpiresAt time.Time `json:"exp"` // The timestamp that the hashcash expires, as seconds since the Unix epoch + Subject string `json:"sub"` // Resource data string being transmitted, e.g., a domain or URL + Nonce string `json:"nonce"` // Unique string of random characters, encoded as url-safe base-64 + Alg string `json:"alg"` // always SHA-256 for now + Solution string `json:"solution"` // Binary counter, encoded as url-safe base-64 +} + +// New returns a Hashcash with reasonable defaults +func New(h Hashcash) *Hashcash { + h.Tag = "H" + + if 0 == h.Difficulty { + // safe for WebCrypto + h.Difficulty = 10 + } + + if h.ExpiresAt.IsZero() { + h.ExpiresAt = time.Now().Add(5 * time.Minute) + } + h.ExpiresAt = h.ExpiresAt.UTC().Truncate(time.Second) + + if "" == h.Subject { + h.Subject = "*" + } + + if "" == h.Nonce { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); nil != err { + panic(err) + return nil + } + h.Nonce = base64.RawURLEncoding.EncodeToString(nonce) + } + + if "" == h.Alg { + h.Alg = "SHA-256" + } + /* + if "SHA-256" != h.Alg { + // TODO error + } + */ + + return &h +} + +// Parse will (obviously) parse the hashcash string, without verifying any +// of the parameters. +func Parse(hc string) (*Hashcash, error) { + parts := strings.Split(hc, Sep) + n := len(parts) + if n < 6 || n > 7 { + return nil, ErrParse + } + + tag := parts[0] + if "H" != tag { + return nil, ErrInvalidTag + } + + bits, err := strconv.Atoi(parts[1]) + if nil != err || bits < 0 { + return nil, ErrInvalidDifficulty + } + + // Allow empty ExpiresAt + var exp time.Time + if "" != parts[2] { + expAt, err := strconv.ParseInt(parts[2], 10, 64) + if nil != err || expAt < 0 { + return nil, ErrInvalidDate + } + exp = time.Unix(int64(expAt), 0).UTC() + } + + /* + exp, err := time.ParseInLocation(isoTS, parts[2], time.UTC) + if nil != err { + return nil, ErrInvalidDate + } + */ + + sub := parts[3] + + nonce := parts[4] + + alg := parts[5] + + var solution string + if n > 6 { + solution = parts[6] + } + + h := &Hashcash{ + Tag: tag, + Difficulty: bits, + ExpiresAt: exp.UTC().Truncate(time.Second), + Subject: sub, + Nonce: nonce, + Alg: alg, + Solution: solution, + } + + return h, nil +} + +// String will return the formatted Hashcash, omitting the solution if it has not be solved. +func (h *Hashcash) String() string { + var solution string + if "" != h.Solution { + solution = Sep + h.Solution + } + + var expAt string + if !h.ExpiresAt.IsZero() { + expAt = strconv.FormatInt(h.ExpiresAt.UTC().Truncate(time.Second).Unix(), 10) + } + return strings.Join( + []string{ + "H", + strconv.Itoa(h.Difficulty), + //h.ExpiresAt.UTC().Format(isoTS), + expAt, + h.Subject, + h.Nonce, + h.Alg, + }, + Sep, + ) + solution +} + +// Verify the Hashcash based on Difficulty, Algorithm, ExpiresAt, Subject and, +// of course, the Solution and hash. +func (h *Hashcash) Verify(subject string) error { + if h.Difficulty < 0 { + return ErrInvalidDifficulty + } + + if "SHA-256" != h.Alg { + return ErrUnsupportedAlgorithm + } + + if !h.ExpiresAt.IsZero() && h.ExpiresAt.Sub(time.Now()) < 0 { + return ErrExpired + } + + if subject != h.Subject { + return ErrInvalidSubject + } + + bits := h.Difficulty + hash := sha256.Sum256([]byte(h.String())) + n := bits / 8 // 10 / 8 = 1 + m := bits % 8 // 10 % 8 = 2 + if m > 0 { + n++ // 10 bits = 2 bytes + } + + if !verifyBits(hash[:n], bits, n) { + return ErrInvalidSolution + } + return nil +} + +func verifyBits(hash []byte, bits, n int) bool { + for i := 0; i < n; i++ { + if bits > 8 { + bits -= 8 + if 0 != hash[i] { + return false + } + continue + } + + // (bits % 8) == bits + pad := 8 - bits + if 0 != hash[i]>>pad { + return false + } + + return true + } + + // 0 == bits + return true +} + +// Solve will search for a solution, returning an error if the difficulty is +// above the local or global MaxDifficulty, the Algorithm is unsupported. +func (h *Hashcash) Solve(maxDifficulty int) error { + if "SHA-256" != h.Alg { + return ErrUnsupportedAlgorithm + } + + if h.Difficulty < 0 { + return ErrInvalidDifficulty + } + + if h.Difficulty > maxDifficulty || h.Difficulty > MaxDifficulty { + return ErrInvalidDifficulty + } + + if "" != h.Solution { + if nil == h.Verify(h.Subject) { + return nil + } + h.Solution = "" + } + + hashcash := h.String() + bits := h.Difficulty + n := bits / 8 // 10 / 8 = 1 + m := bits % 8 // 10 % 8 = 2 + if m > 0 { + n++ // 10 bits = 2 bytes + } + + var solution uint32 = 0 + sb := make([]byte, 4) + for { + // Note: it's not actually important what method of change or encoding is used + // but incrementing by 1 on an int32 is good enough, and makes for a small base64 encoding + binary.LittleEndian.PutUint32(sb, solution) + h.Solution = base64.RawURLEncoding.EncodeToString(sb) + hash := sha256.Sum256([]byte(hashcash + Sep + h.Solution)) + if verifyBits(hash[:n], bits, n) { + return nil + } + solution++ + } +} diff --git a/hashcash_test.go b/hashcash_test.go new file mode 100644 index 0000000..45b0d05 --- /dev/null +++ b/hashcash_test.go @@ -0,0 +1,331 @@ +package hashcash + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "testing" + "time" +) + +var never = time.Date(3006, time.January, 1, 15, 4, 5, 0, time.UTC) + +func TestNew(t *testing.T) { + h1 := New(Hashcash{}) + + h2, err := Parse(h1.String()) + if nil != err { + t.Fatal(err) + return + } + + if h1.Tag != h2.Tag { + t.Fatal("wrong tag version") + return + } + + if h1.Difficulty != h2.Difficulty { + t.Fatal("wrong difficulty") + return + } + + if h1.ExpiresAt != h2.ExpiresAt { + t.Fatal("wrong expiresat") + return + } + + if h1.Subject != h2.Subject { + t.Fatal("wrong subject") + return + } + + if h1.Nonce != h2.Nonce { + t.Fatal("wrong nonce") + return + } + + if h1.Alg != h2.Alg { + t.Fatal("wrong algorithm") + return + } + + if h1.Solution != h2.Solution { + t.Fatal("wrong solution") + return + } +} + +func TestExplicit(t *testing.T) { + nonce := make([]byte, 16) + _, _ = rand.Read(nonce) + h0 := Hashcash{ + Tag: "H", + Difficulty: 1000, + ExpiresAt: time.Now().UTC().Truncate(time.Second), + Subject: "example.com", + Nonce: base64.RawURLEncoding.EncodeToString(nonce), + Alg: "FOOBAR", + Solution: "incorrect", + } + h1 := New(h0) + + h2, err := Parse(h1.String()) + if nil != err { + t.Fatal(err) + return + } + h3 := New(*h2) + + if h0.Tag != h3.Tag { + t.Fatal("wrong tag version") + return + } + + if h0.Difficulty != h3.Difficulty { + t.Fatal("wrong difficulty") + return + } + + if h0.ExpiresAt != h3.ExpiresAt { + t.Fatal("wrong expiresat") + return + } + + if h0.Subject != h3.Subject { + t.Fatal("wrong subject") + return + } + + if h0.Nonce != h3.Nonce { + t.Fatal("wrong nonce") + return + } + + if h0.Alg != h3.Alg { + t.Fatal("wrong algorithm") + return + } + + if h0.Solution != h3.Solution { + t.Fatal("wrong solution") + return + } +} + +func TestExpired(t *testing.T) { + h := New(Hashcash{ + Alg: "SHA-256", + Difficulty: 10, + ExpiresAt: time.Now().Add(-5 * time.Second), + }) + + err := h.Verify(h.Subject) + if nil == err { + t.Error("verified expired token") + return + } + if err != ErrExpired { + t.Error("expired token error is not ErrExpired") + return + } +} + +func TestNoExpiry(t *testing.T) { + h := New(Hashcash{}) + h.ExpiresAt = time.Time{} + + if err := h.Solve(20); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve0(t *testing.T) { + h := New(Hashcash{ + ExpiresAt: never, + Nonce: "DeadBeef", + }) + h.Difficulty = 0 + + if err := h.Solve(1); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve1(t *testing.T) { + h := New(Hashcash{ + Difficulty: 1, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(2); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve2(t *testing.T) { + h := New(Hashcash{ + Difficulty: 2, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(3); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve7(t *testing.T) { + h := New(Hashcash{ + Difficulty: 7, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(8); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve8(t *testing.T) { + h := New(Hashcash{ + Difficulty: 8, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(9); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve9(t *testing.T) { + h := New(Hashcash{ + Difficulty: 9, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(10); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve15(t *testing.T) { + h := New(Hashcash{ + Difficulty: 15, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(16); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve16(t *testing.T) { + h := New(Hashcash{ + Difficulty: 16, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(17); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } +} + +func TestSolve17(t *testing.T) { + h := New(Hashcash{ + Difficulty: 17, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + if err := h.Solve(18); nil != err { + t.Error(err) + return + } + + if err := h.Verify(h.Subject); nil != err { + t.Error(err) + return + } + + if "H:17:32693036645:*:DeadBeef:SHA-256:R7sIAA" != h.String() { + t.Errorf("unexpected hashcash string: %s, has the implementation changed?", h.String()) + } +} + +func TestTooHard(t *testing.T) { + h := New(Hashcash{ + Alg: "SHA-256", + Difficulty: 24, + ExpiresAt: never, + Nonce: "DeadBeef", + }) + + err := h.Solve(20) + if nil == err { + t.Error(errors.New("the challenge is too hard, should've quite")) + return + } + if ErrInvalidDifficulty != err { + t.Error(errors.New("incorrect error")) + return + } +}