diff --git a/README.md b/README.md index 4bd0022..4c449e1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ signature = Cox.sign_detached(message, signing_pair.secret) Cox.verify_detached(signature, message, signing_pair.public) # => true ``` +# Key derivation +kdf = Cox::Kdf.new + +# kdf.derive(8_byte_context, subkey_size, subkey_id) +subkey1 = kdf.derive "context1", 16, 0 +subkey2 = kdf.derive "context1", 16, 1 +subkey3 = kdf.derive "context2", 32, 0 +subkey4 = kdf.derive "context2", 64, 1 + ## Contributing 1. Fork it ( https://github.com/andrewhamon/cox/fork ) diff --git a/spec/cox/blake2b_spec.cr b/spec/cox/blake2b_spec.cr new file mode 100644 index 0000000..f858174 --- /dev/null +++ b/spec/cox/blake2b_spec.cr @@ -0,0 +1,89 @@ +require "../spec_helper" + +libsodium_comparisons = [ + { + key: nil, + input: "", + output: "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8", + out_size: 32, + }, +] + +# from https://github.com/BLAKE2/BLAKE2/tree/master/testvectors +test_vectors = [ + { + key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + input: "", + output: "10ebb67700b1868efb4417987acf4690ae9d972fb7a590c2f02871799aaa4786b5e996e8f0f4eb981fc214b005f42d2ff4233499391653df7aefcbc13fc51568", + }, + { + key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + input: "00", + output: "961f6dd1e4dd30f63901690c512e78e4b45e4742ed197c3c5e45c549fd25f2e4187b0bc9fe30492b16b0d0bc4ef9b0f34c7003fac09a5ef1532e69430234cebd", + }, +] + + +describe Cox::Blake2b do + it "libsodium comparisons" do + libsodium_comparisons.each do |vec| + d = Cox::Blake2b.new vec[:out_size], key: vec[:key].try(&.hexbytes) + d.update vec[:input].hexbytes + d.hexdigest.should eq vec[:output] + end + end + + it "test vectors" do + test_vectors.each do |vec| + d = Cox::Blake2b.new 64, key: vec[:key].hexbytes + d.update vec[:input].hexbytes + d.hexdigest.should eq vec[:output] + end + end + + it "produces different output with different salt or personal params" do + key = Bytes.new Cox::Blake2b::KEY_SIZE + salt = Bytes.new Cox::Blake2b::SALT_SIZE + salt2 = Bytes.new Cox::Blake2b::SALT_SIZE + salt2 = salt.dup + salt2[0] = 1 + personal = Bytes.new Cox::Blake2b::PERSONAL_SIZE + personal2 = personal.dup + personal2[0] = 1 + + + d = Cox::Blake2b.new key: key, salt: salt, personal: personal + d.update "foo".to_slice + output = d.hexdigest + + d = Cox::Blake2b.new key: key, salt: salt2, personal: personal + d.update "foo".to_slice + saltout = d.hexdigest + + d = Cox::Blake2b.new key: key, salt: salt, personal: personal2 + d.update "foo".to_slice + personalout = d.hexdigest + + output.should_not eq saltout + output.should_not eq personalout + saltout.should_not eq personalout + end + + it "raises on invalid " do + expect_raises ArgumentError do + Cox::Blake2b.new key: Bytes.new(128) + end + + expect_raises ArgumentError do + Cox::Blake2b.new salt: Bytes.new(1) + end + + expect_raises ArgumentError do + Cox::Blake2b.new salt: Bytes.new(128) + end + + expect_raises ArgumentError do + Cox::Blake2b.new personal: Bytes.new(128) + end + end +end diff --git a/spec/cox/kdf_spec.cr b/spec/cox/kdf_spec.cr new file mode 100644 index 0000000..35bbdfe --- /dev/null +++ b/spec/cox/kdf_spec.cr @@ -0,0 +1,26 @@ +require "../spec_helper" + +CONTEXT = "8_bytess" + +describe Cox::Kdf do + it "generates master key" do + kdf1 = Cox::Kdf.new + + # verify loading saved key + kdf2 = Cox::Kdf.from_base64 kdf1.to_base64 + + # verify generated subkey's are the same after loading + key1_s1 = kdf1.derive CONTEXT, 16, 0 + key2_s1 = kdf2.derive CONTEXT, 16, 0 + key1_s1.should eq key2_s1 + end + + it "generates different keys" do + kdf1 = Cox::Kdf.new + subkey1 = kdf1.derive CONTEXT, 16, 0 + subkey2 = kdf1.derive CONTEXT, 16, 1 + subkey1.should_not eq subkey2 + end + +# TODO: test exceptions +end diff --git a/spec/cox/pwhash_spec.cr b/spec/cox/pwhash_spec.cr new file mode 100644 index 0000000..b7f61c9 --- /dev/null +++ b/spec/cox/pwhash_spec.cr @@ -0,0 +1,22 @@ +require "../spec_helper" + +describe Cox::Pwhash do + it "hashes and verifies a password" do + pwhash = Cox::Pwhash.new + + # set to minimum to speed up tests + pwhash.memlimit = Cox::Pwhash::MEMLIMIT_MIN + pwhash.opslimit = Cox::Pwhash::OPSLIMIT_MIN + + pass = "1234" + hash = pwhash.hash_str pass + pwhash.verify hash, pass + expect_raises(Cox::Pwhash::PasswordVerifyError) do + pwhash.verify hash, "5678" + end + + pwhash.needs_rehash?(hash).should be_false + pwhash.opslimit = Cox::Pwhash::OPSLIMIT_MAX + pwhash.needs_rehash?(hash).should be_true + end +end diff --git a/spec/cox/secret_key_spec.cr b/spec/cox/secret_key_spec.cr new file mode 100644 index 0000000..0871d4e --- /dev/null +++ b/spec/cox/secret_key_spec.cr @@ -0,0 +1,16 @@ +require "../spec_helper" + +describe Cox::SecretKey do + it "encrypts/decrypts" do + key = Cox::SecretKey.random + + message = "foobar" + encrypted, nonce = key.encrypt_easy message + decrypted = key.decrypt_easy encrypted, nonce + message.should eq String.new(decrypted) + + expect_raises(Cox::DecryptionFailed) do + key.decrypt_easy "badmsgbadmsgbadmsgbadmsgbadmsg".to_slice, nonce + end + end +end diff --git a/src/cox.cr b/src/cox.cr index 96169fe..330844f 100644 --- a/src/cox.cr +++ b/src/cox.cr @@ -1,3 +1,15 @@ +require "random/secure" + +module Cox + class Error < ::Exception + end + class VerificationFailed < Error + end + class DecryptionFailed < Error + end +end + + require "./cox/*" module Cox @@ -5,7 +17,9 @@ module Cox data_buffer = data.to_slice data_size = data_buffer.bytesize output_buffer = Bytes.new(data_buffer.bytesize + LibSodium::MAC_BYTES) - LibSodium.crypto_box_easy(output_buffer.to_unsafe, data_buffer, data_size, nonce.pointer, recipient_public_key.pointer, sender_secret_key.pointer) + if LibSodium.crypto_box_easy(output_buffer.to_unsafe, data_buffer, data_size, nonce.pointer, recipient_public_key.pointer, sender_secret_key.pointer) != 0 + raise Error.new("crypto_box_easy") + end output_buffer end @@ -18,7 +32,9 @@ module Cox data_buffer = data.to_slice data_size = data_buffer.bytesize output_buffer = Bytes.new(data_buffer.bytesize - LibSodium::MAC_BYTES) - LibSodium.crypto_box_open_easy(output_buffer.to_unsafe, data_buffer.to_unsafe, data_size, nonce.pointer, sender_public_key.pointer, recipient_secret_key.pointer) + if LibSodium.crypto_box_open_easy(output_buffer.to_unsafe, data_buffer.to_unsafe, data_size, nonce.pointer, sender_public_key.pointer, recipient_secret_key.pointer) != 0 + raise DecryptionFailed.new("crypto_box_open_easy") + end output_buffer end @@ -27,7 +43,9 @@ module Cox message_buffer_size = message_buffer.bytesize signature_output_buffer = Bytes.new(LibSodium::SIGNATURE_BYTES) - LibSodium.crypto_sign_detached(signature_output_buffer.to_unsafe, 0, message_buffer.to_unsafe, message_buffer_size, secret_key.pointer) + if LibSodium.crypto_sign_detached(signature_output_buffer.to_unsafe, 0, message_buffer.to_unsafe, message_buffer_size, secret_key.pointer) != 0 + raise Error.new("crypto_sign_detached") + end signature_output_buffer end diff --git a/src/cox/blake2b.cr b/src/cox/blake2b.cr new file mode 100644 index 0000000..1848277 --- /dev/null +++ b/src/cox/blake2b.cr @@ -0,0 +1,108 @@ +require "openssl/digest/digest_base" + +module Cox + class Blake2b + # provides copying digest/hexdigest methods + include OpenSSL::DigestBase + + KEY_SIZE = LibSodium.crypto_generichash_blake2b_keybytes + KEY_SIZE_MIN = LibSodium.crypto_generichash_blake2b_keybytes_min + KEY_SIZE_MAX = LibSodium.crypto_generichash_blake2b_keybytes_max + + SALT_SIZE = LibSodium.crypto_generichash_blake2b_saltbytes + + PERSONAL_SIZE = LibSodium.crypto_generichash_blake2b_personalbytes + + OUT_SIZE = LibSodium.crypto_generichash_blake2b_bytes.to_i32 + OUT_SIZE_MIN = LibSodium.crypto_generichash_blake2b_bytes_min.to_i32 + OUT_SIZE_MAX = LibSodium.crypto_generichash_blake2b_bytes_max.to_i32 + + getter digest_size + + @state = StaticArray(UInt8, 384).new 0 + @key_size = 0 + @have_salt = false + @have_personal = false + + + # implemented as static array's so clone works without jumping through hoops. + @key = StaticArray(UInt8, 64).new 0 + @salt = StaticArray(UInt8, 16).new 0 + @personal = StaticArray(UInt8, 16).new 0 + + def initialize(@digest_size : Int32 = OUT_SIZE, key : Bytes? = nil, salt : Bytes? = nil, personal : Bytes? = nil) + if k = key + raise ArgumentError.new("key larger than KEY_SIZE_MAX, got #{k.bytesize}") if k.bytesize > KEY_SIZE_MAX + @key_size = k.bytesize + k.copy_to @key.to_slice + end + + if sa = salt + raise ArgumentError.new("salt must be SALT_SIZE bytes, got #{sa.bytesize}") if sa.bytesize != SALT_SIZE + sa.copy_to @salt.to_slice + @have_salt = true + end + + if pe = personal + raise ArgumentError.new("personal must be PERSONAL_SIZE bytes, got #{pe.bytesize}") if pe.bytesize != PERSONAL_SIZE + pe.copy_to @personal.to_slice + @have_personal = true + end + + reset + end + + def reset + key = @key_size > 0 ? @key.to_unsafe : nil + salt = @have_salt ? @salt.to_unsafe : nil + personal = @have_personal ? @personal.to_unsafe : nil + + if LibSodium.crypto_generichash_blake2b_init_salt_personal(@state, key, @key_size, @digest_size, salt, personal) != 0 + raise Cox::Error.new("blake2b_init_key_salt_personal") + end + end + + def update(data : Bytes) + if LibSodium.crypto_generichash_blake2b_update(@state, data, data.bytesize) != 0 + raise Cox::Error.new("crypto_generichash_blake2b_update") + end + + self + end + + # Destructive operation. Assumes you know what you are doing. + # Use .digest or .hexdigest instead. + def finish + dst = Bytes.new @digest_size + finish dst + dst + end + + # Destructive operation. Assumes you know what you are doing. + # Use .digest or .hexdigest instead. + def finish(dst : Bytes) : Bytes + if LibSodium.crypto_generichash_blake2b_final(@state, dst, dst.bytesize) != 0 + raise Cox::Error.new("crypto_generichash_blake2b_final") + end + + dst + end + + def clone + dup + end + + # :nodoc: + def __validate_sizes__ + state_size = LibSodium.crypto_generichash_blake2b_statebytes + abort "@state.bytesize doesn't match library version #{@state.to_slice.bytesize} #{state_size}" if @state.to_slice.bytesize < state_size + abort "@key.bytesize doesn't match library version" if @key.to_slice.bytesize != KEY_SIZE_MAX + abort "@salt.bytesize doesn't match library version #{@salt.to_slice.bytesize} #{SALT_SIZE}" if @salt.to_slice.bytesize != SALT_SIZE + abort "@personal.bytesize doesn't match library version #{@personal.to_slice.bytesize} #{PERSONAL_SIZE}" if @personal.to_slice.bytesize != SALT_SIZE + end + end + + Blake2b.new.__validate_sizes__ +end + + diff --git a/src/cox/kdf.cr b/src/cox/kdf.cr new file mode 100644 index 0000000..60fd277 --- /dev/null +++ b/src/cox/kdf.cr @@ -0,0 +1,47 @@ +module Cox + class Kdf + property bytes : Bytes + + def initialize(bytes : Bytes) + if bytes.bytesize != LibSodium::KDF_KEY_BYTES + raise ArgumentError.new("bytes must be #{LibSodium::KDF_KEY_BYTES}, got #{bytes.bytesize}") + end + + @bytes = bytes + end + + def initialize + @bytes = Random::Secure.random_bytes(LibSodium::KDF_KEY_BYTES) + end + + # context must be 8 bytes + # subkey_size must be 16..64 bytes as of libsodium 1.0.17 + def derive(context, subkey_size, subkey_id = 0) + if context.bytesize != LibSodium::KDF_CONTEXT_BYTES + raise ArgumentError.new("context must be #{LibSodium::KDF_CONTEXT_BYTES}, got #{context.bytesize}") + end + + subkey = Bytes.new subkey_size + if (ret = LibSodium.crypto_kdf_derive_from_key(subkey, subkey.bytesize, subkey_id, context, @bytes)) != 0 + raise Cox::Error.new("crypto_kdf_derive_from_key returned #{ret} (subkey size is probably out of range)") + end + subkey + end + + def pointer + bytes.to_unsafe + end + + def pointer(size) + bytes.pointer(size) + end + + def to_base64 + Base64.encode(bytes) + end + + def self.from_base64(encoded_key) + new(Base64.decode(encoded_key)) + end + end +end diff --git a/src/cox/lib_sodium.cr b/src/cox/lib_sodium.cr index 06ab66f..9303fbe 100644 --- a/src/cox/lib_sodium.cr +++ b/src/cox/lib_sodium.cr @@ -10,6 +10,26 @@ module Cox fun crypto_sign_publickeybytes() : LibC::SizeT fun crypto_sign_secretkeybytes() : LibC::SizeT fun crypto_sign_bytes() : LibC::SizeT + fun crypto_kdf_keybytes() : LibC::SizeT + fun crypto_kdf_contextbytes() : LibC::SizeT + fun crypto_pwhash_memlimit_min() : LibC::SizeT + fun crypto_pwhash_memlimit_interactive() : LibC::SizeT + fun crypto_pwhash_memlimit_max() : LibC::SizeT + fun crypto_pwhash_opslimit_min() : LibC::SizeT + fun crypto_pwhash_opslimit_interactive() : LibC::SizeT + fun crypto_pwhash_opslimit_moderate() : LibC::SizeT + fun crypto_pwhash_opslimit_sensitive() : LibC::SizeT + fun crypto_pwhash_opslimit_max() : LibC::SizeT + fun crypto_pwhash_strbytes() : LibC::SizeT + fun crypto_generichash_blake2b_statebytes : LibC::SizeT + fun crypto_generichash_blake2b_bytes : LibC::SizeT + fun crypto_generichash_blake2b_bytes_min : LibC::SizeT + fun crypto_generichash_blake2b_bytes_max : LibC::SizeT + fun crypto_generichash_blake2b_keybytes : LibC::SizeT + fun crypto_generichash_blake2b_keybytes_min : LibC::SizeT + fun crypto_generichash_blake2b_keybytes_max : LibC::SizeT + fun crypto_generichash_blake2b_saltbytes : LibC::SizeT + fun crypto_generichash_blake2b_personalbytes : LibC::SizeT PUBLIC_KEY_BYTES = crypto_box_publickeybytes() SECRET_KEY_BYTES = crypto_box_secretkeybytes() @@ -18,6 +38,25 @@ module Cox PUBLIC_SIGN_BYTES = crypto_sign_publickeybytes() SECRET_SIGN_BYTES = crypto_sign_secretkeybytes() SIGNATURE_BYTES = crypto_sign_bytes() + KDF_KEY_BYTES = crypto_kdf_keybytes() + KDF_CONTEXT_BYTES = crypto_kdf_contextbytes() + PWHASH_STR_BYTES = crypto_pwhash_strbytes() + + fun crypto_secretbox_easy( + output : Pointer(LibC::UChar), + data : Pointer(LibC::UChar), + data_size : LibC::ULongLong, + nonce : Pointer(LibC::UChar), + key : Pointer(LibC::UChar), + ) : LibC::Int + + fun crypto_secretbox_open_easy( + output : Pointer(LibC::UChar), + data : Pointer(LibC::UChar), + data_size : LibC::ULongLong, + nonce : Pointer(LibC::UChar), + key : Pointer(LibC::UChar), + ) : LibC::Int fun crypto_box_keypair( public_key_output : Pointer(LibC::UChar), @@ -61,5 +100,54 @@ module Cox message_size : LibC::ULongLong, public_key : Pointer(LibC::UChar) ) : LibC::Int + + fun crypto_kdf_derive_from_key( + subkey : Pointer(LibC::UChar), + subkey_len : LibC::SizeT, + subkey_id : UInt64, + ctx : Pointer(LibC::UChar), + key : Pointer(LibC::UChar) + ) : LibC::Int + + fun crypto_pwhash_str( + outstr : Pointer(LibC::UChar), + pass : Pointer(LibC::UChar), + pass_size : LibC::ULongLong, + optslimit : LibC::ULongLong, + memlimit : LibC::SizeT, + ) : LibC::Int + + fun crypto_pwhash_str_verify( + str : Pointer(LibC::UChar), + pass : Pointer(LibC::UChar), + pass_size : LibC::ULongLong, + ) : LibC::Int + + fun crypto_pwhash_str_needs_rehash( + str : Pointer(LibC::UChar), + optslimit : LibC::ULongLong, + memlimit : LibC::SizeT, + ) : LibC::Int + + fun crypto_generichash_blake2b_init_salt_personal( + state : Pointer(LibC::UChar), + key : Pointer(LibC::UChar), + key_len : UInt8, + out_len : UInt8, + salt : Pointer(LibC::UChar), + personal : Pointer(LibC::UChar), + ) : LibC::Int + + fun crypto_generichash_blake2b_update( + state : Pointer(LibC::UChar), + in : Pointer(LibC::UChar), + in_len : UInt64, + ) : LibC::Int + + fun crypto_generichash_blake2b_final( + state : Pointer(LibC::UChar), + output : Pointer(LibC::UChar), + output_len : UInt64, + ) : LibC::Int end end diff --git a/src/cox/pwhash.cr b/src/cox/pwhash.cr new file mode 100644 index 0000000..944da48 --- /dev/null +++ b/src/cox/pwhash.cr @@ -0,0 +1,49 @@ +module Cox + class Pwhash + class PasswordVerifyError < Cox::Error + end + + OPSLIMIT_MIN = LibSodium.crypto_pwhash_opslimit_min + OPSLIMIT_INTERACTIVE = LibSodium.crypto_pwhash_opslimit_interactive + OPSLIMIT_MODERATE = LibSodium.crypto_pwhash_opslimit_moderate + OPSLIMIT_SENSITIVE = LibSodium.crypto_pwhash_opslimit_sensitive + OPSLIMIT_MAX = LibSodium.crypto_pwhash_opslimit_max + + MEMLIMIT_MIN = LibSodium.crypto_pwhash_memlimit_min + MEMLIMIT_MAX = LibSodium.crypto_pwhash_memlimit_max + MEMLIMIT_INTERACTIVE = LibSodium.crypto_pwhash_memlimit_interactive + + property opslimit = OPSLIMIT_INTERACTIVE + property memlimit = MEMLIMIT_INTERACTIVE + + def hash_str(pass) + outstr = Bytes.new LibSodium::PWHASH_STR_BYTES + if LibSodium.crypto_pwhash_str(outstr, pass, pass.bytesize, @opslimit, @memlimit) != 0 + raise Cox::Error.new("crypto_pwhash_str") + end + outstr + end + + def verify(str, pass) + # BUG: verify str length + case LibSodium.crypto_pwhash_str_verify(str, pass, pass.bytesize) + when 0 + true + else + raise PasswordVerifyError.new + end + end + + def needs_rehash?(str) + # BUG: verify str length + case LibSodium.crypto_pwhash_str_needs_rehash(str, @opslimit, @memlimit) + when 0 + false + when 1 + true + else + raise Cox::Error.new("crypto_pwhash_str_needs_rehash") + end + end + end +end diff --git a/src/cox/secret_key.cr b/src/cox/secret_key.cr index 592dd93..bc50276 100644 --- a/src/cox/secret_key.cr +++ b/src/cox/secret_key.cr @@ -5,11 +5,64 @@ module Cox property bytes : Bytes KEY_LENGTH = LibSodium::SECRET_KEY_BYTES + MAC_BYTES = LibSodium::MAC_BYTES def initialize(@bytes : Bytes) if bytes.bytesize != KEY_LENGTH raise ArgumentError.new("Secret key must be #{KEY_LENGTH} bytes, got #{bytes.bytesize}") end end + + def self.random + new Random::Secure.random_bytes(KEY_LENGTH) + end + + def encrypt_easy(data) + encrypt_easy data.to_slice + end + + def encrypt_easy(data, nonce : Nonce) + encrypt_easy data.to_slice, nonce + end + + def encrypt_easy(data : Bytes) + nonce = Nonce.new + output = encrypt_easy data, nonce + {output, nonce} + end + + def encrypt_easy(data : Bytes, nonce : Nonce) : Bytes + output = Bytes.new(data.bytesize + MAC_BYTES) + encrypt_easy(data, output, nonce) + end + + def encrypt_easy(src : Bytes, dst : Bytes, nonce : Nonce) : Bytes + if dst.bytesize != (src.bytesize + MAC_BYTES) + raise ArgumentError.new("dst.bytesize must be src.bytesize + MAC_BYTES, got #{dst.bytesize}") + end + if LibSodium.crypto_secretbox_easy(dst, src, src.bytesize, nonce.pointer, @bytes) != 0 + raise Cox::Error.new("crypto_secretbox_easy") + end + dst + end + + def decrypt_easy(data : Bytes, nonce : Nonce) : Bytes + output_size = data.bytesize - MAC_BYTES + raise Cox::DecryptionFailed.new("encrypted data too small #{data.bytesize}") if output_size <= 0 + output = Bytes.new output_size + decrypt_easy(data, output, nonce) + end + + def decrypt_easy(src : Bytes, dst : Bytes, nonce : Nonce) : Bytes + if dst.bytesize != (src.bytesize - MAC_BYTES) + raise ArgumentError.new("dst.bytesize must be src.bytesize - MAC_BYTES, got #{dst.bytesize}") + end + if LibSodium.crypto_secretbox_open_easy(dst, src, src.bytesize, nonce.pointer, @bytes) != 0 + raise Cox::DecryptionFailed.new("crypto_secretbox_easy") + end + dst + end + + # TODO: encrypt_detached end end