ntag424
Version:
A Node.js implementation for interop with NTAG 424 DNA.
386 lines (385 loc) • 16.3 kB
JavaScript
import * as buffer from "./buffer.js";
import * as ntagCrypto from "./crypto/aes/crypto.js";
import { deriveSessionKeys } from "./crypto/session.js";
import { CommandResponse } from "./response.js";
import * as configuration from "./serializer/configuration.js";
import * as fileSettings from "./serializer/fileSettings.js";
/**
* Terms:
* - Elementary File (EF)
* - Dedicated File (DF)
* - Master File (MF)
*
* Table 39, ISO IEC 7816-4-1
*/
export const isoSelectFileMode = Object.freeze({
MF_DF_EF: 0b0,
CHILD_DF: 0b1,
EF_UNDER_CURRENT_DF: 0b10,
PARENT_DF_OF_CURRENT_DF: 0b11,
/** Eg, application identifier */
BY_DF_NAME: 0b100,
/** Path without the MF identifier */
FROM_MF: 0b1000,
/** Path without the MF identifier */
FROM_CURRENT_DF: 0b1001,
});
/**
* Section 8.2.3 NT4H2421Gx.pdf
*/
const standardFileIds = {
/** Capability Container */
cc: Buffer.of(0xe1, 0x03),
ndef: Buffer.of(0xe1, 0x04),
raw: Buffer.of(0xe1, 0x05),
};
/**
* Section 8.2.3 NT4H2421Gx.pdf
*/
// biome-ignore lint/correctness/noUnusedVariables: maybe used some day :harold:
const standardFileNumbers = {
/** Capability Container */
cc: 1,
ndef: 2,
raw: 3,
};
export function createTagSession(reader, options) {
let commandCounter = options?.initialCommandCounter ?? 0;
const log = options?.logger ?? undefined;
let authentication = options?.authentication ?? undefined;
return {
isAuthenticated: () => !!authentication,
getUid,
getFileSettings,
getFileSettingsRaw,
selectFile,
readStandardFile,
writeStandardFile,
getCardUid,
setConfiguration,
getKeyVersion,
getFileCounters,
writeData,
authenticate,
setFileSettings,
setFileSettingsRaw,
changeKey,
};
// #region Raw sending + Packing/unpacking commands
/**
* Runs a basic framed NXP native command.
* **Does not affect the command counter**.
*/
async function sendNativeCommand(command, header, data, macData, comment) {
const buffers = [header];
if (data) {
buffers.push(data);
}
if (macData) {
buffers.push(macData);
}
return sendIsoCommand(0x90, command, 0x00, 0x00, Buffer.concat(buffers), 0x00, // all
comment);
}
/**
* Runs a standard ISO/IEC7816-4 communication frame.
* **Does not affect the command counter**.
*/
async function sendIsoCommand(instructionClass, instruction, param1, param2, data, expectedResponseLength, comment) {
const arr = [instructionClass, instruction, param1, param2];
if (data !== null) {
arr.push(data.length & 0xff);
arr.push(...data);
}
if (expectedResponseLength !== null) {
arr.push(expectedResponseLength & 0xff);
}
const commandBuffer = Buffer.from(arr);
log?.debug(`[${comment}]`, "-> %s", buffer.format(commandBuffer));
const response = await reader.transmit(commandBuffer, 0x80);
log?.debug(`[${comment}]`, "<- %s", buffer.format(response));
if (response.length < 2) {
throw new Error("Got malformed response.");
}
return CommandResponse.create(response.subarray(-2), response.length > 2 ? response.subarray(0, -2) : null);
}
// #endregion
/** @deprecated */
async function send(value, comment, responseMaxLength = 40) {
const b = value instanceof Buffer ? value : Buffer.from(value);
log?.debug(`[${comment}]`, "-> %s", buffer.format(b));
const data = await reader.transmit(b, responseMaxLength);
log?.debug(`[${comment}]`, "<- %s", buffer.format(data));
return data;
}
async function authenticate(keyNumber, key) {
const ecRndB = await authStep1(keyNumber);
const [ecRndAp, rndA, rndB] = await authStep2(key, ecRndB);
const newAuthentication = deriveSessionKeys(key, ecRndAp, rndA, rndB);
log?.debug(`Authenticated with key ${keyNumber}`);
commandCounter = 0;
authentication = newAuthentication;
}
/**
* @see AuthenticateEV2First - Part1
* @returns {Buffer} ecRndB
*/
async function authStep1(keyNumber) {
const result = await sendNativeCommand(0x71, Buffer.of(keyNumber, 0x00), null, null, authStep1.name);
return result.getDataOrThrow(authStep1.name);
}
/**
* @see AuthenticateEV2First - Part2
* @returns {Buffer} ecRndB
*/
async function authStep2(key, ecRndB) {
const rndB = ntagCrypto.decryptCbc(key, ecRndB, ntagCrypto.createEmptyIv(), false);
const rndBp = buffer.rotateLeft(rndB);
const nodeCrypto = await import("node:crypto");
const rndA = nodeCrypto.randomBytes(rndB.length);
// If keySize === blockSize, this would actually be the same as an ECB pass
// But the spec states that it needs to be CBC (which is only relevant if keySize > blockSize)
const message = ntagCrypto.encryptCbc(key, Buffer.concat([rndA, rndBp]), ntagCrypto.createEmptyIv(), false);
const result = await sendNativeCommand(0xaf, message, null, null, authStep2.name);
return [result.getDataOrThrow(authStep2.name), rndA, rndB];
}
async function getUid() {
// TODO: Find this in the docs and replace it with some iso command
const res = await send(Buffer.of(0xff, 0xca, 0x00, 0x00, 0x00), getUid.name);
if (res.at(-1) !== 0x00) {
throw new Error("Error getting UID");
}
return res.subarray(0, -2);
}
/**
* Performs GetFileSettings
* @see Section 10.7.2
* @returns Something
*/
async function getFileSettings(fileNumber) {
const res = await getFileSettingsRaw(fileNumber);
return fileSettings.parseFromGetFileSettings(res);
}
/**
* Performs GetFileSettings
* @see Section 10.7.2
* @returns Something
*/
async function getFileSettingsRaw(fileNumber) {
if (fileNumber < 0 || fileNumber > 0b0000_1111) {
throw new Error("fileNumber must fit into 4 bits");
}
const result = await sendWithMac(0xf5, Buffer.of(fileNumber & 0xf), null, getFileSettingsRaw.name);
return result.getDataOrThrow(getFileSettingsRaw.name);
}
async function setFileSettings(fileNumber, value, tagParams) {
const serialized = fileSettings.serializeForChangeFileSettings(value, tagParams);
return await setFileSettingsRaw(fileNumber, serialized);
}
/**
* Performs ChangeFileSettings
* @See Section 10.7.1
*/
async function setFileSettingsRaw(fileNumber, value) {
if (fileNumber < 0 || fileNumber > 0b0000_1111) {
throw new Error("fileNumber must fit into 4 bits");
}
const command = 0x5f;
const header = Buffer.of(fileNumber & 0b0000_1111);
if (!authentication) {
const result = await sendPlainCommand(command, header, value, setFileSettings.name);
result.throwIfError(setFileSettingsRaw.name);
return;
}
const result = await sendEncrypted(command, header, value, setFileSettings.name);
result.throwIfError(setFileSettings.name);
}
async function selectFile(fileId, mode) {
if (fileId.length > 16) {
throw new Error("fileId cannot be larger than 16 bytes");
}
const result = await sendIsoCommand(0x00, 0xa4, mode, 0x0c, // Don't return FCI (No response data if L_e field absent, or proprietary if L_e field present)
fileId, 0x80, // Largest supported value by our reader. Found out by trail&error
selectFile.name);
result.throwIfError(getFileSettingsRaw.name);
}
/**
* Performs ISOReadBinary on the StandardDataFile.
*
* @see Section 10.9.2
*/
async function readStandardFile() {
await selectFile(standardFileIds.ndef, isoSelectFileMode.MF_DF_EF);
const result = await sendIsoCommand(0x00, 0xb0, 0x00, 0x00, null, 0x80 - 2, readStandardFile.name);
return result.getDataOrThrow(readStandardFile.name);
}
/** Performs ISOUpdateBinary on the StandardDataFile.
*
* @see Section 10.9.3
*/
async function writeStandardFile(contents) {
if (contents.length > 255) {
throw new Error("Buffer is too large. StandardDataFile can hold a maximum of 255 bytes.");
}
await selectFile(standardFileIds.ndef, isoSelectFileMode.MF_DF_EF);
const result = await sendIsoCommand(0x00, 0xd6, 0x00, 0x00, contents.length === 0 ? null : contents, null, writeStandardFile.name);
result.throwIfError(writeStandardFile.name);
}
async function getCardUid(commMode) {
const result = await sendSwitchedCommand(commMode, 0x51, Buffer.alloc(0), null, getCardUid.name);
return result.getDataOrThrow(getCardUid.name);
}
async function writeData(commMode, fileNumber, data, offset = 0) {
const header = Buffer.alloc(7);
header.writeUint8(fileNumber);
header.writeUintLE(offset, 1, 3);
header.writeUintLE(data.byteLength, 4, 3);
const result = await sendSwitchedCommand(commMode, 0x8d, header, data, writeData.name);
result.throwIfError(writeData.name);
}
async function changeKey(keyNumber, oldKey, newKey, newKeyVersion) {
if (oldKey.byteLength !== 16) {
throw new Error("`oldKey` must be 16 bytes long");
}
if (newKey.byteLength !== 16) {
throw new Error("`newKey` must be 16 bytes long");
}
let keyData;
if (keyNumber === 0) {
keyData = Buffer.from([...newKey, newKeyVersion & 0xff]);
}
else {
keyData = Buffer.from([
...buffer.xor(oldKey, newKey),
newKeyVersion & 0xff,
...buffer.create.u32le(ntagCrypto.crc(newKey)),
]);
}
const result = await sendEncrypted(0xc4, Buffer.of(keyNumber), keyData, changeKey.name);
result.throwIfError(changeKey.name);
}
async function setConfiguration(config) {
const [header, data] = configuration.serializeConfigurationUpdate(config);
const result = await sendEncrypted(0x5c, Buffer.of(header), data, setConfiguration.name);
result.throwIfError();
}
async function getKeyVersion(keyNumber) {
if (keyNumber < 0 || keyNumber > 4) {
throw new Error("`keyNumber` must be in the range `[0, 4]`");
}
const res = await sendWithMac(0x64, Buffer.of(keyNumber & 0b111), null, getKeyVersion.name);
const data = res.getDataOrThrow(getKeyVersion.name);
if (data.length !== 1) {
throw new Error("Returned data has unsupported length");
}
return data[0];
}
/**
* The `getFileCounters` command supports retrieving of the current values associated with
* the SDMReadCtr related with a StandardData file after enabling Secure Dynamic Messaging.
*
* @remarks Requires CommMode "full" (authenticated session).
*
* @param {number} fileNumber
*/
async function getFileCounters(fileNumber) {
if (fileNumber < 0 || fileNumber > 0b11111) {
throw new Error("`fileNumber` must be in the range `[0, 0b11111]`");
}
const res = await sendEncrypted(0xf6, Buffer.of(fileNumber & 0b11111), null, getFileCounters.name);
const counters = res.getDataOrThrow(getFileCounters.name);
if (counters.byteLength !== 5) {
throw new Error("Expected the response to have exactly 5 bytes");
}
const sdmReadCounter = counters.readUintLE(0, 3);
const rfu0 = counters.readUintLE(3, 2);
if (rfu0 !== 0) {
throw new Error("Expected RFU counters to be `0x0000`.");
}
return sdmReadCounter;
}
//#region Command Dispatch
async function sendSwitchedCommand(commMode, command, header, data, comment) {
switch (commMode) {
case "plain":
return sendPlainCommand(command, header, data, comment);
case "mac":
return sendWithMac(command, header, data, comment);
case "full":
return sendEncrypted(command, header, data, comment);
default:
throw new Error("Unsupported commMode");
}
}
async function sendPlainCommand(command, header, data, comment) {
++commandCounter;
return await sendNativeCommand(command, header, data, null, comment);
}
async function sendWithMac(command, header, data, comment) {
if (!authentication) {
return await sendPlainCommand(command, header, data, comment);
}
const macIn = Buffer.of(command, 0x00, 0x00, ...authentication.TI, ...header, ...(data ?? []));
macIn.writeUInt16LE(commandCounter, 1);
const longMac = ntagCrypto.MAC(authentication.sessionMacKey, macIn);
const macData = ntagCrypto.reduceMac(longMac);
const macedResult = await sendNativeCommand(command, header, data, macData, comment);
++commandCounter; // sendNativeCommand can throw, we only want to increment successful commands
if (macedResult.isError()) {
return macedResult;
}
const macedPayload = macedResult.data;
if (macedPayload === null) {
// there is no additional data to be checked
return new CommandResponse(macedResult.status, null);
}
const actualResponseMac = macedPayload.subarray(-8);
const responseData = macedPayload.subarray(0, -8);
const resultMacInputHeader = Buffer.of(macedResult.status.status2, 0x00, 0x00, // counter
...authentication.TI);
resultMacInputHeader.writeUInt16LE(commandCounter, 1);
const resultMacInput = Buffer.concat([resultMacInputHeader, responseData]);
const expectedResponseMac = ntagCrypto.reduceMac(ntagCrypto.MAC(authentication.sessionMacKey, resultMacInput));
if (Buffer.compare(expectedResponseMac, actualResponseMac) !== 0) {
throw new Error(`Expected response MAC and actual response MAC did not match. Expected: ${expectedResponseMac.toString("hex")}, actual: ${actualResponseMac.toString("hex")}`);
}
return new CommandResponse(macedResult.status, responseData);
}
function encryptData(params, commandCounter, data) {
// Section 9.1.4
const ivInput = Buffer.alloc(16);
ivInput.writeUint8(0xa5, 0);
ivInput.writeUint8(0x5a, 1);
params.TI.copy(ivInput, 2, 0); // 2, 3, 4, 5
ivInput.writeUInt16LE(commandCounter, 6);
// 8 bytes kept to 0
const iv = ntagCrypto.encryptEcb(params.sessionEncryptionKey, ivInput);
return ntagCrypto.encryptCbc(params.sessionEncryptionKey, data, iv, true);
}
function decryptData(params, data) {
// Section 9.1.4
const ivInput = Buffer.alloc(16);
ivInput.writeUint8(0x5a, 0);
ivInput.writeUint8(0xa5, 1);
params.TI.copy(ivInput, 2, 0); // 2, 3, 4, 5
ivInput.writeUInt16LE(commandCounter, 6);
// 8 bytes kept to 0
const iv = ntagCrypto.encryptEcb(params.sessionEncryptionKey, ivInput);
return ntagCrypto.decryptCbc(params.sessionEncryptionKey, data, iv, true);
}
async function sendEncrypted(command, header, data, comment) {
if (!authentication) {
throw new Error("You should not be able to do this");
}
const encryptedData = data !== null && data.length > 0
? encryptData(authentication, commandCounter, data)
: data;
const result = await sendWithMac(command, header, encryptedData, comment);
if (result.isError() || result.data === null || result.data.length === 0) {
return new CommandResponse(result.status, null);
}
const decryptedData = decryptData(authentication, result.getDataOrThrow());
return new CommandResponse(result.status, decryptedData);
}
//#endregion
}