diff --git a/client-webserver/client.py b/client-webserver/client.py index 8d562b9aa..7b739e67d 100755 --- a/client-webserver/client.py +++ b/client-webserver/client.py @@ -4,8 +4,14 @@ from chocolate_protocol_pb2 import chocolatemessage from Crypto.Hash import SHA256 import CSR from CSR import M2Crypto -import urllib2, os, sys, time, random, sys +import urllib2, os, sys, time, random, sys, hashcash # CSR.py here should be a symlink to ../server-ca/CSR.py +# hashcash.py here should be a symlink to ../server-ca/hashcash.py + +difficulty = 20 +# TODO: unfortunately, the C hashcash implementation seems to be about +# 2^6 times faster than the native Python implementation, so +# calibrating the difficulty is a bit of a problem. def sha256(m): return SHA256.new(m).hexdigest() @@ -29,9 +35,11 @@ def init(m): m.session = "" def make_request(m, csr): - m.request.recipient = os.environ["CHOCOLATESERVER"] + server = os.environ["CHOCOLATESERVER"] + m.request.recipient = server m.request.timestamp = int(time.time()) m.request.csr = csr + m.request.clientpuzzle = hashcash.mint(server, difficulty) def sign(k, m): m.request.sig = CSR.sign(k, ("(%d) (%s) (%s)" % (m.request.timestamp, m.request.recipient, m.request.csr))) @@ -42,6 +50,8 @@ init(k) init(m) make_request(m, csr=open("req.pem").read()) sign(open("key.pem").read(), m) +print m +assert False r=decode(do(m)) print r while r.proceed.IsInitialized(): diff --git a/client-webserver/hashcash.py b/client-webserver/hashcash.py new file mode 120000 index 000000000..3335450d4 --- /dev/null +++ b/client-webserver/hashcash.py @@ -0,0 +1 @@ +../server-ca/hashcash.py \ No newline at end of file diff --git a/server-ca/chocolate.py b/server-ca/chocolate.py index b73f69162..c06e72a68 100755 --- a/server-ca/chocolate.py +++ b/server-ca/chocolate.py @@ -4,6 +4,7 @@ import web, redis, time import CSR import hashlib import hmac +import hashcash from CSR import M2Crypto from Crypto import Random from chocolate_protocol_pb2 import chocolatemessage @@ -12,6 +13,8 @@ from google.protobuf.message import DecodeError MaximumSessionAge = 100 # seconds, to demonstrate session timeout MaximumChallengeAge = 600 # to demonstrate challenge timeout +difficulty = 20 # bits of hashcash required with new requests + try: chocolate_server_name = open("SERVERNAME").read().rstrip() except IOError: @@ -201,6 +204,12 @@ class session(object): # It is mandatory to make a signing request at the outset of a session. self.die(r, r.BadRequest, uri="https://ca.example.com/failures/missingrequest") return + # Check hashcash before doing any crypto or database access. + if not m.request.clientpuzzle or not hashcash.check(m.request.clientpuzzle, chocolate_server_name, difficulty): + # TODO: should enforce hashcash expiry and use the database to store valid + # ones in order to prevent double-spending. + self.die(r, r.NeedClientPuzzle, uri="https://ca.example.com/failures/hashcash") + return if self.request_made(): # Can't make new signing requests if there have already been requests in # this session. (All signing requests should occur together at the @@ -208,7 +217,6 @@ class session(object): self.die(r, r.BadRequest, uri="https://ca.example.com/failures/priorrequest") return # Process the request. - # TODO: check client puzzle before processing request timestamp = m.request.timestamp recipient = m.request.recipient csr = m.request.csr diff --git a/server-ca/hashcash.py b/server-ca/hashcash.py new file mode 100644 index 000000000..8a660bc50 --- /dev/null +++ b/server-ca/hashcash.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python2.3 +"""Implement Hashcash version 1 protocol in Python ++-------------------------------------------------------+ +| Written by David Mertz; released to the Public Domain | ++-------------------------------------------------------+ + +Double spend database not implemented in this module, but stub +for callbacks is provided in the 'check()' function + +The function 'check()' will validate hashcash v1 and v0 tokens, as well as +'generalized hashcash' tokens generically. Future protocol version are +treated as generalized tokens (should a future version be published w/o +this module being correspondingly updated). + +A 'generalized hashcash' is implemented in the '_mint()' function, with the +public function 'mint()' providing a wrapper for actual hashcash protocol. +The generalized form simply finds a suffix that creates zero bits in the +hash of the string concatenating 'challenge' and 'suffix' without specifying +any particular fields or delimiters in 'challenge'. E.g., you might get: + + >>> from hashcash import mint, _mint + >>> mint('foo', bits=16) + '1:16:040922:foo::+ArSrtKd:164b3' + >>> _mint('foo', bits=16) + '9591' + >>> from sha import sha + >>> sha('foo9591').hexdigest() + '0000de4c9b27cec9b20e2094785c1c58eaf23948' + >>> sha('1:16:040922:foo::+ArSrtKd:164b3').hexdigest() + '0000a9fe0c6db2efcbcab15157735e77c0877f34' + +Notice that '_mint()' behaves deterministically, finding the same suffix +every time it is passed the same arguments. 'mint()' incorporates a random +salt in stamps (as per the hashcash v.1 protocol). +""" +import sys +from string import ascii_letters +from math import ceil, floor +from sha import sha +from random import choice +from time import strftime, localtime, time + +ERR = sys.stderr # Destination for error messages +DAYS = 60 * 60 * 24 # Seconds in a day +tries = [0] # Count hashes performed for benchmark + +def mint(resource, bits=20, now=None, ext='', saltchars=8, stamp_seconds=False): + """Mint a new hashcash stamp for 'resource' with 'bits' of collision + + 20 bits of collision is the default. + + 'ext' lets you add your own extensions to a minted stamp. Specify an + extension as a string of form 'name1=2,3;name2;name3=var1=2,2,val' + FWIW, urllib.urlencode(dct).replace('&',';') comes close to the + hashcash extension format. + + 'saltchars' specifies the length of the salt used; this version defaults + 8 chars, rather than the C version's 16 chars. This still provides about + 17 million salts per resource, per timestamp, before birthday paradox + collisions occur. Really paranoid users can use a larger salt though. + + 'stamp_seconds' lets you add the option time elements to the datestamp. + If you want more than just day, you get all the way down to seconds, + even though the spec also allows hours/minutes without seconds. + """ + ver = "1" + now = now or time() + if stamp_seconds: ts = strftime("%y%m%d%H%M%S", localtime(now)) + else: ts = strftime("%y%m%d", localtime(now)) + challenge = "%s:"*6 % (ver, bits, ts, resource, ext, _salt(saltchars)) + return challenge + _mint(challenge, bits) + +def _salt(l): + "Return a random string of length 'l'" + alphabet = ascii_letters + "+/=" + return ''.join([choice(alphabet) for _ in [None]*l]) + +def _mint(challenge, bits): + """Answer a 'generalized hashcash' challenge' + + Hashcash requires stamps of form 'ver:bits:date:res:ext:rand:counter' + This internal function accepts a generalized prefix 'challenge', + and returns only a suffix that produces the requested SHA leading zeros. + + NOTE: Number of requested bits is rounded up to the nearest multiple of 4 + """ + counter = 0 + hex_digits = int(ceil(bits/4.)) + zeros = '0'*hex_digits + while 1: + digest = sha(challenge+hex(counter)[2:]).hexdigest() + if digest[:hex_digits] == zeros: + tries[0] = counter + return hex(counter)[2:] + counter += 1 + +def check(stamp, resource=None, bits=None, + check_expiration=None, ds_callback=None): + """Check whether a stamp is valid + + Optionally, the stamp may be checked for a specific resource, and/or + it may require a minimum bit value, and/or it may be checked for + expiration, and/or it may be checked for double spending. + + If 'check_expiration' is specified, it should contain the number of + seconds old a date field may be. Indicating days might be easier in + many cases, e.g. + + >>> from hashcash import DAYS + >>> check(stamp, check_expiration=28*DAYS) + + NOTE: Every valid (version 1) stamp must meet its claimed bit value + NOTE: Check floor of 4-bit multiples (overly permissive in acceptance) + """ + if stamp.startswith('0:'): # Version 0 + try: + date, res, suffix = stamp[2:].split(':') + except ValueError: + ERR.write("Malformed version 0 hashcash stamp!\n") + return False + if resource is not None and resource != res: + return False + elif check_expiration is not None: + good_until = strftime("%y%m%d%H%M%S", localtime(time()-check_expiration)) + if date < good_until: + return False + elif callable(ds_callback) and ds_callback(stamp): + return False + elif type(bits) is not int: + return True + else: + hex_digits = int(floor(bits/4)) + return sha(stamp).hexdigest().startswith('0'*hex_digits) + elif stamp.startswith('1:'): # Version 1 + try: + claim, date, res, ext, rand, counter = stamp[2:].split(':') + except ValueError: + ERR.write("Malformed version 1 hashcash stamp!\n") + return False + if resource is not None and resource != res: + return False + elif type(bits) is int and bits > int(claim): + return False + elif check_expiration is not None: + good_until = strftime("%y%m%d%H%M%S", localtime(time()-check_expiration)) + if date < good_until: + return False + elif callable(ds_callback) and ds_callback(stamp): + return False + else: + hex_digits = int(floor(int(claim)/4)) + return sha(stamp).hexdigest().startswith('0'*hex_digits) + else: # Unknown ver or generalized hashcash + ERR.write("Unknown hashcash version: Minimal authentication!\n") + if type(bits) is not int: + return True + elif resource is not None and stamp.find(resource) < 0: + return False + else: + hex_digits = int(floor(bits/4)) + return sha(stamp).hexdigest().startswith('0'*hex_digits) + +def is_doublespent(stamp): + """Placeholder for double spending callback function + + The check() function may accept a 'ds_callback' argument, e.g. + check(stamp, "mertz@gnosis.cx", bits=20, ds_callback=is_doublespent) + + This placeholder simply reports stamps as not being double spent. + """ + return False + +if __name__=='__main__': + # Import Psyco if available + try: + import psyco + psyco.bind(_mint) + except ImportError: + pass + import optparse + out, err = sys.stdout.write, sys.stderr.write + parser = optparse.OptionParser(version="%prog 0.1", + usage="%prog -c|-m [-b bits] [string|STDIN]") + parser.add_option('-b', '--bits', type='int', dest='bits', default=20, + help="Specify required collision bits" ) + parser.add_option('-m', '--mint', help="Mint a new stamp", + action='store_true', dest='mint') + parser.add_option('-c', '--check', help="Check a stamp for validity", + action='store_true', dest='check') + parser.add_option('-s', '--timer', help="Time the operation performed", + action='store_true', dest='timer') + parser.add_option('-n', '--raw', help="Suppress trailing newline", + action='store_true', dest='raw') + (options, args) = parser.parse_args() + start = time() + if options.mint: action = mint + elif options.check: action = check + else: + out("Try: %s --help\n" % sys.argv[0]) + sys.exit() + if args: out(str(action(args[0], bits=options.bits))) + else: out(str(action(sys.stdin.read(), bits=options.bits))) + if not options.raw: sys.stdout.write('\n') + if options.timer: + timer = time()-start + err("Completed in %0.4f seconds (%d hashes per second)\n" % + (timer, tries[0]/timer)) +