fix(machine): read NTAG424 NDEF via Capability Container (Bolt Card FileID)

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-05 20:02:26 +02:00
commit ea736dfab0
2 changed files with 76 additions and 40 deletions

View file

@ -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()
})
})