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.
📖 You can find detailed documentation on cryptag.io.
CrypTag is distributed as source (not on a package registry) — and it runs straight from source, with no build step. Clone the repo and install:
git clone https://github.com/serdartpkl/cryptag.git
cd cryptag
npm installTo consume it from a separate project, install it straight from GitHub or by local path:
npm install serdartpkl/cryptag
# or, from a local clone:
npm install /path/to/cryptagThen import { CrypTagEncoder, CrypTagDecoder } from 'cryptag'.
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 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).
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 ABI
npm 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 ABI
npx @electron/rebuild -f -w nfc-pcsc # …or for your project's Electron versionA 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';
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 decodeFull
const 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 automatically
const 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.
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 case
const 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 example
const 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":"…"}).
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();NTAG424 DNA commands implemented, by communication mode. The full per-method API (encoder + decoder), with examples, lives in the documentation.
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).
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.)
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.
Single package, flat src/, no build step — the code runs straight from source. From the repo root:
npm install # install dependencies
npm run types # (optional) emit .d.ts into types/ for TypeScript consumers
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 — contact info@cryptag.io to arrange one.
