Browse Source

Implement the Sphinx reblinding procedure

There are two versions: the DH version is implemented by the
Sphinx.server and Sphinx.client static methods; this is the version used
for the relay selection keys.  The NTor version is implemented by the
NTor class itself, using a new optional sphinx_domainsep parameter; this
is the version used for the data encryption keys.

This implementation does a few more public-key operations than it would
strictly need to if we were using a group implementation that (unlike
nacl) supported the operations we needed directly.  The biggest issue is
that nacl insists the high bit is set on private keys, which means we
can't just multiply private keys together to get a new private key, and
do a single DH operation with that resulting key; we have to perform a
linear number of DH operations instead, per node in the circuit, so a
quadratic number of DH operations total.
Ian Goldberg 4 years ago
parent
commit
df1f1b0048
1 changed files with 213 additions and 8 deletions
  1. 213 8
      relay.py

+ 213 - 8
relay.py

@@ -8,6 +8,7 @@ import nacl.utils
 import nacl.signing
 import nacl.public
 import nacl.hash
+import nacl.bindings
 
 import network
 import dirauth
@@ -199,6 +200,93 @@ class RelayFallbackTerminationError(Exception):
     relay."""
 
 
+class Sphinx:
+    """Implement the public-key reblinding technique based on Sphinx.
+    This does a few more public-key operations than it would strictly
+    need to if we were using a group implementation that (unlike nacl)
+    supported the operations we needed directly.  The biggest issue is
+    that nacl insists the high bit is set on private keys, which means
+    we can't just multiply private keys together to get a new private
+    key, and do a single DH operation with that resulting key; we have
+    to perform a linear number of DH operations instead, per node in the
+    circuit, so a quadratic number of DH operations total."""
+
+    @staticmethod
+    def makeblindkey(shared_secret, domain_separator, perfstats):
+        """Create a Sphinx reblinding key (a PrivateKey) out of a shared
+        secret and a domain separator (both bytestrings).  The domain
+        separator is just a constant bytestring like b'data' or
+        b'circuit' for the data-protecting and circuit-protecting
+        public-key elements respectively."""
+        rawkey = nacl.hash.sha256(domain_separator + shared_secret,
+                encoder=nacl.encoding.RawEncoder)
+        perfstats.keygens += 1
+
+        # The PrivateKey constructor does the Curve25519 pinning of
+        # certain bits of the key to 0 and 1
+        return nacl.public.PrivateKey(rawkey)
+
+    @staticmethod
+    def reblindpubkey(blindkey, pubkey, perfstats):
+        """Create a Sphinx reblinded PublicKey out of a reblinding key
+        (output by makeblindkey) and a (possibly already reblinded)
+        PublicKey."""
+        new_pubkey =  nacl.bindings.crypto_scalarmult(bytes(blindkey),
+                bytes(pubkey))
+        perfstats.dhs += 1
+
+        return nacl.public.PublicKey(new_pubkey)
+
+    @staticmethod
+    def client(client_privkey, blindkey_list, server_pubkey,
+            domain_separator, is_last, perfstats):
+        """Given the client's PrivateKey, a (possibly empty) list of
+        reblinding keys, and the server's PublicKey, produce the shared
+        secret and the new blinding key (to add to the list).  The
+        domain separator is as above.  If is_last is true, don't bother
+        creating the new blinding key, since this is the last iteration,
+        and we won't be using it."""
+        reblinded_server_pubkey = server_pubkey
+        for blindkey in blindkey_list:
+            reblinded_server_pubkey = Sphinx.reblindpubkey(blindkey,
+                    reblinded_server_pubkey, perfstats)
+
+        sharedsecret = nacl.public.Box(client_privkey,
+                reblinded_server_pubkey).shared_key()
+        perfstats.dhs += 1
+
+        if is_last:
+            blindkey = None
+        else:
+            blindkey = Sphinx.makeblindkey(sharedsecret,
+                    domain_separator, perfstats)
+
+        return sharedsecret, blindkey
+
+    @staticmethod
+    def server(client_pubkey, server_privkey, domain_separator, is_last,
+            perfstats):
+        """Given the client's PublicKey and the server's PrivateKey,
+        produce the shared secret and the new reblinded client
+        PublicKey.  The domain separator is as above.  If is_last is
+        True, don't bother generating the new PublicKey, since we're the
+        last server in the chain, and won't be using it."""
+        sharedsecret = nacl.public.Box(server_privkey,
+                client_pubkey).shared_key()
+        perfstats.dhs += 1
+
+        if is_last:
+            blinded_pubkey = None
+        else:
+            blindkey = Sphinx.makeblindkey(sharedsecret, domain_separator,
+                    perfstats)
+
+            blinded_pubkey = Sphinx.reblindpubkey(blindkey, client_pubkey,
+                    perfstats)
+
+        return sharedsecret, blinded_pubkey
+
+
 class NTor:
     """A class implementing the ntor one-way authenticated key agreement
     scheme.  The details are not exactly the same as either the ntor
@@ -207,6 +295,9 @@ class NTor:
 
     def __init__(self, perfstats):
         self.perfstats = perfstats
+        # Only used for Single-Pass Walking Onions; it is the sequence
+        # of blinding keys used by Sphinx
+        self.blinding_keys = []
 
     def request(self):
         """Create the ntor request message: X = g^x."""
@@ -215,10 +306,13 @@ class NTor:
         return self.client_ephem_key.public_key
 
     @staticmethod
-    def reply(onion_privkey, idpubkey, client_pubkey, perfstats):
+    def reply(onion_privkey, idpubkey, client_pubkey, perfstats,
+            sphinx_domainsep=None):
         """The server calls this static method to produce the ntor reply
         message: (Y = g^y, B = g^b, A = H(M, "verify")) and the shared
-        secret S = H(M, "secret") for M = (X^y,X^b,ID,B,X,Y)."""
+        secret S = H(M, "secret") for M = (X^y,X^b,ID,B,X,Y). If
+        sphinx_domainsep is not None, also compute and return the Sphinx
+        reblinded client request to pass to the next server."""
         server_ephem_key = nacl.public.PrivateKey.generate()
         perfstats.keygens += 1
         xykey = nacl.public.Box(server_ephem_key, client_pubkey).shared_key()
@@ -230,18 +324,43 @@ class NTor:
                 server_ephem_key.public_key.encode(encoder=nacl.encoding.RawEncoder)
         A = nacl.hash.sha256(M + b'verify', encoder=nacl.encoding.RawEncoder)
         S = nacl.hash.sha256(M + b'secret', encoder=nacl.encoding.RawEncoder)
-        return ((server_ephem_key.public_key, onion_privkey.public_key, A), S)
 
-    def verify(self, reply, onion_pubkey, idpubkey):
+        if sphinx_domainsep is not None:
+            blindkey = Sphinx.makeblindkey(S, sphinx_domainsep, perfstats)
+            blinded_client_pubkey = Sphinx.reblindpubkey(blindkey,
+                    client_pubkey, perfstats)
+            return ((server_ephem_key.public_key, onion_privkey.public_key, A),
+                    S), blinded_client_pubkey
+        else:
+            return ((server_ephem_key.public_key, onion_privkey.public_key, A),
+                    S)
+
+    def verify(self, reply, onion_pubkey, idpubkey, sphinx_domainsep=None):
         """The client calls this method to verify the ntor reply
         message, passing the onion and id public keys for the server
-        it's expecting to be talking to .  Returns the shared secret on
-        success, or raises ValueError on failure."""
+        it's expecting to be talking to.  If sphinx_domainsep is not
+        None, also compute the reblinding key so that the client can
+        reuse this same NTor object for the next server.  Returns the
+        shared secret on success, or raises ValueError on failure."""
         server_ephem_pubkey, server_onion_pubkey, authtag = reply
         if onion_pubkey != server_onion_pubkey:
             raise ValueError("NTor onion pubkey mismatch")
-        xykey = nacl.public.Box(self.client_ephem_key, server_ephem_pubkey).shared_key()
-        xbkey = nacl.public.Box(self.client_ephem_key, onion_pubkey).shared_key()
+        # We use the blinding keys if present; if they're not present
+        # (because we're not in Single-Pass Walking Onions), the loops
+        # are just empty anyway, so everything will work in the usual
+        # unblinded way.
+        reblinded_server_ephem_pubkey = server_ephem_pubkey
+        for blindkey in self.blinding_keys:
+            reblinded_server_ephem_pubkey = Sphinx.reblindpubkey(blindkey,
+                    reblinded_server_ephem_pubkey, self.perfstats)
+        xykey = nacl.public.Box(self.client_ephem_key,
+                reblinded_server_ephem_pubkey).shared_key()
+        reblinded_onion_pubkey = onion_pubkey
+        for blindkey in self.blinding_keys:
+            reblinded_onion_pubkey = Sphinx.reblindpubkey(blindkey,
+                    reblinded_onion_pubkey, self.perfstats)
+        xbkey = nacl.public.Box(self.client_ephem_key,
+                reblinded_onion_pubkey).shared_key()
         self.perfstats.dhs += 2
         M = xykey + xbkey + \
                 idpubkey.encode(encoder=nacl.encoding.RawEncoder) + \
@@ -251,6 +370,11 @@ class NTor:
         S = nacl.hash.sha256(M + b'secret', encoder=nacl.encoding.RawEncoder)
         if Acheck != authtag:
             raise ValueError("NTor auth mismatch")
+
+        if sphinx_domainsep is not None:
+            blindkey = Sphinx.makeblindkey(S, sphinx_domainsep,
+                    self.perfstats)
+            self.blinding_keys.append(blindkey)
         return S
 
 
@@ -970,3 +1094,84 @@ if __name__ == '__main__':
     R, S = NTor.reply(relays[1].onionkey, idpubkey, req, perfstats)
     S2 = nt.verify(R, onionpubkey, idpubkey)
     print(S == S2)
+
+    # Test the Sphinx class: DH version (for the path selection keys)
+
+    server1_key = nacl.public.PrivateKey.generate()
+    server2_key = nacl.public.PrivateKey.generate()
+    server3_key = nacl.public.PrivateKey.generate()
+    client_key = nacl.public.PrivateKey.generate()
+
+    # Check that normal DH is working
+    ckey = nacl.public.Box(client_key, server1_key.public_key).shared_key()
+    skey = nacl.public.Box(server1_key, client_key.public_key).shared_key()
+    assert(ckey == skey)
+
+    # Transform the client pubkey with Sphinx as it passes through the
+    # servers and record the resulting shared secrets
+    blinded_client_pubkey = client_key.public_key
+    s1secret, blinded_client_pubkey = Sphinx.server(blinded_client_pubkey,
+        server1_key, b'circuit', False, perfstats)
+    s2secret, blinded_client_pubkey = Sphinx.server(blinded_client_pubkey,
+        server2_key, b'circuit', False, perfstats)
+    s3secret, _ = Sphinx.server(blinded_client_pubkey,
+        server3_key, b'circuit', True, perfstats)
+
+    # Hopefully matching keys on the client side
+    blinding_keys = []
+    c1secret, blind_key = Sphinx.client(client_key, blinding_keys,
+        server1_key.public_key, b'circuit', False, perfstats)
+    blinding_keys.append(blind_key)
+    c2secret, blind_key = Sphinx.client(client_key, blinding_keys,
+        server2_key.public_key, b'circuit', False, perfstats)
+    blinding_keys.append(blind_key)
+    c3secret, _ = Sphinx.client(client_key, blinding_keys,
+        server3_key.public_key, b'circuit', True, perfstats)
+
+    assert(s1secret == c1secret)
+    assert(s2secret == c2secret)
+    assert(s3secret == c3secret)
+    print('Sphinx DH test successful')
+
+    # End test of Sphinx (DH version)
+
+    # Test the Sphinx class: NTor version (for the path selection keys)
+
+    server1_idkey = nacl.signing.SigningKey.generate().verify_key
+    server2_idkey = nacl.signing.SigningKey.generate().verify_key
+    server3_idkey = nacl.signing.SigningKey.generate().verify_key
+    server1_onionkey = nacl.public.PrivateKey.generate()
+    server2_onionkey = nacl.public.PrivateKey.generate()
+    server3_onionkey = nacl.public.PrivateKey.generate()
+    client_ntor = NTor(perfstats)
+
+    # Client's initial message
+    client_pubkey = client_ntor.request()
+
+    # Transform the client pubkey with Sphinx as it passes through the
+    # servers and record the resulting shared secrets
+    blinded_client_pubkey = client_pubkey
+    (s1reply, s1secret), blinded_client_pubkey = NTor.reply(
+            server1_onionkey, server1_idkey, blinded_client_pubkey,
+            perfstats, b'data')
+    (s2reply, s2secret), blinded_client_pubkey = NTor.reply(
+            server2_onionkey, server2_idkey, blinded_client_pubkey,
+            perfstats, b'data')
+    (s3reply, s3secret) = NTor.reply(
+            server3_onionkey, server3_idkey, blinded_client_pubkey,
+            perfstats)
+
+    # Hopefully matching keys on the client side
+    c1secret = client_ntor.verify(s1reply, server1_onionkey.public_key,
+            server1_idkey, b'data')
+    c2secret = client_ntor.verify(s2reply, server2_onionkey.public_key,
+            server2_idkey, b'data')
+    c3secret = client_ntor.verify(s3reply, server3_onionkey.public_key,
+            server3_idkey)
+
+    assert(s1secret == c1secret)
+    assert(s2secret == c2secret)
+    assert(s3secret == c3secret)
+    print('Sphinx NTor test successful')
+
+    # End test of Sphinx (NTor version)