Last active
July 19, 2018 15:16
-
-
Save grantr/4757832 to your computer and use it in GitHub Desktop.
CurveCP handshake protocol in Ruby
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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
You might also mention forward secrecy (even in the presence of a MitM) in your overview of the protocol's desirable properties. It's fairly unique to transport encryption protocols like this.