firmware/shared/ndef.py
2026-06-19 12:48:49 -04:00

242 lines
7.5 KiB
Python

# (c) Copyright 2021 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# ndef.py -- NDEF records: making them and parsing them.
#
# - see ../docs/nfc-coldcard.md for background.
# - cross platform file
#
from struct import pack, unpack
from binascii import hexlify as b2a_hex
# From ST AN4911 - Fixed CC file that uses E2 to indicate 2-byte lengths
# - allocates entire memory (64k) to tag usage, read only
# - followed the "NDEF File Control TLV" tag (0x03) but not the length
CC_FILE = bytes([0xE2, 0x43, 0x00, 0x01, 0x00, 0x00, 0x04, 0x00, 0x03])
# When we are writable, empty file is given
CC_WR_FILE = bytes([0xE2, 0x40, 0x00, 0x01, 0x00, 0x00, 0x04, 0x00,
0x03, 0x00, # empty
0xfe, # end marker
])
class ndefMaker:
'''
make a few different types of NDEF records, very limited and only
for our use-cases
'''
def __init__(self):
# ndef records: len, TNF value, type (byte string), payload (bytes)
self.lst = []
def add_text(self, msg):
# assume: english, utf-8
ln = len(msg) + 3
self.lst.append( (ln, 0x1, b'T', b'\x02en' + msg.encode()) )
def add_url(self, url, https=True):
# always https since we're in Bitcoin, or else full URL
# - ascii only
proto_code = b'\x04' if https else b'\x00'
self.lst.append( (len(url)+1, 0x1, b'U', proto_code + url.encode()) )
def add_large_object(self, ext_type, offset, obj_len):
# zero-copy a binary file from PSRAM into NFC flash
# - or accept bytes
if isinstance(offset, int):
from glob import PSRAM
self.lst.append( (obj_len, 0x4, ext_type.encode(),
PSRAM.read_at(offset, obj_len)) )
else:
self.add_custom(ext_type, offset)
def add_custom(self, ext_type, payload):
# "NFC Forum external Type" using bitcoin.org domain
self.lst.append( (len(payload), 0x4, ext_type.encode(), payload) )
def add_mime_data(self, mime_type, payload):
# "image/png" or other RFC mime types, including application/json
self.lst.append( (len(payload), 0x2, mime_type.encode(), payload) )
def bytes(self):
# Walk list of records, and set various framing bits to first bytes of each
# and concat.
# - resist urge to make this a generator, it's not worth it.
rv = bytearray(CC_FILE)
# calc total length of all records
ln = sum((3 if ln <= 255 else 6) + len(ntype) + len(rec)
for (ln, _, ntype, rec) in self.lst)
if ln <= 0xfe:
rv.append(ln)
else:
rv.append(0xff)
rv.extend(pack('>H', ln))
last = len(self.lst) - 1
for n, (ln, tnf, ntype, rec) in enumerate(self.lst):
# First byte of the NDEF record: it's a bitmask + TNF 3-bit value
# TNF=1 => well-known type
# TNF=2 => mime-type from RFC 2046
# TNF=4 => NFC Forum external type
assert 0 < tnf < 7
first = tnf
if ln <= 255:
first |= 0x10 # SR=1
if n == 0:
first |= 0x80 # = MB Message Begin
if n == last:
first |= 0x40 # = ME Message End
rv.append(first) # NDEF header byte
rv.append(len(ntype)) # type-length always one, if well-known
if ln <= 255:
rv.append(ln) # value-length
else:
rv.extend(pack('>I', ln))
rv.extend(ntype)
rv.extend(rec)
rv.append(0xfe) # Terminator TLV
return rv
def ccfile_decode(taste):
# Given first 16 bytes of tag's memory (user memory):
# - returns start and length of real Ndef records
# - and is_writable flag
# - and max size of tag memory capacity, in bytes (poorly spec'ed / compat issues)
ex, b1, b2 = taste[0:3]
assert b1 & 0xf0 == 0x40 # bad version.
if ex == 0xE1:
# "one byte addressing mode" -- max of 2040 bytes, 4-6 byte header
if b2 != 0x00:
st = 4
mlen = b2 # aka MLEN
else:
st = 6
mlen = unpack('>H', taste[4:4+2])[0]
elif ex == 0xE2:
# 8-byte CC Field, allows 2 byte address mode
st = 8
mlen = unpack('>H', taste[6:6+2])[0]
else:
raise ValueError("bad first byte") # not one of 2 magic values we support
assert taste[st] == 0x03 # special first TLV
st += 1
ll = taste[st:st+3]
if ll[0] == 0xff:
ll = unpack('>H', ll[1:])[0]
st += 3
else:
ll = ll[0]
st += 1
assert 0 <= ll < 8196 # 64kbit max part
return st, ll, ((b1 & 3) == 0), mlen*4
def record_parser(msg):
# Given body of ndef records, yield a tuple for each record:
# - type info, as urn string
# - bytes of body
# - dict of meta data, appropriate to type
# - we gag on chunks
pos = 0
while 1:
meta = {}
hdr = msg[pos]
MB = hdr & 0x80
ME = hdr & 0x40
CF = hdr & 0x20
SR = hdr & 0x10
IL = hdr & 0x08
TNF = hdr & 0x7
assert not CF, "no chunks please"
assert (pos == 0) == bool(MB), "first needs MB set"
ty_len = msg[pos+1]
pos += 2
if SR: # short record: one byte for payload length
pl_len = msg[pos]
pos += 1
else:
pl_len = unpack('>I', msg[pos:pos+4])[0]
pos += 4
id_len = 0
if IL:
id_len = msg[pos]
pos += 1
urn = None
# type is next
ty = msg[pos:pos+ty_len]
pos += ty_len
if TNF == 0x0: # empty
assert ty_len == pl_len == 0, "ty_len = pl_len = 0"
urn = None
elif TNF == 0x1: # WKT
urn = 'urn:nfc:wkt:'
urn += ty.decode()
if ty == b'T':
# unwrap Text
hdr2 = msg[pos]
assert hdr2 & 0xc0 == 0x00, "only UTF supported"
lang_len = hdr2 & 0x3f
meta['lang'] = msg[pos+1:pos+1 + lang_len].decode()
skip = 1 + lang_len
pl_len -= skip
pos += skip
if ty == b'U':
# limited URL support
meta['prefix'] = msg[pos]
pos += 1
pl_len -= 1
elif TNF == 0x2: # mime-type, like 'image/png'
urn = ty.decode()
elif TNF == 0x3: # absolute URI??
urn = 'uri'
elif TNF == 0x4: # NFC forum external type
urn = 'urn:nfc:ext:'
urn += ty.decode()
else:
raise ValueError("TNF") # unknown/reserved/not handled.
if IL:
meta['ident'] = bytes(msg[pos:pos+id_len])
pos += id_len
yield urn, memoryview(msg)[pos:pos+pl_len], meta
if ME: return
pos += pl_len
assert pos < len(msg), "missing ME/truncated"
# EOF
# from NXP:
# E1 40 80 09 03 10 D1 01 0C 55 01 6E 78 70 2E 63 6F 6D 2F 6E 66 63 FE 00
# ST AN5439 -- works
# 4-byte CCfile then "NDEF File Control TLV":
# E1 40 40 00 03 2A
# NDef records:
# D1012655016578616D706C652E636F6D2F74656D703D303030302F746170636F756E7465723D30303030FE000000
#
# m=b'\xe1@@\x00\x03*\xd1\x01&U\x01example.com/temp=0000/tapcounter=0000\xfe\x00\x00\x00'