From 2485bdcfbb959d3dbe24e0d1d834efd26ba7d0e6 Mon Sep 17 00:00:00 2001 From: Tony Arcieri Date: Sat, 24 Jun 2017 16:12:22 -0700 Subject: [PATCH] ruby: Merge jtdowney/aes_siv into the Ruby implementation --- lib/sivchain.rb | 3 +- lib/sivchain/aes/cmac.rb | 109 +++++++++++++++++++++ lib/sivchain/aes/siv.rb | 138 +++++++++++++++++++++++++++ lib/sivchain/aes_siv.rb | 136 -------------------------- lib/sivchain/cmac.rb | 106 -------------------- spec/sivchain/{ => aes}/cmac_spec.rb | 6 +- spec/sivchain/aes/siv_spec.rb | 32 +++++++ spec/sivchain/aes_siv_spec.rb | 75 --------------- spec/support/test_vectors.rb | 29 +++++- 9 files changed, 312 insertions(+), 322 deletions(-) create mode 100644 lib/sivchain/aes/cmac.rb create mode 100644 lib/sivchain/aes/siv.rb delete mode 100644 lib/sivchain/aes_siv.rb delete mode 100644 lib/sivchain/cmac.rb rename spec/sivchain/{ => aes}/cmac_spec.rb (70%) create mode 100644 spec/sivchain/aes/siv_spec.rb delete mode 100644 spec/sivchain/aes_siv_spec.rb diff --git a/lib/sivchain.rb b/lib/sivchain.rb index f57aa88..6df44cf 100644 --- a/lib/sivchain.rb +++ b/lib/sivchain.rb @@ -4,7 +4,8 @@ require "sivchain/version" -require "sivchain/cmac" +require "sivchain/aes/siv" +require "sivchain/aes/cmac" # Advanced symmetric encryption using the AES-SIV (RFC 5297) and CHAIN constructions module SIVChain diff --git a/lib/sivchain/aes/cmac.rb b/lib/sivchain/aes/cmac.rb new file mode 100644 index 0000000..8311d66 --- /dev/null +++ b/lib/sivchain/aes/cmac.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module SIVChain + # The Advanced Encryption Standard Block Cipher + module AES + # The AES-CMAC message authentication code + class CMAC + Exception = Class.new(StandardError) + ZeroBlock = ("\0" * 16).b.freeze + ConstantBlock = (("\0" * 15) + "\x87").b.freeze + + def initialize(key) + case key.length + when 16 + @cipher = OpenSSL::Cipher.new("AES-128-ECB") + when 32 + @cipher = OpenSSL::Cipher.new("AES-256-ECB") + else raise ArgumentError, "key must be 16 or 32 bytes" + end + + @cipher.encrypt + @cipher.padding = 0 + @cipher.key = key + @key1, @key2 = _generate_subkeys + end + + alias inspect to_s + + def digest(message) + message = message.b + + if _needs_padding?(message) + message = _pad_message(message) + final_block = @key2 + else + final_block = @key1 + end + + last_ciphertext = ZeroBlock + count = message.length / 16 + range = Range.new(0, count - 1) + blocks = range.map { |i| message.slice(16 * i, 16) } + blocks.each_with_index do |block, i| + block = _xor(final_block, block) if i == range.last + block = _xor(block, last_ciphertext) + last_ciphertext = _encrypt_block(block) + end + + last_ciphertext + end + + private + + def _encrypt_block(block) + @cipher.update(block) + @cipher.final + end + + def _generate_subkeys + key0 = _encrypt_block(ZeroBlock) + key1 = _next_key(key0) + key2 = _next_key(key1) + [key1, key2] + end + + def _needs_padding?(message) + message.empty? || message.length % 16 != 0 + end + + def _next_key(key) + if key[0].ord < 0x80 + _leftshift(key) + else + _xor(_leftshift(key), ConstantBlock) + end + end + + def _leftshift(input) + overflow = 0 + words = input.unpack("N4").reverse + words = words.map do |word| + new_word = (word << 1) & 0xFFFFFFFF + new_word |= overflow + overflow = (word & 0x80000000) >= 0x80000000 ? 1 : 0 + new_word + end + words.reverse.pack("N4") + end + + def _pad_message(message) + padded_length = message.length + 16 - (message.length % 16) + message += "\x80".b + message.ljust(padded_length, "\0") + end + + def _xor(a, b) + a = a.b + b = b.b + + output = "".b + length = [a.length, b.length].min + length.times do |i| + output << (a[i].ord ^ b[i].ord).chr + end + + output + end + end + end +end diff --git a/lib/sivchain/aes/siv.rb b/lib/sivchain/aes/siv.rb new file mode 100644 index 0000000..b3a48f8 --- /dev/null +++ b/lib/sivchain/aes/siv.rb @@ -0,0 +1,138 @@ +# encoding: binary +# frozen_string_literal: true + +module SIVChain + # The Advanced Encryption Standard Block Cipher + module AES + # The AES-SIV misuse resistant authenticated encryption cipher + class SIV + DOUBLE_CONSTANT = ("\x0" * 15) + "\x87" + + def initialize(key) + raise TypeError, "expected String, got #{key.class}" unless key.is_a?(String) + raise ArgumentError, "key must be Encoding::BINARY" unless key.encoding == Encoding::BINARY + raise ArgumentError, "key must be 32 or 64 bytes" unless [32, 64].include?(key.length) + + length = key.length / 2 + + @key1 = key.slice(0, length) + @key2 = key.slice(length..-1) + end + + alias inspect to_s + + def encrypt(plaintext, associated_data = []) + inputs = [] + inputs.concat(Array(associated_data)) + inputs << plaintext + + v = _s2v(inputs) + ciphertext = _transform(v, plaintext) + v + ciphertext + end + + def decrypt(ciphertext, associated_data = []) + v = ciphertext.slice(0, 16) + ciphertext = ciphertext.slice(16..-1) + plaintext = _transform(v, ciphertext) + + inputs = [] + inputs.concat(Array(associated_data)) + inputs << plaintext + t = _s2v(inputs) + + # TODO: not constant time + raise "bad encrypt" unless t == v + + plaintext + end + + private + + def _transform(v, data) + return "".b if data.empty? + + counter = v.dup + counter[8] = (counter[8].ord & 0x7f).chr + counter[12] = (counter[12].ord & 0x7f).chr + + cipher = OpenSSL::Cipher::AES.new(@key1.length * 8, :CTR) + cipher.encrypt + cipher.iv = counter + cipher.key = @key2 + cipher.update(data) + cipher.final + end + + def _s2v(inputs) + inputs = Array(inputs) + cmac = CMAC.new(@key1) + + if inputs.empty? + data = ("\0" * 15) + "\x01" + return cmac.digest(data) + end + + d = cmac.digest("\0" * 16) + + inputs.each_with_index do |input, index| + break if index == inputs.size - 1 + + d = _double(d) + block = cmac.digest(input) + d = _xor(d, block) + end + + input = inputs.last + + if input.bytesize >= 16 + d = _xorend(input, d) + else + d = _double(d) + d = _xor(d, _pad(input)) + end + + cmac.digest(d) + end + + def _pad(value) + difference = 15 - value.length + pad = "\x80" + ("\0" * difference) + value + pad + end + + def _double(value) + # TODO: not constant time + return _leftshift(value) if value[0].ord < 0x80 + _xor(_leftshift(value), DOUBLE_CONSTANT) + end + + def _leftshift(input) + overflow = 0 + words = input.unpack("N4").reverse + words = words.map do |word| + new_word = (word << 1) & 0xFFFFFFFF + new_word |= overflow + overflow = (word & 0x80000000) >= 0x80000000 ? 1 : 0 + new_word + end + words.reverse.pack("N4") + end + + def _xor(a, b) + length = [a.length, b.length].min + output = "\0" * length + length.times do |i| + output[i] = (a[i].ord ^ b[i].ord).chr + end + output + end + + def _xorend(a, b) + difference = a.length - b.length + left = a.slice(0, difference) + right = a.slice(difference..-1) + left + _xor(right, b) + end + end + end +end diff --git a/lib/sivchain/aes_siv.rb b/lib/sivchain/aes_siv.rb deleted file mode 100644 index 692f3a9..0000000 --- a/lib/sivchain/aes_siv.rb +++ /dev/null @@ -1,136 +0,0 @@ -require "cmac" -require "openssl" -require "aes_siv/version" - -class AES_SIV - DOUBLE_CONSTANT = ("\x0" * 15) + "\x87" - - def initialize(key) - raise ArgumentError unless [32, 48, 64].include?(key.length) - - length = key.length / 2 - - @key1 = key.slice(0, length) - @key2 = key.slice(length..-1) - end - - def encrypt(plaintext, options = {}) - inputs = _gather_inputs(plaintext, options) - v = _s2v(inputs) - ciphertext = _transform(v, plaintext) - v + ciphertext - end - - def decrypt(ciphertext, options = {}) - v = ciphertext.slice(0, 16) - ciphertext = ciphertext.slice(16..-1) - plaintext = _transform(v, ciphertext) - - inputs = _gather_inputs(plaintext, options) - t = _s2v(inputs) - - if t == v - plaintext - else - fail "bad encrypt" - end - end - - def _gather_inputs(plaintext, options = {}) - associated_data = options.fetch(:associated_data, []) - associated_data = Array(associated_data) - nonce = options[:nonce] - - inputs = [] - inputs.concat(associated_data) - inputs << nonce if nonce - inputs << plaintext - inputs - end - - def _transform(v, data) - counter = v.dup - counter[8] = (counter[8].ord & 0x7f).chr - counter[12] = (counter[12].ord & 0x7f).chr - - cipher = OpenSSL::Cipher::AES.new(@key1.length * 8, :CTR) - cipher.encrypt - cipher.iv = counter - cipher.key = @key2 - cipher.update(data) + cipher.final - end - - def _s2v(inputs) - inputs = Array(inputs) - cmac = CMAC.new(@key1) - if inputs.empty? - data = ("\0" * 15) + "\x01" - cmac.sign(data) - else - d = cmac.sign("\0" * 16) - - inputs.each_with_index do |input, index| - break if index == inputs.size - 1 - - d = _double(d) - block = cmac.sign(input) - d = _xor(d, block) - end - - input = inputs.last - if input.bytesize >= 16 - d = _xorend(input, d) - else - d = _double(d) - d = _xor(d, _pad(input)) - end - - cmac.sign(d) - end - end - - def _pad(value) - difference = 15 - value.length - pad = "\x80".b + ("\0" * difference) - value + pad - end - - def _double(value) - if value[0].ord < 0x80 - _leftshift(value) - else - _xor(_leftshift(value), DOUBLE_CONSTANT) - end - end - - def _leftshift(input) - overflow = 0 - words = input.unpack('N4').reverse - words = words.map do |word| - new_word = (word << 1) & 0xFFFFFFFF - new_word |= overflow - overflow = (word & 0x80000000) >= 0x80000000 ? 1 : 0 - new_word - end - words.reverse.pack('N4') - end - - def _xor(a, b) - a = a.b - b = b.b - - output = '' - length = [a.length, b.length].min - length.times do |i| - output << (a[i].ord ^ b[i].ord).chr - end - output - end - - def _xorend(a, b) - difference = a.length - b.length - left = a.slice(0, difference) - right = a.slice(difference..-1) - left + _xor(right, b) - end -end diff --git a/lib/sivchain/cmac.rb b/lib/sivchain/cmac.rb deleted file mode 100644 index 44e1753..0000000 --- a/lib/sivchain/cmac.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -module SIVChain - # The AES-CMAC message authentication code - class CMAC - Exception = Class.new(StandardError) - ZeroBlock = ("\0" * 16).b.freeze - ConstantBlock = (("\0" * 15) + "\x87").b.freeze - - def initialize(key) - case key.length - when 16 - @cipher = OpenSSL::Cipher.new("AES-128-ECB") - when 32 - @cipher = OpenSSL::Cipher.new("AES-256-ECB") - else raise ArgumentError, "key must be 16 or 32 bytes" - end - - @cipher.encrypt - @cipher.padding = 0 - @cipher.key = key - @key1, @key2 = _generate_subkeys - end - - alias inspect to_s - - def digest(message) - message = message.b - - if _needs_padding?(message) - message = _pad_message(message) - final_block = @key2 - else - final_block = @key1 - end - - last_ciphertext = ZeroBlock - count = message.length / 16 - range = Range.new(0, count - 1) - blocks = range.map { |i| message.slice(16 * i, 16) } - blocks.each_with_index do |block, i| - block = _xor(final_block, block) if i == range.last - block = _xor(block, last_ciphertext) - last_ciphertext = _encrypt_block(block) - end - - last_ciphertext - end - - private - - def _encrypt_block(block) - @cipher.update(block) + @cipher.final - end - - def _generate_subkeys - key0 = _encrypt_block(ZeroBlock) - key1 = _next_key(key0) - key2 = _next_key(key1) - [key1, key2] - end - - def _needs_padding?(message) - message.empty? || message.length % 16 != 0 - end - - def _next_key(key) - if key[0].ord < 0x80 - _leftshift(key) - else - _xor(_leftshift(key), ConstantBlock) - end - end - - def _leftshift(input) - overflow = 0 - words = input.unpack("N4").reverse - words = words.map do |word| - new_word = (word << 1) & 0xFFFFFFFF - new_word |= overflow - overflow = (word & 0x80000000) >= 0x80000000 ? 1 : 0 - new_word - end - words.reverse.pack("N4") - end - - def _pad_message(message) - padded_length = message.length + 16 - (message.length % 16) - message += "\x80".b - message.ljust(padded_length, "\0") - end - - def _xor(a, b) - a = a.b - b = b.b - - output = "".b - length = [a.length, b.length].min - length.times do |i| - output << (a[i].ord ^ b[i].ord).chr - end - - output - end - end -end diff --git a/spec/sivchain/cmac_spec.rb b/spec/sivchain/aes/cmac_spec.rb similarity index 70% rename from spec/sivchain/cmac_spec.rb rename to spec/sivchain/aes/cmac_spec.rb index b2156ed..68987c0 100644 --- a/spec/sivchain/cmac_spec.rb +++ b/spec/sivchain/aes/cmac_spec.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true -RSpec.describe SIVChain::CMAC do +RSpec.describe SIVChain::AES::CMAC do let(:example_key) { "\x01" * 16 } describe "inspect" do it "does not contain instance variable values" do cmac = described_class.new(example_key) - expect(cmac.inspect).to match(/\A#\z/) + expect(cmac.inspect).to match(/\A#\z/) end end describe "digest" do it "passes all AES-CMAC test vectors" do - AesCmacExample.load_file.each do |ex| + described_class::Example.load_file.each do |ex| cmac = described_class.new(ex.key) output = cmac.digest(ex.input) expect(output).to eq(ex.result) diff --git a/spec/sivchain/aes/siv_spec.rb b/spec/sivchain/aes/siv_spec.rb new file mode 100644 index 0000000..18159bf --- /dev/null +++ b/spec/sivchain/aes/siv_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.describe SIVChain::AES::SIV do + let(:example_key) { "\x01".b * 32 } + + describe "inspect" do + it "does not contain instance variable values" do + cmac = described_class.new(example_key) + expect(cmac.inspect).to match(/\A#\z/) + end + end + + describe "encrypt" do + it "passes all AES-SIV test vectors" do + described_class::Example.load_file.each do |ex| + siv = described_class.new(ex.key) + ciphertext = siv.encrypt(ex.plaintext, ex.ad) + expect(ciphertext).to eq(ex.output) + end + end + end + + describe "decrypt" do + it "passes all AES-SIV test vectors" do + described_class::Example.load_file.each do |ex| + siv = described_class.new(ex.key) + plaintext = siv.decrypt(ex.output, ex.ad) + expect(plaintext).to eq(ex.plaintext) + end + end + end +end diff --git a/spec/sivchain/aes_siv_spec.rb b/spec/sivchain/aes_siv_spec.rb deleted file mode 100644 index 6dcf83c..0000000 --- a/spec/sivchain/aes_siv_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -require 'test_helper' - -class AES_SIVTest < Minitest::Test - def test_valid_key_lengths - aes_siv = AES_SIV.new("\0" * 32) - refute_nil aes_siv - - aes_siv = AES_SIV.new("\0" * 48) - refute_nil aes_siv - - aes_siv = AES_SIV.new("\0" * 64) - refute_nil aes_siv - end - - def test_invalid_key_lengths - assert_raises(ArgumentError) do - AES_SIV.new("\0" * 52) - end - end - - def test_deterministic_authenticated_encryption - key = ["fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"].pack("H*") - associated_data = ["101112131415161718191a1b1c1d1e1f2021222324252627"].pack("H*") - plaintext = ["112233445566778899aabbccddee"].pack("H*") - ciphertext = ["85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c"].pack("H*") - - aes_siv = AES_SIV.new(key) - - assert_equal ciphertext, aes_siv.encrypt(plaintext, associated_data: associated_data) - end - - def test_nonce_based_authenticated_encryption - key = ["7f7e7d7c7b7a79787776757473727170404142434445464748494a4b4c4d4e4f"].pack("H*") - associated_data = [] - associated_data << ["00112233445566778899aabbccddeeffdeaddadadeaddadaffeeddccbbaa99887766554433221100"].pack("H*") - associated_data << ["102030405060708090a0"].pack("H*") - nonce = ["09f911029d74e35bd84156c5635688c0"].pack("H*") - plaintext = ["7468697320697320736f6d6520706c61696e7465787420746f20656e6372797074207573696e67205349562d414553"].pack("H*") - ciphertext = ["7bdb6e3b432667eb06f4d14bff2fbd0fcb900f2fddbe404326601965c889bf17dba77ceb094fa663b7a3f748ba8af829ea64ad544a272e9c485b62a3fd5c0d"].pack("H*") - - aes_siv = AES_SIV.new(key) - - assert_equal ciphertext, aes_siv.encrypt(plaintext, associated_data: associated_data, nonce: nonce) - end - - def test_encrypt_and_decrypt_aes128 - key = SecureRandom.random_bytes(32) - nonce = SecureRandom.random_bytes(16) - - aes_siv = AES_SIV.new(key) - ciphertext = aes_siv.encrypt("too many secrets", nonce: nonce) - - assert_equal "too many secrets", aes_siv.decrypt(ciphertext, nonce: nonce) - end - - def test_encrypt_and_decrypt_aes192 - key = SecureRandom.random_bytes(48) - nonce = SecureRandom.random_bytes(16) - - aes_siv = AES_SIV.new(key) - ciphertext = aes_siv.encrypt("too many secrets", nonce: nonce) - - assert_equal "too many secrets", aes_siv.decrypt(ciphertext, nonce: nonce) - end - - def test_encrypt_and_decrypt_aes256 - key = SecureRandom.random_bytes(64) - nonce = SecureRandom.random_bytes(16) - - aes_siv = AES_SIV.new(key) - ciphertext = aes_siv.encrypt("too many secrets", nonce: nonce) - - assert_equal "too many secrets", aes_siv.decrypt(ciphertext, nonce: nonce) - end -end diff --git a/spec/support/test_vectors.rb b/spec/support/test_vectors.rb index 49b5b68..f08b2e6 100644 --- a/spec/support/test_vectors.rb +++ b/spec/support/test_vectors.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true +# rubocop:disable Style/ClassAndModuleChildren + require "tjson" -class AesCmacExample +class SIVChain::AES::CMAC::Example attr_reader :key, :input, :result # Error parsing the example file @@ -24,3 +26,28 @@ def initialize(attrs) @result = attrs.fetch("result") end end + +class SIVChain::AES::SIV::Example + attr_reader :name, :key, :ad, :plaintext, :output + + # Error parsing the example file + ParseError = Class.new(StandardError) + + # Default file to load examples from + DEFAULT_EXAMPLES = File.expand_path("../../../../vectors/aes_siv.tjson", __FILE__) + + def self.load_file(filename = DEFAULT_EXAMPLES) + examples = TJSON.load_file(filename).fetch("examples") + raise ParseError, "expected a toplevel array of examples" unless examples.is_a?(Array) + + examples.map { |example| new(example) } + end + + def initialize(attrs) + @name = attrs.fetch("name") + @key = attrs.fetch("key") + @ad = attrs.fetch("ad") + @plaintext = attrs.fetch("plaintext") + @output = attrs.fetch("output") + end +end