From ea736dfab06365f777597e952256f58944d6a820 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 5 Aug 2026 20:02:26 +0200 Subject: [PATCH] fix(machine): read NTAG424 NDEF via Capability Container (Bolt Card FileID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-card tap read the NDEF file with id 0004 and got "not a Bolt Card" — NTAG424 (Bolt Cards) use FileID E104. Read the Capability Container (EF E103) after selecting the NDEF app to learn the advertised NDEF FileID, then read that file; fall back to E104/0004. Tolerates a transient transmit error (surfaced as a retryable status; the next tap re-reads). Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/nfc-service.test.ts | 50 +++++++++-------- apps/machine/electron/nfc-service.ts | 66 ++++++++++++++++------- 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts index 3023d36..f694758 100644 --- a/apps/machine/electron/nfc-service.test.ts +++ b/apps/machine/electron/nfc-service.test.ts @@ -31,36 +31,44 @@ describe('extractLnurlw', () => { describe('readNdefLnurlw', () => { const SW_OK = Buffer.from([0x90, 0x00]) + const SW_NOTFOUND = Buffer.from([0x6a, 0x82]) + // Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]). + const CC = Buffer.from([ + 0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff, + ]) - it('runs the Type-4 read sequence and returns the lnurlw', async () => { - const msg = ndefUriMessage(LNURLW) + /** Route APDUs by content so the CC-read + fallback loop is exercised. */ + function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) { + const msg = ndefUriMessage(opts.uri ?? LNURLW) const nlen = msg.length - const transmit = vi - .fn() - .mockResolvedValueOnce(SW_OK) // select NDEF app - .mockResolvedValueOnce(SW_OK) // select NDEF file - .mockResolvedValueOnce(Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK])) // NLEN - .mockResolvedValueOnce(Buffer.concat([msg, SW_OK])) // NDEF message + return vi.fn(async (apdu: Buffer) => { + const hex = apdu.toString('hex') + if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app + if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC + if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC + if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104) + if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent + if (hex.startsWith('00b0000002')) + return opts.nlen0 + ? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK]) + : Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN + if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message + return SW_NOTFOUND + }) + } - const out = await readNdefLnurlw(transmit) - expect(out).toBe(LNURLW) + it('reads CC → NDEF file (E104) and returns the lnurlw', async () => { + const transmit = cardMock() + expect(await readNdefLnurlw(transmit)).toBe(LNURLW) // First APDU selects the NDEF application (AID D2760000850101). - expect(Buffer.from((transmit.mock.calls[0][0] as Buffer)).toString('hex')).toContain( - 'd2760000850101' - ) + expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101') }) it('returns null if selecting the NDEF app fails', async () => { - const transmit = vi.fn().mockResolvedValue(Buffer.from([0x6a, 0x82])) // file not found SW - expect(await readNdefLnurlw(transmit)).toBeNull() + expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull() }) it('returns null on an empty NDEF file', async () => { - const transmit = vi - .fn() - .mockResolvedValueOnce(SW_OK) - .mockResolvedValueOnce(SW_OK) - .mockResolvedValueOnce(Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK])) // NLEN = 0 - expect(await readNdefLnurlw(transmit)).toBeNull() + expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull() }) }) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index e7e3f00..ecafc07 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -39,35 +39,27 @@ export function extractLnurlw(ndef: Buffer): string | null { return m ? m[0] : null } -/** - * Read the NDEF file of a Type-4 tag and return the extracted lnurlw, or null. - * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. - */ -export async function readNdefLnurlw( - transmit: (apdu: Buffer, maxLen: number) => Promise -): Promise { - const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) - const ok = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 +const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 - // 1) Select the NDEF Tag Application (AID D2760000850101). - if (!ok(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { - return null - } - // 2) Select the NDEF file (EF 0x0004). - if (!ok(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x04]))) return null - // 3) Read the 2-byte NLEN header. +/** Select an EF by its 2-byte file id and read + parse its NDEF message. */ +async function readNdefFile( + send: (bytes: number[]) => Promise, + fid: [number, number] +): Promise { + if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null + // 2-byte NLEN header at offset 0. const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02]) - if (!ok(lenResp)) return null + if (!swOk(lenResp)) return null const nlen = (lenResp[0] << 8) | lenResp[1] if (nlen <= 0 || nlen > 0x2000) return null - // 4) Read the NDEF message (starts at offset 2), in <=250-byte chunks. + // NDEF message starts at offset 2; read in <=250-byte chunks. const chunks: Buffer[] = [] let offset = 2 let remaining = nlen while (remaining > 0) { const toRead = Math.min(remaining, 0xfa) const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead]) - if (!ok(resp)) break + if (!swOk(resp)) break const data = resp.subarray(0, resp.length - 2) if (data.length === 0) break chunks.push(data) @@ -77,6 +69,42 @@ export async function readNdefLnurlw( return extractLnurlw(Buffer.concat(chunks)) } +/** + * Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null. + * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. + * + * Select the NDEF Tag Application, read the Capability Container to learn the + * real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use), + * then read that file. Falls back to E104/0004 if the CC read is unavailable. + */ +export async function readNdefLnurlw( + transmit: (apdu: Buffer, maxLen: number) => Promise +): Promise { + const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) + + // Select the NDEF Tag Application (AID D2760000850101). + if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + return null + } + + // Prefer the FileID advertised by the Capability Container (EF E103). + const candidates: Array<[number, number]> = [] + if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) { + const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f]) + // CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID. + if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) candidates.push([cc[9], cc[10]]) + } + for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { + if (!candidates.some((c) => c[0] === fid[0] && c[1] === fid[1])) candidates.push(fid) + } + + for (const fid of candidates) { + const found = await readNdefFile(send, fid) + if (found) return found + } + return null +} + let stopFn: (() => void) | null = null /**