CrypTag is a complete NTAG424 DNA toolkit: one half programs tags over a contactless reader, the other verifies the tap output they produce — sharing the same cryptographic core and a single, structured error model.
- Encoder (
@cryptag/encoder) — talk to a tag over a PC/SC reader: discover, authenticate (EV2 & LRP), read/write files, configure SDM profiles, and manage keys (AN10922 diversification). - Decoder (
@cryptag/decoder) — verify the SDM URL/token a tag produces (plain, encrypted and full). No hardware required.
@cryptag/sdk is the umbrella package that re-exports both. @cryptag/crypto (primitives) and
@cryptag/common (error model and validation) are shared internals.
import { CrypTagEncoder, CrypTagDecoder } from '@cryptag/sdk';📖 You can find detailed documentation on cryptag.io.
| Package | Role |
|---|---|
@cryptag/sdk |
Umbrella — re-exports CrypTagEncoder + CrypTagDecoder |
@cryptag/encoder |
Tag programming over PC/SC |
@cryptag/decoder |
SDM URL/token verification |
@cryptag/crypto |
Shared AES/LRP/CMAC/diversification primitives |
@cryptag/common |
Shared error model and argument validation |
npm install @cryptag/sdk
# …or just one half:
npm install @cryptag/encoder
npm install @cryptag/decoderThe encoder needs a PC/SC contactless reader. Its native binding (
nfc-pcsc→@pokusew/pcsclite) is compiled on install. If you switch Node versions, rebuild it withnpm run rebuild. For Electron, build it for the Electron ABI withnpm run rebuild:electron(defaults to Electron 26; pass a version withnpm run rebuild:electron -- 28.2.0).
A tag carries its SDM data in one of two NDEF records: a URL (the chip emits a
browser-openable link with the picc_data/enc/cmac query params) or a text
record (a JSON blob your own app reads). The cryptography is identical — only the container
differs, and readNDEF parses both into the same sdmParams.params (including the
cmacSeparator the tag needs), so the decode call is the same for both.
Program a tag, read back the URL it emits, then verify that tap on your backend:
import { CrypTagEncoder, CrypTagDecoder } from '@cryptag/sdk';
const encoder = new CrypTagEncoder();
await encoder.connect();
// 'full' = encrypted PICC data + encrypted file data + CMAC. encodeTag discovers
// the tag and authenticates with key 0 internally.
await encoder.encodeTag('full', {
ndefType: 'url', // 'url' or 'text'
url: 'https://cryptag.app/verify',
fileData: 'HELLO-FROM-CRYPTAG',// plain string encrypted into every tap
encSize: 128, // enc field length in hex chars (32/64/128)
counterLimit: 3000, // SDM tap counter stops after 3000 taps
resetCounter: true, // reset the counter to zero before encoding
enableTTStatus: false, // TagTamper status mirroring (TagTamper variant only)
compressed: false, // shorter URLs without parameter names
sdmSettings: null, // advanced SDM access-right override
});
// encodeTag already re-discovered the new SDM config, so readNDEF sees it: the
// chip fills the placeholders live and readNDEF parses them into separate fields.
const ndef = await encoder.readNDEF();
await encoder.disconnect();
const sdm = ndef.data.sdmParams.params; // { picc_data, enc, cmac, cmacSeparator } — ready for decodeFull
// Verify and decode on your backend (no hardware).
const decoder = new CrypTagDecoder({
keyList: { // keys must match the tag (factory = all-zero)
'2': { masterKey: '00000000000000000000000000000000', diversify: false },
'3': { masterKey: '00000000000000000000000000000000', diversify: false },
},
sdmSettings: { sdmMetaRead: 2, sdmFileRead: 3 },
});
const out = decoder.decodeFull(sdm); // sdm.cmacSeparator wires the MAC input automatically
if (out.success && out.data.cmacValid) {
console.log('authentic tap →', { uid: out.data.uid, counter: out.data.counter, file: out.data.fileData });
}readNDEF also hands you the full link at ndef.data.url.
Same full profile, but written as a JSON text record instead of a URL — drop url, set
ndefType: 'text'. SDM builds the JSON itself, and the decode side is identical:
import { CrypTagEncoder, CrypTagDecoder } from '@cryptag/sdk';
const encoder = new CrypTagEncoder();
await encoder.connect();
// 'full' = encrypted PICC data + encrypted file data + CMAC. encodeTag discovers
// the tag and authenticates with key 0 internally.
await encoder.encodeTag('full', {
ndefType: 'text', // JSON text record instead of a URL
fileData: 'HELLO-FROM-CRYPTAG',// plain string encrypted into every tap
encSize: 128, // enc field length in hex chars (32/64/128)
counterLimit: 3000, // SDM tap counter stops after 3000 taps
resetCounter: true, // reset the counter to zero before encoding
enableTTStatus: false, // TagTamper status mirroring (TagTamper variant only)
compressed: false, // shorter token instead of named fields
sdmSettings: null, // advanced SDM access-right override
});
// encodeTag already re-discovered the new SDM config, so readNDEF sees it: the
// chip fills the placeholders live and readNDEF parses them into separate fields.
const ndef = await encoder.readNDEF();
await encoder.disconnect();
const sdm = ndef.data.sdmParams.params; // same shape (incl. cmacSeparator) as the URL case
// Verify and decode on your backend (no hardware).
const decoder = new CrypTagDecoder({
keyList: { // keys must match the tag (factory = all-zero)
'2': { masterKey: '00000000000000000000000000000000', diversify: false },
'3': { masterKey: '00000000000000000000000000000000', diversify: false },
},
sdmSettings: { sdmMetaRead: 2, sdmFileRead: 3 },
});
const out = decoder.decodeFull(sdm); // identical to the URL example
if (out.success && out.data.cmacValid) {
console.log('authentic tap →', { uid: out.data.uid, counter: out.data.counter, file: out.data.fileData });
}readNDEF hands you the raw record at ndef.data.text (e.g. {"picc_data":"…","enc":"…","cmac":"…"}).
NTAG424 DNA commands implemented, by communication mode. The full per-method API (encoder + decoder), with examples, lives in the documentation.
Authentication (4 Commands)
- EV2First
- LRPFirst
- EV2NonFirst
- LRPNonFirst
Plain Mode (10 Commands)
- GetFileSettings
- ChangeFileSettings
- GetFileCounters
- GetVersion
- ISOReadBinary
- ISOSelectFile
- ISOUpdateBinary
- ReadData
- WriteData
- ReadSig
MAC Mode (7 Commands)
- GetFileSettings
- ChangeFileSettings
- GetFileCounters
- GetKeyVersion
- GetVersion
- ReadData
- WriteData
Full Mode (11 Commands)
- ChangeKey
- ChangeKey0
- ChangeFileSettings
- GetCardUID
- GetFileCounters
- ReadData
- WriteData
- ReadSig
- SetConfiguration (FailedCtr)
- SetConfiguration (RandomID)
- SetConfiguration (LRP)
Total: 37 commands (some appear in multiple communication modes with distinct implementations).
Both halves return the same result shape (duration in ms is added on encoder tag operations):
{ success: true, data: { /* method-specific */ }, duration: 12 }
{ success: false, error: { code, message, details? } }For the decoder, data holds { cmacValid, uid, counter, … }. success: true means the decode
ran — authenticity is data.cmacValid, so always check it (a tap can decode cleanly yet still fail
CMAC verification). A hard failure (bad input or a decode error) returns { success: false, error }.
error.code is shared across both halves:
| Code | Category | When it happens |
|---|---|---|
E100 |
Connection | Reader, card, or transport problem (no reader, no card, discovery failed) |
E200 |
Authentication | Wrong key, MAC mismatch, permission denied, or auth temporarily blocked |
E300 |
Command | A tag command did not complete (select, read/write, change key/settings) |
E400 |
Validation | Bad argument or configuration (encoder + decoder) |
E500 |
Unknown | Unmapped/unexpected error |
E600 |
Decoding | A decode could not be completed (decoder: malformed data or a key/config problem) |
When a failure originates at the tag, its ISO/NTAG424 status word (e.g. 911E, 919D, 91AE) is
translated into one of the categories above with a descriptive message. A result's error is always
a plain object { code, message, details? } — no stack, no class — so it logs and JSON.stringifys
identically. (Internally the SDK throws a CrypTagError, but that never leaks into a result.)
encodeTag() configures a tag so that, on each tap, it emits a URL containing SDM placeholders
(picc_data, enc, cmac, …). Your backend parses those query parameters and passes them to the
matching decoder method (decodePlain/decodeEncrypted/decodeFull), which verifies the CMAC
and returns the UID, the tap counter, and — for the full profile — the decrypted file data.
- Node.js ≥ 14 (ES modules).
- Encoder only: a PC/SC contactless reader (e.g. ACR122U) and an NTAG424 DNA tag.
This is an npm-workspaces monorepo. From the repo root:
npm install # install + link all workspaces
npm run types # type-check/emit .d.ts for all packages
npm run rebuild # rebuild the encoder's native binding for Node
npm run rebuild:electron # …or for the Electron ABIPolyForm Noncommercial License 1.0.0 © Serdar Tepekule.
Free for noncommercial use. Commercial use requires a separate license — open an issue on GitHub to arrange one.
