Skip to content

Instantly share code, notes, and snippets.

@sinisterchipmunk
Created June 21, 2017 16:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sinisterchipmunk/e6b9c0517ad13d0edf2ceb9e3ec991d7 to your computer and use it in GitHub Desktop.
Save sinisterchipmunk/e6b9c0517ad13d0edf2ceb9e3ec991d7 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
require 'openssl'
=begin
Implements the MAC generation process which needs to be applied to Standard 70
transaction requests and replies. Uses example data to assert correctness at
each step of the process.
This script generates the MAC and MAC residue for the example request message,
then does the same for the example reply, both of which are hard-coded so that
the logic can be tested offline. (As a sanity check, it was also tested online
against a real host, once the offline tests passed.)
After generating the MACs and residues, the response MAC is verified to ensure
the message is legitimate. Then the request and response residues are
concatenated together and hashed with the original key to produce the key
which would be used for the next transaction. This produces the MAC chaining
process described in Standard 70, and the script exits after the new key is
generated.
You need to persist the new key once it is generated, and replace the terminal
key with that for the following transaction. You also need to keep the
original terminal key (don't overwrite it), because there exists a condition
where the host and client can become out of sync, resulting in the replacement
key being invalid. In that situation, Standard 70 indicates you should retry
the transaction using the original key and discard the generated replacement.
I use my own nomenclature for variable names and such. I try not to divert too
far from the spec, but in some places the spec uses rather confusing language,
and in those places I used something that would be more likely to jog my own
memory if I have to re-read this 3 years from now.
DISCLAIMER: I am not a Standard 70 expert and this code is the result of a lot
of muddling over an amount of time I'd rather not think about. If running this
code causes SHODAN to take over the internet, don't look at me.
=end
# Some helpers for conversion to and from hex
class String
def hex_to_bytes
scan(/../).map(&:hex).map(&:chr).join
end
def bytes_to_hex
bytes.map { |b| b.to_s(16).rjust(2, '0') }.join
end
end
class Numeric
# Tests for odd parity, returning true or false
def odd_parity?
to_s(2).count('1').odd?
end
end
# Implements the one-way function (OWF) needed for generating keys.
def oneway(key_d, key_k)
key_x = [0xA5, 0xC7, 0XB2, 0X82, 0X84, 0X76, 0XA8, 0X29]
key_y = [0xB5, 0xE3, 0X7F, 0XC5, 0XD4, 0XF7, 0XA3, 0X93]
data = [0,0,0,0,0,0,0,0]
key = [0,0,0,0,0,0,0,0]
result = [0,0,0,0,0,0,0,0]
key_d = key_d.bytes
key_k = key_k.bytes
raise ArgumentError, "key_d must be 8 bytes" unless key_d.size == 8
raise ArgumentError, "key_k must be 8 bytes" unless key_k.size == 8
8.times do |i|
key_d[i] = key_d[i] ^ key_x[i]
key_k[i] = key_k[i] ^ key_y[i]
# key_k must have odd parity, correct it if necessary
key_k[i] = key_k[i] ^ 0x01 unless key_k[i].odd_parity?
data[i] = key_d[i]
key[i] = key_k[i]
end
cipher = OpenSSL::Cipher.new("des").encrypt
cipher.key = key.pack("C8")
cipher.padding = 0
data = cipher.update(data.pack("C8")).bytes
8.times { |i| result[i] = data[i] ^ key_d[i] }
return result.pack("C8")
end
# Generates a MAC for request or reply
def generate_mac(key, data)
data = data + "\x00" until data.size % 8 == 0
des1 = OpenSSL::Cipher.new('des-cbc').encrypt
des1.key = key
des1.iv = "\x00".b * 8
des1.padding = 0
(des1.update(data) + des1.final)[-8..-1]
end
### REQUEST
# This is the example request that we are going to generate a MAC for. Note
# that STX and ETX are omitted.
example_msg = "10327256000013260116815476\x1c;4539791001730106=06091010912345678901?9\x1c2500\x1c\x1c704012004\x1c\x1c".b
# The terminal_key is initially the key that has been shared with the host,
# but it will change over time (see the end of this script).
terminal_key = "\x8F\x23\x49\xA1\x49\x64\x92\x25".b
# Use the card details ';4539791001730106=06091010912345678901?9' for example.
# The value before the ';' and the '=' is the account number. Use this value
# from swiped or EMV transactions. For manual transactions, the user will
# enter their account number, so use that.
#
# The first half of transaction_key is the *last* 8 characters of the account
# number, interpreted as hexadecimal binary (not ASCII), padded on the left
# with 0's if the account number is too short. The second half of
# transaction_key is the 8 characters preceding that, interpreted the same
# way. So to be clear, only exactly the right-most 16 characters are used, and
# the interpretation of these characters packs them into 8 bytes.
transaction_key = "\x01\x73\x01\x06\x45\x39\x79\x10".b
# Use the transaction_key and terminal_key to generate a MAC key for this
# transaction.
mac_key = oneway(transaction_key, terminal_key)
p ['mac key', mac_key.bytes_to_hex]
raise "BUG in mac key generation" unless mac_key == "\x28\xbc\x08\x28\xf6\xe9\x99\x5e".b
# The MAC, then, is just the result of DES-CBC encrypting the message and then
# discarding all but the final 8 bytes.
mac = generate_mac(mac_key, example_msg)
raise "BUG in MAC generation" unless mac == "\xde\xec\x0b\xf0\x1c\xa7\xbb\x9a".b
request_mac, request_residue = mac[0...4], mac[4..-1]
p ['request mac', request_mac.bytes_to_hex]
p ['request residue', request_residue.bytes_to_hex]
# The full request is the original request plus the MAC. Send this to the
# host.
full_request = example_msg + request_mac
### RESPONSE
# This is the example response whose MAC we are going to verify. Note that
# STX and ETX are omitted.
example_msg = "103272560000113002341768\x1c00\x1f6\x1cAUTH CODE:341768\x1c\x1c000\x1f000\x1c\x1c16F17AB8".b
# Pop the received MAC off of the response message. The MAC we generate for
# this message must match, or else we can't trust the message.
received_mac, verify_msg = example_msg[-8..-1].hex_to_bytes, example_msg[0...-8]
# Add the request residue to the BEGINNING of the response. See the standard
# for pretty pictures, then this should make sense.
verify_msg = request_residue + verify_msg
# Pad the message to a multiple of 8 bytes.
verify_msg += "\x00" until verify_msg.size % 8 == 0
# The auth parameter is 8 bytes long and requires knowledge of the original
# transaction to construct. It is:
#
# First byte:
# high nibble:
# - 8 for debit (as in: the customer is paying the merchant),
# - 9 for credit (as in: the merchant is refunding the customer), or
# for balance inquiry,
# - 7 for administration
# low nibble:
# - The number of digits in the transaction amount, when interpreted as
# ASCII characters. (This was misleading to me, as we are about to
# encode them below as 2 bytes, rather than 4 characters.)
#
# Next several bytes:
#
# The transaction amount, interpreted such that the 1st and each
# subsequent odd digit is a high nibble, and each even digit is a low
# nibble. Example: ASCII "2500" becomes hex [ 0x25, 0x00 ].
#
# The remainder of the auth parameter bytes are padded, using the byte
# value 0xFF.
#
auth_parameter = [0x84, # 0x84 -- 8 for debit, 4 digits in the amount
0x25, 0x00, # amount 2500 encoded as bytes
0xFF, 0xFF, 0xFF, 0xFF, 0xFF # padding
].pack("C8").b
p ['auth parameter', auth_parameter.bytes_to_hex]
# Hash the auth parameter against the mac key generated during the request
hashed_auth_parameter = oneway(auth_parameter, mac_key)
p ['hashed auth parameter', hashed_auth_parameter.bytes_to_hex]
raise "BUG in auth parameter" unless hashed_auth_parameter == "\xA6\xEE\xD3\x85\x0E\x8D\xB3\x4B".b
# Concatenate the hashed auth parameter to the message, and generate the
# response MAC.
verify_msg += hashed_auth_parameter
mac = generate_mac(mac_key, verify_msg)
raise "BUG in response mac" unless mac == "\x16\xF1\x7A\xB8\x22\x92\x90\xA0".b
response_mac, response_residue = mac[0...4], mac[4..-1]
p ['response mac', response_mac.bytes_to_hex]
p ['response residue', response_residue.bytes_to_hex]
# Verify the received MAC against the computed one. Of all the assertions in
# this script, this is probably the one you'll want to keep (but handle more
# gracefully, by failing or retrying the transaction).
raise "ERROR MACs don't match" unless response_mac == received_mac
### NEXT TXN
# The key for the next txn is generated by combining the request and response
# residues, and then hash them against the previous terminal key.
next_terminal_key = oneway(request_residue + response_residue, terminal_key)
p ['next transaction terminal key', next_terminal_key.bytes_to_hex]
raise "BUG in next terminal key" unless next_terminal_key == "\x38\x6e\x84\xfe\xde\x15\x4d\xc5".b
# NOTE: if the host rejects the MAC for the a given transaction, you must
# rerun the same transaction, using the original terminal_key that was shared
# with the host. Therefore you must persist both the original and replacement
# key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment