Skip to content

serdartpkl/cryptag

Repository files navigation

CrypTag

GitHub stars PolyForm Noncommercial license


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.

Install

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 install

To 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/cryptag

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 .mjs file) and use a normal top-level import — 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-pcsc

nfc-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 ABI
npm run rebuild:electron              # …or for Electron (defaults to v26)
npm run rebuild:electron -- 28.2.0    # …a specific Electron version

From 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 version

Quick 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 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.

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 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":"…"}).

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 new connect()).
  • 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. 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).

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; details is 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.

Development

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 ABI

License

PolyForm Noncommercial License 1.0.0 © Serdar Tepekule.

Free for noncommercial use. Commercial use requires a separate license — contact info@cryptag.io to arrange one.

About

NTAG424 DNA Toolkit — program tags and verify their Secure Dynamic Messaging (SDM) output.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors