Skip to content

Instantly share code, notes, and snippets.

@sysbot
Forked from grantr/curvecp_handshake.rb
Created October 14, 2015 06:20
Show Gist options
  • Save sysbot/4b23e9765f1fd13ec5aa to your computer and use it in GitHub Desktop.
Save sysbot/4b23e9765f1fd13ec5aa to your computer and use it in GitHub Desktop.
CurveCP handshake protocol in Ruby
# A demonstration of the CurveCP handshake protocol. This protocol has many
# favorable security properties described at http://curvecp.org.
#
# In addition to its security advantages, it has the following favorable properties:
# * Needs only 2 messages (1 from client, 1 from server) before application
# messages can be exchanged (3 before the server can send application messages)
# * Does not require the server to keep protocol state between handshake messages.
#
# An overview of the protocol:
#
# Definitions:
# S : Server long term public key
# S': Server short term public key
# s': Server short term private key
# C : Client long term public key
# C': Client short term public key
# V : Vouch:
# 16 byte nonce + Box to S from C containing C'
# K : Cookie:
# 16 byte nonce + SecretBox under minute-key containing C' and s'
# minute-key: A 32-byte random string rotated every minute (the current and
# previous key are both valid)
#
# Prerequisites:
# Client knows S and the domain name of the server
#
# The protocol flow:
#
# (Note some elements of messages are omitted here for clarity, see the CurveCP
# site for details)
#
# 1. Client sends HelloMessage
# - C'
# - 8 byte nonce
# - 64 null bytes encrypted with a Box to S from C'
#
# 2. Server sends CookieMessage
# - 16 byte nonce
# - Box to C' from S containing S' and K
#
# 3. Client sends InitiateMessage
# - C'
# - K
# - 8-byte nonce
# - Box to S' from C' containing:
# - C
# - V
# - server's domain name
# - a message (optional)
#
# The handshake has concluded at this point, and both server and client are
# free to send messages.
#
# 4. Server sends Message
# - 8 byte nonce
# - Box to C' from S' containing a message
#
# 5. Client sends Message
# - C'
# - 8 byte nonce
# - Box to S' from C' containing a message
#
#
# ALERT This implementation has not been inspected or verified by cryptography
# experts. Additionally, the CurveCP protocol itself is a work in progress. While
# the handshake protocol uses only proven primitives from the NaCL library, it
# is possible that weaknesses will be discovered. See the CurveCP website above
# for more information.
#
# Finally, ALERT comments throughout the code denote parts of the implementation
# that are noncompliant or not ready for production use. Read these carefully
# before basing an implementation on this code.
require 'celluloid'
require 'rbnacl'
module CurveCPHandshake
class Client
include Celluloid
attr_accessor :long_term_key
def initialize(long_term_key=Crypto::PrivateKey.generate)
@long_term_key = long_term_key
end
# returns a Connection
def connect(server, options={})
# ALERT Normally the server long term key would be pre-distributed
server_long_term_pubkey = options[:server_key] || server.long_term_public_key
# ALERT Normally the domain name would be pre-distributed
domain_name = options[:domain_name] || server.domain_name
# The initial message is optional
initial_message = options[:initial_message]
# Generate a client short term key
short_term_key = Crypto::PrivateKey.generate
# Generate a hello message
hello_message = HelloMessage.new(server_long_term_pubkey, short_term_key)
# add our mailbox so the server can reply
# ALERT Normally this would be handled by the transport layer
hello_message.reply_mailbox = Actor.current.mailbox
# send the hello message to the server
server.mailbox << hello_message
# Wait for a cookie from the server
cookie_message = receive { |msg| msg.is_a?(CookieMessage) }
# Extract the server's short term pubkey and cookie
server_short_term_pubkey, cookie = cookie_message.open(server_long_term_pubkey, short_term_key)
# Generate a vouch so the server knows we are authentic
vouch = Vouch.generate(server_long_term_pubkey, long_term_key, short_term_key.public_key)
# Generate an initiate message and send it to the server
# This contains the initial message
initiate_message = InitiateMessage.new(server_short_term_pubkey, short_term_key, cookie, long_term_key.public_key, vouch, domain_name, initial_message)
server.mailbox << initiate_message
# Now the connection can be used to send further messages
Connection.new(server_short_term_pubkey, short_term_key, :client)
end
end
class Server
include Celluloid
attr_accessor :long_term_key
attr_accessor :minute_key, :prev_minute_key
attr_accessor :domain_name
def initialize(long_term_key=Crypto::PrivateKey.generate)
@long_term_key = long_term_key
@client_connections = {}
# generate minute keys and rotate them
rotate_minute_key
every(60) { rotate_minute_key }
end
def domain_name
@domain_name ||= "example.com"
end
def long_term_public_key
long_term_key.public_key
end
def rotate_minute_key
self.prev_minute_key = minute_key || Crypto::Random.random_bytes(32)
self.minute_key = Crypto::Random.random_bytes(32)
end
def accept
accept_hello
# ALERT For testing
if block_given?
yield
end
accept_initiate
end
# returns a connection and initial message
def accept_hello
# Wait for a hello from a client
hello_message = receive { |msg| msg.is_a?(HelloMessage) }
# The client short term public key is sent in the clear
client_short_term_pubkey = hello_message.client_short_term_pubkey
# Ensure the hello message is valid, that is, the sender has access to
# the client short term private key and the server long term public key
raise "invalid hello message" unless hello_message.valid?(long_term_key)
# Generate a server short term key
short_term_key = Crypto::PrivateKey.generate
# Generate a cookie for the client to authenticate
# The cookie is also a state storage mechanism. It allows the handshake
# protocol to be stateless so that different threads can handle hello and
# initiate messages.
cookie = Cookie.generate(client_short_term_pubkey, short_term_key, minute_key)
# Generate a cookie message and send it to the client
cookie_message = CookieMessage.new(client_short_term_pubkey, long_term_key, short_term_key.public_key, cookie.to_bytes)
hello_message.reply_mailbox << cookie_message
end
def accept_initiate
# Wait for an initiate from a client
initiate_message = receive { |msg| msg.is_a?(InitiateMessage) }
# The client short term public key is sent in the clear
client_short_term_pubkey = initiate_message.client_short_term_pubkey
# The cookie is also sent in the clear
# This was sent to the server by the client and is returned unchanged
cookie = Cookie.new(initiate_message.cookie)
# Open the cookie to retrieve the boxed client short term public key and
# the server short term private key
# If the current minute key doesn't work, try the previous one
boxed_client_short_term_pubkey, short_term_key = begin
cookie.open(minute_key)
rescue Crypto::CryptoError
cookie.open(prev_minute_key)
end
# Ensure the boxed public key matches the one sent in the clear
# This is safe because Crypto::PublicKey implements constant-time
# equality
raise "boxed client key does not match" unless client_short_term_pubkey == boxed_client_short_term_pubkey
# Extract the client's long term public key, vouch, domain name, and initial message
client_long_term_pubkey, vouch, sent_domain_name, initial_message = initiate_message.open(short_term_key)
# Ensure the sent domain name matches our domain name
# ALERT This is potentially vulnerable to timing attacks. Constant-time
# comparison would probably be more secure.
raise "domain names do not match (#{sent_domain_name}, #{domain_name})" unless sent_domain_name == domain_name
# Open the vouch to retrieve the boxed client short term public key
vouched_client_short_term_pubkey = vouch.open(client_long_term_pubkey, long_term_key)
# Ensure the vouched public key matches the one sent in the clear
raise "vouched client key does not match" unless client_short_term_pubkey == vouched_client_short_term_pubkey
# ALERT Any application-specific logic for authorizing the client long
# term key would go here.
# The initiate message is valid, return a new Connection and the initial message
[Connection.new(client_short_term_pubkey, short_term_key, :server), initial_message]
end
end
class Connection
attr_accessor :public_key, :private_key, :type
def initialize(public_key, private_key, type)
raise "invalid type" unless [:server, :client].include?(type)
@box = Crypto::Box.new(public_key, private_key)
@type = type
end
def box(bytes)
Message.new(@box, @type, bytes)
end
def open(message)
message.open(@box)
end
end
module NonceGenerator
# Nonces can never be used more than once for a particular key!
#
# ALERT In real life, you would use a generator for each key so that
# information about the number of clients is not leaked.
# Rules for short term nonces:
# Must be 8 bytes
# Nonces must strictly increase for a particular short term key
# Not required to start at 0
# Not required to increase by 1
def short_term_nonce
@counter = (@counter ? @counter + 1 : 0)
# CurveCP specifies little-endian
[@counter].pack("Q<")
end
# Rules for long term nonces:
# Must be 16 bytes
# Not required to start at 0
# Not required to strictly increase
# Must not be used more than once, even if the process restarts. The docs
# mention two possible strategies for dealing with this: persistent counters
# and timestamps.
# ALERT In real life, long term nonce generators must be persisted. Even if
# the timestamp strategy is used, the timestamp must be persisted to ensure
# the clock never runs backwards.
# counter strategy
def long_term_nonce_counter
short_term_nonce + Crypto::Random.random_bytes(8)
end
# timestamp strategy
def long_term_nonce_timestamp
# microseconds since epoch
timestamp = (Time.now.to_f*1_000_000).to_i
# CurveCP specifies little-endian
timestamp.pack("Q<") + Crypto::Random_bytes(8)
end
end
class HelloMessage
extend NonceGenerator
attr_accessor :client_short_term_pubkey
attr_accessor :nonce, :ciphertext
attr_accessor :reply_mailbox
def initialize(server_long_term_pubkey, client_short_term_privkey)
@client_short_term_pubkey = client_short_term_privkey.public_key
@nonce = self.class.short_term_nonce
@ciphertext = Crypto::Box.new(server_long_term_pubkey, client_short_term_privkey).box(nonce_string, Crypto::Util.zeros(64))
end
def nonce_string
"CurveCP-client-H" + @nonce
end
# ALERT In real life, Hello messages should be constructed so their length
# is greater than or equal to the length of Cookie messages. This is to
# avoid an amplification attack whereby a client can use small bandwidth to
# overwhelm a server with larger bandwidth.
def valid?(server_long_term_privkey)
string = Crypto::Box.new(@client_short_term_pubkey, server_long_term_privkey).open(nonce_string, @ciphertext)
zeros = Crypto::Util.zeros(32)
Crypto::Util.verify32(string[0, 32], zeros) && Crypto::Util.verify32(string[32, 32], zeros)
end
end
class CookieMessage
extend NonceGenerator
attr_accessor :nonce, :ciphertext
def initialize(client_short_term_pubkey, server_long_term_privkey, server_short_term_pubkey, cookie)
@nonce = self.class.long_term_nonce_counter
@ciphertext = Crypto::Box.new(client_short_term_pubkey, server_long_term_privkey).box(nonce_string, server_short_term_pubkey.to_bytes + cookie)
end
def nonce_string
"CurveCPK" + @nonce
end
def open(server_long_term_pubkey, client_short_term_privkey)
plaintext = Crypto::Box.new(server_long_term_pubkey, client_short_term_privkey).open(nonce_string, @ciphertext)
server_short_term_pubkey, cookie = plaintext.unpack("a32a96")
[Crypto::PublicKey.new(server_short_term_pubkey), cookie]
end
end
class Cookie
extend NonceGenerator
NONCE_PREFIX = "minute-k"
def self.generate(client_short_term_pubkey, server_short_term_privkey, minute_key)
nonce = long_term_nonce_counter
nonce_string = NONCE_PREFIX + nonce
ciphertext = Crypto::SecretBox.new(minute_key).box(nonce_string, client_short_term_pubkey.to_bytes + server_short_term_privkey.to_bytes)
new(nonce + ciphertext)
end
def initialize(bytes)
@cookie = bytes
end
def open(minute_key)
nonce, ciphertext = @cookie.unpack("a16a80")
nonce_string = NONCE_PREFIX + nonce
plaintext = Crypto::SecretBox.new(minute_key).open(nonce_string, ciphertext)
client_short_term_pubkey, server_short_term_privkey = plaintext.unpack("a32a32")
[Crypto::PublicKey.new(client_short_term_pubkey), Crypto::PrivateKey.new(server_short_term_privkey)]
end
def to_bytes
@cookie
end
end
class InitiateMessage
extend NonceGenerator
attr_accessor :client_short_term_pubkey
attr_accessor :cookie
attr_accessor :nonce, :ciphertext
def initialize(server_short_term_pubkey, client_short_term_privkey, cookie, client_long_term_pubkey, vouch, domain_name, message)
@client_short_term_pubkey = client_short_term_privkey.public_key
@cookie = cookie
@nonce = self.class.short_term_nonce
@ciphertext = Crypto::Box.new(server_short_term_pubkey, client_short_term_privkey).box(nonce_string, [client_long_term_pubkey.to_bytes, vouch.to_bytes, domain_name, message].pack("a32a64a256a*"))
end
def nonce_string
"CurveCP-client-I" + @nonce
end
def open(server_short_term_privkey)
plaintext = Crypto::Box.new(@client_short_term_pubkey, server_short_term_privkey).open(nonce_string, ciphertext)
# Use A256 to unpack the domain name so null padding is not retained
client_long_term_pubkey, vouch, domain_name, message = plaintext.unpack("a32a64A256a*")
[Crypto::PublicKey.new(client_long_term_pubkey), Vouch.new(vouch), domain_name, message]
end
end
class Vouch
extend NonceGenerator
NONCE_PREFIX = "CurveCPV"
def self.generate(server_long_term_pubkey, client_long_term_privkey, client_short_term_pubkey)
nonce = long_term_nonce_counter
nonce_string = NONCE_PREFIX + nonce
ciphertext = Crypto::Box.new(server_long_term_pubkey, client_long_term_privkey).box(nonce_string, client_short_term_pubkey.to_bytes)
new(nonce + ciphertext)
end
def initialize(bytes)
@vouch = bytes
end
def open(client_long_term_pubkey, server_long_term_privkey)
nonce, ciphertext = @vouch.unpack("a16a48")
nonce_string = NONCE_PREFIX + nonce
client_short_term_pubkey = Crypto::Box.new(client_long_term_pubkey, server_long_term_privkey).open(nonce_string, ciphertext)
Crypto::PublicKey.new(client_short_term_pubkey)
end
def to_bytes
@vouch
end
end
class Message
extend NonceGenerator
attr_accessor :nonce, :box
def initialize(box, type, bytes)
raise "invalid type" unless [:server, :client].include?(type)
@nonce = self.class.short_term_nonce
@type = type
@ciphertext = box.box(nonce_string, bytes)
end
def nonce_string
"CurveCP-#{@type}-M" + @nonce
end
def open(box)
box.open(nonce_string, @ciphertext)
end
end
end
if $0 == __FILE__
require 'minitest/spec'
require 'minitest/autorun'
include CurveCPHandshake
Celluloid.logger = nil
def connect(options={})
client = Client.new
server = Server.new
connected = client.future.connect(server, options)
accepted = server.future.accept
client_connection = connected.value(0.1)
server_connection, initial_message = accepted.value(0.1)
[client_connection, server_connection, initial_message]
end
describe CurveCPHandshake do
it 'should transmit an initial message' do
_, _, initial_message = connect(initial_message: "hello!")
initial_message.must_equal "hello!"
end
it 'should exchange further messages' do
client_conn, server_conn = connect
m1 = client_conn.box("message 1")
m2 = server_conn.box("message 2")
server_conn.open(m1).must_equal "message 1"
client_conn.open(m2).must_equal "message 2"
end
it 'should raise if the server long term key is incorrect' do
lambda {
client = Client.new
server = Server.new
connected = client.future.connect(server, server_key: Crypto::PrivateKey.generate)
server.accept
}.must_raise(Crypto::CryptoError)
end
it 'should raise if the server domain is incorrect' do
client = Client.new
server = Server.new
server.domain_name = "foobar.com"
connected = client.future.connect(server, domain_name: "foobaz.com")
lambda {
server.accept
}.must_raise(RuntimeError)
end
it 'should not raise if the minute key has rotated once' do
client = Client.new
server = Server.new
connected = client.future.connect(server)
server.accept do
server.rotate_minute_key
end
end
it 'should raise if the minute key has rotated twice' do
client = Client.new
server = Server.new
connected = client.future.connect(server)
lambda {
server.accept do
server.rotate_minute_key
server.rotate_minute_key
end
}.must_raise(Crypto::CryptoError)
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment