Skip to content

serdartpkl/cryptag

Repository files navigation

CrypTag

npm version node version 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 (@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.

Packages

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

Install

npm install @cryptag/sdk
# …or just one half:
npm install @cryptag/encoder
npm install @cryptag/decoder

The 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 with npm run rebuild. For Electron, build it for the Electron ABI with npm run rebuild:electron (defaults to Electron 26; pass a version with npm run rebuild:electron -- 28.2.0).

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

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

Commands

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

Results and Errors

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

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

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 ABI

License

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

About

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

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors