Getting Started
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 (
CrypTagEncoder) — 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 (
CrypTagDecoder) — verify the SDM URL/token a tag produces (plain, encrypted and full). No hardware required.
Everything ships in one package — cryptag — with a single entry point and no build step.
Import whichever half you need:
import { CrypTagEncoder, CrypTagDecoder } from 'cryptag';The decoder is pure JavaScript with no native dependency: the PC/SC binding (nfc-pcsc) the
encoder needs is loaded lazily, only when you actually connect to a reader. So importing cryptag
and using CrypTagDecoder runs anywhere — backends, serverless, edge — without a card reader or
its native build.
Install
CrypTag is a licensed, source-available SDK — it is not published to a public package registry. Request a license to get access: email info@cryptag.io and we’ll send your license along with the install instructions.
Once you have access, CrypTag runs straight from source with no build step — then
import { CrypTagEncoder, CrypTagDecoder } from 'cryptag'.
Using CrypTag from CommonJS
cryptag is an ESM-only package. From an ES module project, import it directly:
import { CrypTagEncoder, CrypTagDecoder } from 'cryptag';From CommonJS you don’t need to wrap every call in an async function — pick whichever fits your setup.
Recent Node (v22.12+ / v20.19+) — just require it. cryptag has no top-level await, so modern
Node loads it synchronously:
const { CrypTagEncoder, CrypTagDecoder } = require('cryptag');Any Node version — load it once at startup and cache it. Run the dynamic import() a single time,
keep the references, then use them synchronously everywhere after:
let CrypTagEncoder, CrypTagDecoder;
async function init() { ({ CrypTagEncoder, CrypTagDecoder } = await import('cryptag')); // imported once}
// after init() has run:const encoder = new CrypTagEncoder();For a long-lived CommonJS host — such as an Electron main process — wrap that once-and-cache idea in a small reusable module:
// cryptag-wrapper.js (CommonJS)let mod = null;
// Imported once, then served from cache on every later call.async function getCrypTag() { if (!mod) mod = await import('cryptag'); return mod;}
module.exports = { getCrypTag };The CrypTag desktop app uses exactly this wrapper.
Tip: if you control the project, the simplest path is to make it an ES module (
"type": "module"or a.mjsfile) and use a normal top-levelimport— no dynamic import needed.
The Encoder’s Native Binding
The decoder needs no native dependency — it is pure JavaScript and runs anywhere. The encoder
talks to a PC/SC contactless reader through nfc-pcsc (which builds @pokusew/pcsclite), declared
as an optional peer dependency. That means it is not installed automatically — so decoder-only
and serverless/edge consumers never pull a native build. If you use the encoder, install it alongside
cryptag:
npm install nfc-pcscnfc-pcsc is also loaded lazily at runtime: importing cryptag and using only CrypTagDecoder never
touches it. Calling an encoder operation without it returns a clean error
(PC/SC support is unavailable, native binding is not installed).
Rebuilding the Native Binding
The native binding is tied to a specific ABI. If you switch Node or Electron versions, rebuild it.
Inside the cryptag repo (development):
npm run rebuild # rebuild for the current Node ABInpm run rebuild:electron # …or for Electron (defaults to v26)npm run rebuild:electron -- 28.2.0 # …a specific Electron versionFrom a separate project (cryptag installed as a dependency — rebuild the binding in place):
npm rebuild nfc-pcsc # rebuild for the current Node ABInpx @electron/rebuild -f -w nfc-pcsc # …or for your project's Electron versionQuick Start
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.
URL Record
Program a tag, read back the URL it emits, then verify that tap on your backend:
import { CrypTagEncoder, CrypTagDecoder } from 'cryptag';
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', { // 'url' or 'text' ndefType: 'url', url: 'https://cryptag.io/verify', // plain string encrypted into every tap fileData: 'HelloWorld!', // enc field length in hex chars (32 / 64 / 128) encSize: 32, // SDM tap counter stops after 3000 taps counterLimit: 3000, // reset the counter to zero before encoding resetCounter: true, // TagTamper status mirroring (TagTamper variant only) enableTTStatus: false, // shorter URLs without parameter names compressed: false,});
// 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();
// { picc_data, enc, cmac, cmacSeparator } — ready for decodeFullconst sdm = ndef.data.sdmParams.params;
// Verify and decode on your backend (no hardware).// keys must match the tag (factory = all-zero)const decoder = new CrypTagDecoder({ keyList: { '2': { masterKey: '00000000000000000000000000000000', diversify: false }, '3': { masterKey: '00000000000000000000000000000000', diversify: false }, }, sdmSettings: { sdmMetaRead: 2, sdmFileRead: 3 },});
// sdm.cmacSeparator wires the MAC input automaticallyconst out = decoder.decodeFull(sdm);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.
Text Record
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';
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', { // JSON text record instead of a URL ndefType: 'text', // plain string encrypted into every tap fileData: 'HelloWorld!', // enc field length in hex chars (32 / 64 / 128) encSize: 32, // SDM tap counter stops after 3000 taps counterLimit: 3000, // reset the counter to zero before encoding resetCounter: true, // TagTamper status mirroring (TagTamper variant only) enableTTStatus: false, // shorter token instead of named fields compressed: false,});
// 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();
// same shape (incl. cmacSeparator) as the URL caseconst sdm = ndef.data.sdmParams.params;
// Verify and decode on your backend (no hardware).// keys must match the tag (factory = all-zero)const decoder = new CrypTagDecoder({ keyList: { '2': { masterKey: '00000000000000000000000000000000', diversify: false }, '3': { masterKey: '00000000000000000000000000000000', diversify: false }, }, sdmSettings: { sdmMetaRead: 2, sdmFileRead: 3 },});
// identical to the URL exampleconst out = decoder.decodeFull(sdm);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":"…"}).
The Connect-Once Model
You connect once. After that, the encoder manages discovery and authentication for you:
- Auto-discovery — every tag operation first ensures the tag has been discovered (version, file settings, access rights, SDM state). The result is cached; discovery only re-runs when the cache is invalidated (after a configuration change, or
resetSession()/ a newconnect()). - Auto-authentication — read/write/configuration methods look up the key required by the target file’s access rights (from discovery) and authenticate with it automatically. An existing session is reused when already authenticated with that key; if a different key is needed mid-transaction, a NonFirst authentication switches keys without losing the session.
'free'access skips authentication;'never'returns an authentication error.
Because of this, the examples assume a connected tag and do not repeat connect() / discoverTag() / authenticate(). Call authenticate() / discoverTag() yourself only when you want to force a specific key or refresh state early.
import { CrypTagEncoder } from 'cryptag';
const tag = new CrypTagEncoder();await tag.connect(); // the one required step
// from here, operations auto-discover and authenticate as needed:const ndef = await tag.readNDEF();const counter = await tag.getCounter();
await tag.disconnect();Commands
NTAG424 DNA commands implemented, by communication mode.
Authentication establishes the session before any command below — EV2First, EV2NonFirst, LRPFirst, LRPNonFirst (AES EV2 and LRP, first and follow-on).
Each command is implemented for the communication modes marked below:
| Command | Plain | Encrypted/MAC | Full |
|---|---|---|---|
| GetVersion | ✓ | ✓ | |
| GetCardUID | ✓ | ||
| GetTTStatus | ✓ | ||
| GetFileSettings | ✓ | ✓ | |
| ChangeFileSettings | ✓ | ✓ | ✓ |
| GetFileCounters | ✓ | ✓ | ✓ |
| GetKeyVersion | ✓ | ||
| ReadData | ✓ | ✓ | ✓ |
| WriteData | ✓ | ✓ | ✓ |
| ReadSig | ✓ | ✓ | |
| ChangeKey | ✓ | ||
| ChangeKey0 | ✓ | ||
| SetConfiguration | ✓ | ||
| SelectApplication | ✓ | ||
| ISOSelectFile | ✓ | ||
| ISOReadBinary | ✓ | ||
| ISOUpdateBinary | ✓ |
17 commands across the three communication modes (28 mode-specific implementations), plus the 4 authentication commands above. SetConfiguration is one command with several options (FailedCtr, RandomUID, LRP).
Results and Errors
Both halves return the same result shape — a discriminated union on success, so in TypeScript
if (result.success) narrows to data and the else branch narrows to error:
{ success: true, data: { /* method-specific */ }, duration: 12 }{ success: false, error: { code, message, details? } }data— the method’s payload; present on success (omitted only when a method returns no payload).duration— milliseconds; added on encoder tag operations only.error— always a plain{ code, message, details? }object;detailsis optional extra context (e.g. the tag status word or the offending field) and may be any serialisable value.
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 or undecodable data) |
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.)
How They Fit Together
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.
Requirements
- Node.js ≥ 14 (ES modules).
- Encoder only: a PC/SC contactless reader (e.g. ACR122U) and an NTAG424 DNA tag.