commit ec1d89298f98ebf5e96d3976580830edc9bfc2d5 Author: Ed Date: Tue Jul 11 22:16:30 2023 +0200 Initial commit diff --git a/badge.py b/badge.py new file mode 100644 index 0000000..cfc7f78 --- /dev/null +++ b/badge.py @@ -0,0 +1,130 @@ +from time import time +from base64 import b16encode +from hashlib import sha3_512 +import os.path +from security_const import * +import logging +import binascii +import ndef +from io import BytesIO +from nfc.tag.tt2 import Type2TagCommandError + +logging.basicConfig(level='INFO') +log = logging.getLogger('badge') + +START_PAGE = { + "NXP NTAG213": 0x11, + "NXP NTAG215": 0x6B, + "NXP NTAG216": 0xCB +} + +USER_PAGE_BOUNDS = { + "NXP NTAG213": (0x04, 0x27), + "NXP NTAG215": (0x04, 0x81), + "NXP NTAG216": (0x04, 0xE1), +} + +CONFIG_PAGES = { + "NXP NTAG213": (0x29, 0x2A, 0x2B, 0x2C), + "NXP NTAG215": (0x83, 0x84, 0x85, 0x86), + "NXP NTAG216": (0xE3, 0xE4, 0xE5, 0xE6), +} + +class Badge: + def __init__(self, tag, uuid=None): + self.tag = tag + self.uuid = uuid + + def read_transaction(self): + bounds = USER_PAGE_BOUNDS[self.tag.product] + start_page = START_PAGE[self.tag.product] # We assume transactions are 22 pages wide + + data = b"" + offset = 0 + + while 1: + print("Reading", hex(start_page+offset), end=' ') + + chunk = self.tag.read(start_page+offset) + print(b16encode(chunk)) + if len(data) == 0: + if bytes(chunk[:4]) != HEADER: + print('header is', HEADER) + print('chunk 4', bytes(chunk[:4])) + print('Does not start with header :(') + + data += chunk + offset += 4 + + if FOOTER in chunk: + data = data[:data.find(FOOTER)+4] + break + + return data + + def write_transaction(self, tx, verify=True): + bounds = USER_PAGE_BOUNDS[self.tag.product] + start = START_PAGE[self.tag.product] + + with BytesIO(tx.get_bytes()) as data: + + offset = 0 + + # Write the transaction + while 1: + chunk = data.read(4) + if not chunk: break + + print("Writing", hex(start+offset), b16encode(chunk)) + assert bounds[0] <= start+offset <= bounds[1] + + self.tag.write(start+offset, chunk) + + offset += 1 + + # Read the transaction back + if verify: + print('expected:::', tx.get_bytes()) + print('read:::::', self.read_transaction()) + assert tx.get_bytes() == self.read_transaction() + + def enable_count(self): + + page_addr = CONFIG_PAGES[self.tag.product][1] + + page = self.tag.read(page_addr)[:4] + if not page[0] & (1<<4): + log.info("Enabling tag counter") + page[0] |= 1 << 4 # Set NFC_CNT_EN = 1 + self.tag.write(page_addr, page) + else: + log.info("Tag counter is already enabled!") + + try: + count = self.tag.transceive(binascii.unhexlify('3902'), timeout=0.05) # READ_CNT command + except Type2TagCommandError: + log.error("Tag count is enabled, but didn't get any response anyway.") + else: + log.info(f"Tag count is {int.from_bytes(count, byteorder='little')}") + + def protect(self): + self.tag.protect(self.password()) + + def password(self, with_uuid=True): + x = self.tag.identifier + for i in range(ITERATIONS): + x = sha3_512(x + (self.uuid if with_uuid and self.uuid else bytes(0)) + SALT).digest() + return x[:6] + +if __name__ == '__main__': + + from types import SimpleNamespace + + tag = SimpleNamespace() + tag.identifier = bytes(8) + + pt = Protect(tag, uuid=b"bye") + start = time() + print('with uuid', b16encode(pt.password())) + print('without uuid', b16encode(pt.password(with_uuid=False))) + print("took", (time()-start)*1000, 'ms') diff --git a/enroll.py b/enroll.py new file mode 100644 index 0000000..6b4d177 --- /dev/null +++ b/enroll.py @@ -0,0 +1,147 @@ +import nfc +from nfc.clf import RemoteTarget +import logging +from time import time +import os.path +import coloredlogs +coloredlogs.install() +from base64 import b16encode, b16decode +import sys +import ndef +import binascii +from badge import Badge +from os import system + +CONFIG_PAGES = { + "NXP NTAG213": (0x29, 0x2A, 0x2B, 0x2C), + "NXP NTAG215": (0x83, 0x84, 0x85, 0x86), + "NXP NTAG216": (0xE3, 0xE4, 0xE5, 0xE6), +} + +session_start = time() + +if __name__ == '__main__': + + log = logging.getLogger('nfc') + + nfc_type = sys.argv[1] + balance = 0 + if len(sys.argv) > 2: + balance = sys.argv[2] + elif nfc_type == 'coin': + log.error("You cannot create empty coins.") + exit() + + clf = nfc.ContactlessFrontend() + + log.info(f"Enrolling mode: {nfc_type}") + log.info(f"Initial balance: {balance}") + + try: + assert clf.open('tty:USB0:pn532') + except TimeoutError: + log.error('Got TimeoutError on reader connection attempt.') + except AssertionError: + log.error('There is no reader connected.') + exit() + + # Set baud rate to 115200 to prevent reader from dying + clf.device.chipset.set_serial_baudrate(115200) + clf.device.chipset.transport.baudrate = 115200 + + try: + while 1: + target = clf.sense(RemoteTarget('106A'), iterations=10) + if not target: continue + + start = time() + + tag = nfc.tag.activate(clf, target) + if not tag: + print("Tag was gone while writing.") + break + + if tag.product not in ["NXP NTAG215"]: + raise Exception(f"This tag ({tag.product}) is not compatible with the barcard system.") + + #print("\n".join(tag.dump())) + #break + + badge = Badge(tag) + + password = badge.password() + try: + has_password = tag.read(CONFIG_PAGES[tag.product][0])[3] < 0xFF + except: + break + + if has_password: + log.warning("TAG has a password already! Trying to re-enroll anyway.") + tag.authenticate(password) + + #tx = Transaction(tag_id=tag.identifier) + + try: + # Disable the mirror, for now + page_addr = CONFIG_PAGES[tag.product][0] + page = tag.read(page_addr)[:4] + page[0] &= 0b111111 # Disable UID + COUNT mirror + tag.write(page_addr, page) + except: + break + + # Activate it again + tag = nfc.tag.activate(clf, target) + if has_password: + tag.authenticate(password) + + # Write the correct url depending on the type of wanted badge + if nfc_type == 'badge': + url = "https://go.foxo.me/fz23/bxxxxxxxxxxxxxxxxxxxxx" + else: + url = f"https://go.foxo.me/fz23/cxxxxxxxxxxxxxxxxxxxxx" + + tag.format() + tag.ndef.records = [ndef.uri.UriRecord(url), ] + if tag.ndef.records[0].uri != url: + raise Exception("Written uri is different than expected.") + + boundary = tag.read(0x08) + if not boundary.startswith(b"e/fz23/"): + raise Exception(f"This tag does not seem to correctly store ndef data. Got wrong {boundary}") + + try: + page_addr = CONFIG_PAGES[tag.product][0] + page = tag.read(page_addr)[:4] + page[0] |= 0b11 << 6 # Enable UID + COUNT mirror + if tag.product == 'NXP NTAG215': + page[2] = 0x0A # Location of UID + COUNT + elif tag.product == 'NXP NTAG213': + page[2] = 0x0B + tag.write(page_addr, page) + log.info("UID + COUNT mirror enabled.") + except: + raise + break + + print('EC') + badge.enable_count() + tag = nfc.tag.activate(clf, target) + if not has_password: + print('PRT') + badge.protect() + + with open(f'log_{int(session_start)}.txt', 'a') as f: + f.write(f"{b16encode(tag.identifier).decode()},{nfc_type},{balance}\n") + + system('mpv success.wav') + log.info(f"{time()-start:.2f} tag write done.") + while clf.sense(RemoteTarget('106A')): pass + + system + + except KeyboardInterrupt: + pass + + clf.close() + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..44ce8f6 --- /dev/null +++ b/readme.md @@ -0,0 +1,58 @@ + +# ⚠️ DO NOT USE THIS! READ BELOW! ⚠️ + +Due to a flaw of nfcpy (or maybe not?) tags enrolled by this script will have the capability bit set to 0x31. **This for some reason makes the NDEF payload only compatible with Android phones**! + +This script can be used to enroll your NFC tags in the badge system. Effectively, it will: + +1. Enable the READ_CNT flag, to enable the read counter +2. Add a NDEF Uri so that the badge can be scanned by attendants +3. Protect the badge with a password, generated uniquely for every tag +4. Log the UID and badge type, so you can import it in your backend + +## Requirements + +1. Check the requirements.txt file for python lib requirements +2. Install mpv to play a sound at the end of every succesfull write, or don't (and remove the row) +3. Change the SALT to a very secure password and adjust the iteration count to a sensible number for your computer +4. Connect a PN532 scanner via USB +5. Enjoy! + +## Run the script + +```python3 enroll.py [badge/coin] [initial balance]``` + +The badge/coin changes the url, while initial balance changes the column on the data export (the log_*.txt files) + +## The ASCII mirror! + +You will notice that every badge has a generic "https://go.foxo.me/fz23/bxxxxxxxxxxxxxxxxxxxxx" url written to it. +This is on purpose since after this write the UID + COUNT mirror is enabled. +This is a function of the NDEF tags that lets you "mirror" various registers in ascii format to other arbitrary addresses. +The code following the ndef part will effectively make so that when you read the link will actually become `"/fz23/b[TAG IDENTIFIER]x[READ COUNT]"` (e.g. `"/fz23/b048E77929A7181x0000EB"`) without any manual intervention and in a completely tear-free way. + +## The "toys" folder + +Inside of the toys folder there are two files which were a proof of concept for the storage of monetary transactions inside of NFC tags, for use to buy drinks and similar. Eventually, the system was moved completely server-side (due to slowness in writing the cards and risk of tearing), and the entire system scrapped. + +The system consisted in ed25519-based transaction signatures, where offline clients (aka the PoS) would be able to verify and write transactions. The cards would actually store two transactions and then change a pointer atomically to prevent half-written transaction from being considered. + +The transaction format is as follows: + +``` +[ CA$H ] +[signature ] * 16 +[load][used] +[keyi][txid] +[timestamp ] +[ uuid ] +[ F0X0 ] +``` + +- CA$H and F0X0 are header and footer of the transaction (4 bytes each) +- signature = ed25519 signature of the transaction (4 * 16 = 64 bytes) +- load (2 bytes) + used (2 bytes) = the amount of loaded and used money. These two values can only increase and the balance is calculated from subtracting them so that "replay" attacks are harder +- keyi = id of the key used to sign the transaction (2 bytes) +- txid = increasing id of the transaction (2 bytes) +- timestamp = unix ts of the transaction (4 bytes) +- uuid = a custom uuid to identify the card (this was added because at one point we accidentally bought counterfeit cards with duplicated NFC identifiers) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7b705c0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +coloredlogs +nfcpy diff --git a/security_const.example.py b/security_const.example.py new file mode 100644 index 0000000..44249f0 --- /dev/null +++ b/security_const.example.py @@ -0,0 +1,2 @@ +SALT = b"This is the salt to generate the badge password :)" +ITERATIONS = 10000 diff --git a/success.wav b/success.wav new file mode 100644 index 0000000..aae49a4 Binary files /dev/null and b/success.wav differ diff --git a/toys/enroll_card.py b/toys/enroll_card.py new file mode 100644 index 0000000..426652e --- /dev/null +++ b/toys/enroll_card.py @@ -0,0 +1,176 @@ +import nfc +import sys +from nfc.clf import RemoteTarget +from nfc.tag.tt2 import Type2TagCommandError +import logging +from base64 import b64encode, b16encode +from os.path import join +from time import time +from hashlib import sha3_256 +import ndef +import coloredlogs +import binascii +import os +import json +from colorama import init, Fore, Back, Style + +coloredlogs.install(fmt='%(levelname)s %(message)s') +init() + +uids = json.load(open('uids.json')) + +def get_hash(payload, count=10000, salt=b"x!"): + data = sha3_256(payload + (salt or bytes(0))) + for _ in range(count): + data = sha3_256(data.digest() + (salt or bytes(0))) + return data.digest() + +def activate_and_unlock(*args, **kwargs): + tag = nfc.tag.activate(*args, **kwargs) + tag.has_password = False + assert tag.signature + if not tag.ndef.is_writeable: + tag.has_password = True + unlock_result = tag.authenticate(password=get_hash(tag.identifier+tag.signature)) + if not unlock_result: + log.error("This tag is locked and password is incorrect!") + return + return tag + +if __name__ == '__main__': + log = logging.getLogger('nfc') + clf = nfc.ContactlessFrontend() + + try: + assert clf.open('tty:USB0:pn532') + except TimeoutError: + log.error('Got TimeoutError on reader connection attempt.') + except AssertionError: + log.error('There is no reader connected.') + exit() + + clf.device.chipset.set_serial_baudrate(115200) + clf.device.chipset.transport.baudrate = 115200 + log.info(f"Setting device baud rate to {clf.device.chipset.transport.baudrate}.") + + print(Style.BRIGHT + "What mode would you like to work in?\ns = single enroll\nm = mass enroll\n>>> ", end='') + mode = input()[0] + + if mode == 'm': + print(Style.BRIGHT + "Choose the number from where to begin enrolling. To stop enrolling, just use CTRL+C to exit the program.\n>>> ", end='') + + try: + uid = int(input()) + except KeyboardInterrupt: exit() + except: + log.error("The supplied number is not valid.") + exit() + print(Fore.GREEN + "✔ Mass enroll mode" + Style.RESET_ALL) + else: + print(Fore.GREEN + "✔ Single enroll mode" + Style.RESET_ALL) + + try: + while 1: + + # Ask for a number if in single enroll mode. + if mode == 's': + while 1: + print(Style.BRIGHT + "Input the attendee number\n>>> ", end='') + try: + uid = int(input()) + except KeyboardInterrupt: exit() + except: + log.error("The supplied number is not valid.") + continue + else: + break + + + user_id = uids[str(uid)][0] + url = "http://go.foxo.me/" + user_id + + print(Fore.GREEN + "Attendee info:") + print(f"NAME: {uids[str(uid)][1]}") + print(f" INT: {uid}") + print(f"CODE: {user_id}\n") + + print(Fore.YELLOW + "⌛ Waiting for nfc tag", end='') + + # Wait for a successful reading + while 1: + target = clf.sense(RemoteTarget('106A'), iterations=10) + if not target: + print('.', end='') + sys.stdout.flush() + continue + break + + print(Style.RESET_ALL) + + # Tag was found, write the details + start = time() + tag = activate_and_unlock(clf, target) + + if not tag: + log.error("Tag was gone mid-write.") + continue + + if tag.product != 'NXP NTAG215': + log.error("Incorrect tag type detected! Tag must be a NXP 215!") + while clf.sense(RemoteTarget('106A')): pass + continue + + if not tag.signature: + log.error("Tag signature was not read correctly. Try again.") + while clf.sense(RemoteTarget('106A')): pass + continue + + password = get_hash(tag.identifier+tag.signature, 10000)[:6] + + # If tag count is disabled, enable it + try: + count = tag.transceive(binascii.unhexlify('3902'), timeout=0.05) # READ_CNT command + except Type2TagCommandError: + page = tag.read(0x84)[:4] + if not page[0] & (1<<4): + log.info("Enabling tag counter") + page[0] |= 1 << 4 # Set NFC_CNT_EN = 1 + self.tag.write(0x84, page) + + tag = activate_and_unlock(clf, target) + count = tag.transceive(binascii.unhexlify('3902'), timeout=0.05) # READ_CNT command + + print(Fore.CYAN+Style.BRIGHT) + print(f"UUID: {b16encode(tag.identifier).decode()}") + print(f"SIGN: {b16encode(tag.signature).decode()}") + print(f"PASS: {b16encode(password[:4]).decode()}") + print(f"PWCK: {b16encode(password[4:]).decode()}") + print(" CNT:", int.from_bytes(count, byteorder='little')) + print(Style.RESET_ALL) + + tag.ndef.records = [ndef.UriRecord(url), ] + try: + assert tag.ndef.has_changed is False + except AssertionError: + log.error("NDEF data was written incorrectly!") + while clf.sense(RemoteTarget('106A')): pass + continue + else: + log.info("Writing URL") + + if not tag.has_password: + log.info("Setting password") + tag.protect(password=password, read_protect=False, protect_from=0) + + print(Back.GREEN + Fore.BLACK + f"\n✔ Badge enrolled with success in {time()-start:.2f}s!" + Style.RESET_ALL) + uid += 1 + + while clf.sense(RemoteTarget('106A')): pass + + os.system('clear') + + except KeyboardInterrupt: + pass + + clf.close() + diff --git a/toys/transaction.py b/toys/transaction.py new file mode 100644 index 0000000..ad9254f --- /dev/null +++ b/toys/transaction.py @@ -0,0 +1,146 @@ +from time import time +from base64 import b16encode +import os.path +import libnacl.utils, libnacl.sign +from transaction_const import * +from io import BytesIO +from hashlib import sha3_256 +import logging +from random import randint + +logging.basicConfig(level='DEBUG') +log = logging.getLogger('tx') + +''' + +[ CA$H ] +[signature ] * 16 +[load][used] +[keyi][txid] +[timestamp ] +[ uuid ] +[ F0X0 ] + + +21*4 = 84bytes +76 bytes + +''' + +FIELDS = { + 'signature': (0, 63), + 'enc_key': 64, + 'key_id': 65, + 'load_amt': (66, 67), + 'used_amt': (68, 69), + 'tx_id': (70, 71), + 'ts': (72, 75), + 'uuid': (76, 79) +} + +class Transaction: + def __init__(self, tag_id, nfc_bytes=None): + + # These are needed for proper randomness + self.tag_id = tag_id + + self.data = bytearray(max([(x[1] if isinstance(x, tuple) else x) for x in FIELDS.values()])+1) + + self.set_field('key_id', KEY_ID) + self.set_field('ts', time()) + self.set_field('uuid', libnacl.randombytes(4)) + + if nfc_bytes: + self.load(nfc_bytes) + + def set_field(self, fid, val): + + if isinstance(FIELDS[fid], int): + flen = 1 + sbyte = FIELDS[fid] + else: + flen = 1 + FIELDS[fid][1] - FIELDS[fid][0] + sbyte = FIELDS[fid][0] + + if isinstance(val, float): + val = int(val) + + if isinstance(val, int): + val = val.to_bytes(flen) + + assert len(val) <= flen + + if flen > 1: + log.info(f"Writing {fid} at bytes {sbyte}:{sbyte+flen-1}: 0x{b16encode(val).decode()}") + self.data[sbyte:sbyte+flen] = val + else: + log.info(f"Writing {fid} at byte {sbyte}: 0x{b16encode(val).decode()}") + self.data[sbyte] = val[0] + + def get_field(self, fid): + if isinstance(FIELDS[fid], int): + flen = 1 + sbyte = FIELDS[fid] + else: + flen = 1 + FIELDS[fid][1] - FIELDS[fid][0] + sbyte = FIELDS[fid][0] + + if flen > 1: + val = bytes(self.data[sbyte:sbyte+flen]) + else: + val = self.data[sbyte] + + return val + + def load(self, data): + if data.startswith(HEADER): + data = data[len(HEADER):] + + if data.find(FOOTER): + data = data[:-data.find(FOOTER)] + + self.data = data + + def sign(self): + self.set_field('key_id', KEY_ID) + signer = libnacl.sign.Signer(KEY_SEED) + signature = signer.signature(bytes(self.data[FIELDS['signature'][1]+1:]+self.tag_id)) + self.set_field('signature', signature) + + log.info("Signed message with success!") + + self.verify() + + def get_bytes(self): + return HEADER + self.data + FOOTER + + # TODO: implement these + def topup(self, amt): + self.load_amount += amt + self.tx_bytes + + def spend(self, amt): + self.used_amount += amt + + def verify(self): + verifier = libnacl.sign.Verifier(KEYS[self.get_field('key_id')]) + verifier.verify(bytes(self.data + self.tag_id)) + log.info("Signature has passed!") + +if __name__ == '__main__': + print("Testing transaction.") + tx = Transaction(tag_id=bytes(8)) + tx.sign() + + #for fname in FIELDS: + # print(' ', fname, tx.get_field(fname), 'len') + + #res = tx.get_bytes() + #tx.verify() + + #tx = Transaction(tag_id=bytes(8), nfc_bytes=res) + #res2 = tx.get_bytes() + + + +