diff --git a/ntag215.js b/ntag215.js index ec36b74..f1f38cc 100644 --- a/ntag215.js +++ b/ntag215.js @@ -250,6 +250,11 @@ var txBuffer = new Uint8Array(32); */ var tags = []; +/** + * An array containing the amiibo keys. + */ +var keys = null; + /** * If {@link fastRx} should process data. */ @@ -1051,6 +1056,124 @@ function fastRx(data) { _Bluetooth.write(data); } +/** + * Sets the internal UID (unique identifier) for a tag, or generates a new one if not provided. + * + * @param {Uint8Array} tag - The tag to set the internal UID for. + * @param {Uint8Array} [uid] - (Optional) The UID to set. If not provided, a new one will be generated. + */ +function setInternalUid(tag, uid) { + uid = uid || generateUid(); + + // Set the first 8 bytes of the new UID. + tag.set(uid.slice(0, 8), 0x1D4); + + // ... (omitted) check byte is elsewhere. + tag[0x00] = uid[8]; +} + +/** + * Sets the internal password for a tag. + * + * @param {Uint8Array} tag - The tag to set the internal password for. + */ +function setInternalPass(tag) { + tag[532] = 0xAA ^ tag[1] ^ tag[3]; + tag[533] = 0x55 ^ tag[2] ^ tag[4]; + tag[534] = 0xAA ^ tag[3] ^ tag[5]; + tag[535] = 0x55 ^ tag[4] ^ tag[6]; +} + +/** + * Sets a generated tag with specific values and random 32-byte salt. + * + * @param {Uint8Array} tag - The tag to set with generated values. + * @param {Uint8Array} identifier - The identifier data to set in the tag. + */ +function setGeneratedTag(tag, identifier) { + // Fill the tag with zeros. + tag.fill(0); + + // Set specific values in the tag. + tag.set([0x48, 0x0F, 0xE0, 0xF1, 0x10, 0xFF, 0xEE], 0x01); + tag[0x28] = 0xA5; + tag.set(identifier, 0x1DC); + tag.set([0x01, 0x00, 0x0F, 0xBD], 0x208); + tag.set([0x04, 0x5F], 0x20F); + + // Generate a random 32-byte salt. + for (let i = 0; i < 32; i++) { + tag[0x1E8 + i] = Math.floor(Math.random() * 256); + } + + // Set the internal UID and internal password for the tag. + setInternalUid(tag); + setInternalPass(tag); + + // Pack and encrypt the tag. + amiitool.pack(keys, tag); +} + +/** + * Randomizes the internal UID for a tag in a specific slot. + * + * @param {number} slot - The slot number of the tag to randomize the UID for. + */ +function randomizeUid(slot) { + // Unpack and decrypt the tag. + amiitool.unpack(keys, tags[slot]); + + // Set the internal UID and internal password for the tag. + setInternalUid(tags[slot]); + setInternalPass(tags[slot]); + + // Pack and encrypt the tag. + amiitool.pack(keys, tags[slot]); + + // Refresh the tag if needed. + refreshTag(slot); +} + +/** + * Generates a tag with specific values in a specific slot and refreshes it. + * + * @param {number} slot - The slot number of the tag to generate. + * @param {Uint8Array} identifier - The identifier data to set in the tag. + */ +function generateTag(slot, identifier) { + // Set the generated tag with specific values. + setGeneratedTag(tags[slot], identifier); + + // Refresh the tag if needed. + refreshTag(slot); +} + +/** + * Convert a string to Title Case where the first letter of each word is capitalized. + * + * @param {string} str - The input string to be converted to Title Case. + * @returns {string} The input string converted to Title Case. + */ +function titleCase(str) { + return str.toLowerCase().replace(/(^|\s)\w/g, function(match) { + return match.toUpperCase(); + }); +} + +/** + * Generates a beep sound with the specified frequency and duration. + * + * @param {number} freq - The frequency of the beep sound in Hz. + * @param {number} [ms=50] - The duration of the beep sound in milliseconds (default is 50ms). + * @returns {void} + */ +function beep(freq, ms) { + analogWrite(D14, 0.5, { freq: freq, soft: true }); + setTimeout(function() { + digitalWrite(D14, 1); + }, ms || 50); +} + // Check if the firmware flashed to the puck contains the needed NTAG emulation code. if (typeof _NTAG215 !== "undefined") { // If no name has been assigned, set a generic one based on the hardware ID. @@ -1060,7 +1183,7 @@ if (typeof _NTAG215 !== "undefined") { } else if (BOARD === "PIXLJS") { storage.write(PUCK_NAME_FILE, "Pixl.js " + NRF.getAddress().substr(12, 5).split(":").join("")); } else { - storage.write(PUCK_NAME_FILE, BOARD.charAt(0).toUpperCase() + BOARD.substring(1).toLowerCase() + " " + NRF.getAddress().substr(12, 5).split(":").join("")); + storage.write(PUCK_NAME_FILE, titleCase(BOARD) + " " + NRF.getAddress().substr(12, 5).split(":").join("")); } } @@ -1146,6 +1269,8 @@ if (typeof _NTAG215 !== "undefined") { } } + keys = getBufferClone(storage.readArrayBuffer("key_retail.bin")); + // Initialize watches and start BLE advertising. initialize(); } else { diff --git a/puck-ntag215-manager/src/EspruinoTerser.ts b/puck-ntag215-manager/src/EspruinoTerser.ts index dd4291e..33644ac 100644 --- a/puck-ntag215-manager/src/EspruinoTerser.ts +++ b/puck-ntag215-manager/src/EspruinoTerser.ts @@ -10,14 +10,18 @@ const options: MinifyOptions = { "_Bluetooth", "_Connect", "_Disconnect", - "_Data" + "_Data", + "randomizeUid", + "generateTag" ], "pure_getters": true, "passes": 10 }, "mangle": { "reserved": [ - "fastMode" + "fastMode", + "randomizeUid", + "generateTag" ] }, "output": { diff --git a/puck-ntag215-manager/src/StringView.ts b/puck-ntag215-manager/src/StringView.ts new file mode 100644 index 0000000..48bd5d3 --- /dev/null +++ b/puck-ntag215-manager/src/StringView.ts @@ -0,0 +1,365 @@ +export interface CharacterStruct { + bytesRead?: number, + charVal?: number +} + +const encodingUtf8 = "UTF-8"; +const encodingAscii = "ASCII"; + +const defaultEncoding = encodingUtf8; +const replacementChar = 0xFFFD; + +const createUtf8Char = function(charCode: number, arr: number[]){ + if(charCode < 0x80){ + //Treat ASCII differently since it doesn't begin with 0x80 + arr.push(charCode); + }else{ + const limits = [0x7F, 0x07FF, 0xFFFF, 0x1FFFFF]; + let i = 0; + while(true){ + i++; + + if(i === limits.length){ + console.error("UTF-8 Write - attempted to encode illegally high code point - " + charCode); + createUtf8Char(replacementChar, arr); + return; + } + if(charCode <= limits[i]){ + //We have enough bits in 'i+1' bytes to encode this character + i += 1; + + let aByte = 0; + let j; + //add i bits of length indicator + for(j = 0; j < i; j++){ + aByte <<= 1; + aByte |= 1; + } + //Shift length indicator to MSB + aByte <<= (8 - i); + //Add 8 - (i + 1) bits of code point to fill the first byte + aByte |= (charCode >> (6 * (i - 1))); + arr.push(aByte); + //Fist byte already processed, start at 1 rather than 0 + for(j = 1; j < i; j++){ + //Continuation flag + aByte = 0x80; + //6 bits of code point + aByte |= (charCode >> (6 * (i - (j + 1)))) & 0xBF; + arr.push(aByte); + } + return; + } + } + } +}; + + +const utf8ReadChar = function(charStruct: CharacterStruct, buf: DataView, readPos: number, maxBytes: number){ + const firstByte = buf.getUint8(readPos); + charStruct.bytesRead = 1; + charStruct.charVal = 0; + if(firstByte & 0x80){ + let numBytes = 0; + let aByte = firstByte; + while(aByte & 0x80){ + numBytes++; + aByte <<= 1; + } + if(numBytes === 1){ + console.error("UTF-8 read - found continuation byte at beginning of character"); + charStruct.charVal = replacementChar; + return; + } + if(numBytes > maxBytes){ + console.error("UTF-8 read - attempted to read " + numBytes + " byte character, " + (maxBytes - numBytes) + " bytes past end of buffer"); + charStruct.charVal = replacementChar; + return; + } + //2 bytes means 3 bits reserved for UTF8 byte encoding, 5 bytes remaining for codepoint, and so on + charStruct.charVal = firstByte & (0xFF >> (numBytes + 1)); + for(let i = 1; i < numBytes; i++){ + aByte = buf.getUint8(readPos + i); + //0xC0 should isolate the continuation flag which should be 0x80 + if((aByte & 0xC0) !== 0x80){ + console.error("UTF-8 read - attempted to read " + numBytes + " byte character, found non-continuation at byte " + i); + charStruct.charVal = replacementChar; + //Wikipedia (awesomely reliable source of information /sarcasm) suggests + // parsers should replace first byte of invalid sequence and continue + charStruct.bytesRead = 1; + return; + } + charStruct.charVal <<= 6; + //0x3F is the mask to remove the continuation flag + charStruct.charVal |= (aByte & 0x3F); + + if(i === 1){ + const rshift = (8 - (numBytes + 1)) - 1; + if((charStruct.charVal >> rshift) === 0){ + console.error("UTF-8 read - found overlong encoding"); + charStruct.charVal = replacementChar; + charStruct.bytesRead = 1; + return; + } + } + charStruct.bytesRead++; + } + if(charStruct.charVal > 0x10FFFF){ + console.error("UTF-8 read - found illegally high code point " + charStruct.charVal); + charStruct.charVal = replacementChar; + charStruct.bytesRead = 1; + return; + } + + }else{ + charStruct.charVal = firstByte; + } +}; + +const writeStringUtf8 = function(str: string){ + const arr: number[] = []; + for(let i = 0; i < str.length; i++){ + createUtf8Char(str.charCodeAt(i), arr); + } + return arr; +}; + +const writeStringAscii = function(str: string){ + const arr: number[] = []; + for(let i = 0; i < str.length; i++){ + let chr = str.charCodeAt(i); + if(chr > 255){ + chr = "?".charCodeAt(0); + } + arr.push(chr); + } + return arr; +}; + +const readStringUtf8 = function(buf: DataView, byteOffset: number, bytesToRead: number, terminator: number){ + const nullTerm = (typeof bytesToRead === "undefined"); + let readPos = byteOffset || 0; + if(!nullTerm && readPos + bytesToRead > buf.byteLength){ + throw new Error("Attempted to read " + ((readPos + bytesToRead) - buf.byteLength) + " bytes past end of buffer"); + } + const str = []; + const charStruct: CharacterStruct = {}; + while(readPos < buf.byteLength && (nullTerm || bytesToRead > (readPos - byteOffset))){ + utf8ReadChar(charStruct, buf, readPos, nullTerm ? buf.byteLength - (readPos + byteOffset) : (bytesToRead - (readPos - byteOffset))); + readPos += charStruct.bytesRead; + if(nullTerm && charStruct.charVal === terminator){ + break; + } + str.push(String.fromCharCode(charStruct.charVal)); + } + return { + str: str.join(""), + byteLength: (readPos - byteOffset) + }; +}; + +const readStringAscii = function(buf: DataView, byteOffset: number, bytesToRead: number, terminator: number){ + const str = []; + let byteLength = 0; + byteOffset = byteOffset || 0; + let nullTerm = false; + if(typeof bytesToRead === "undefined"){ + nullTerm = true; + bytesToRead = buf.byteLength - buf.byteOffset; + } + for(let i = 0; i < bytesToRead; i++){ + const charCode = buf.getUint8(i + byteOffset); + byteLength++; + if(nullTerm && charCode === terminator){ + break; + } + str.push(String.fromCharCode(charCode)); + } + return { + str: str.join(""), + byteLength: byteLength + }; +}; + +/** + * The reader function should return an object with two properties - `str` being the decoded string, and `byteLength` representing the number of bytes consumed in reading the string. The string should not include the terminator character, but the character should be included in the byteLength value. + * @param buf The DataView object to operate on + * @param byteOffset The offset in the data buffer to begin reading + * @param bytesToRead The offset in the data buffer to begin reading + * @param terminator The char code for the terminator character (if bytesToRead is undefined) + */ +export type ReaderFunction = (buf: DataView, byteOffset?: number, bytesToRead?: number, terminator?: number) => { + str: string; + byteLength: number; +} + +/** + * The writer function should accept a single argument - the string to encode, and must return a JavaScript array of unsigned byte values representing that string in the specified encoding. + * @param str The string to encode + */ +export type WriterFunction = (str: string) => number[] + +/** + * JavaScript DataView helper for reading/writing strings. + */ +class StringView { + #readString: Map = new Map([ + [encodingAscii, readStringAscii], + [encodingUtf8, readStringUtf8], + ]); + #writeString: Map = new Map([ + [encodingAscii, writeStringAscii], + [encodingUtf8, writeStringUtf8], + ]); + + /** + * + * @param encoding + * @throws Error + * @returns + */ + #checkEncoding(encoding?: string){ + if(typeof encoding === "undefined"){ + encoding = defaultEncoding; + } + if(!this.#writeString.has(encoding)){ + throw new Error("Unknown string encoding '" + encoding + "'"); + } + return encoding; + } + + #getReader(encoding?: string): ReaderFunction { + encoding = this.#checkEncoding(encoding); + var reader: ReaderFunction | undefined + + if (reader = this.#readString.get(encoding)) { + return reader + } + + throw new Error("Unknown string encoding '" + encoding + "'"); + } + + #getWriter(encoding?: string): WriterFunction { + encoding = this.#checkEncoding(encoding); + var writer: WriterFunction | undefined + + if (writer = this.#writeString.get(encoding)) { + return writer + } + + throw new Error("Unknown string encoding '" + encoding + "'"); + } + + addStringCodec(encoding: string, reader: ReaderFunction, writer: WriterFunction){ + this.#readString.set(encoding, reader); + this.#writeString.set(encoding, writer); + } + + stringByteLength(str: string, encoding: string){ + encoding = this.#checkEncoding(encoding); + return this.#getWriter(encoding)(str).length; + } + + /** + * Returns the string represented by this DataView's buffer starting at `byteOffset`. The string will be made from `byteLength` bytes (defaulting to the length of the buffer minus `byteOffset` if not specified) interpreted using the specified encoding. + * + * This method will throw an Error if the provided `byteOffset` and `byteLength` would cause access past the end of the buffer. + * + * If `encoding` is provided to `getString` then `byteLength` must also be provided. + * The `byteLength` defaults to the length of the buffer minus the `byteOffset` if not provided. + * @param dataView + * @param byteOffset + * @param byteLength + * @param encoding + * @throws Error + * @returns + */ + getString(dataView: DataView, byteOffset: number = 0, byteLength?: number, encoding?: string){ + return this.getStringData(dataView, byteOffset, byteLength, encoding).str; + } + + /** + * Functionally identical to the method `getString`, but returns an object with two properties: `str`, and `byteLength` - the `str` property is the read string, and the `byteLength` property indicates the number of bytes that were consumed while reading it. Note that if decoding issues are encountered this byte length value may differ from a subsequently calculated byte length for the returned string. + * @param dataView + * @param byteOffset + * @param byteLength + * @param encoding + * @throws Error + * @returns + */ + getStringData(dataView: DataView, byteOffset: number = 0, byteLength?: number, encoding?: string){ + encoding = this.#checkEncoding(encoding); + if(!byteLength){ + byteLength = dataView.byteLength - byteOffset; + } + return this.#getReader(encoding)(dataView, byteOffset, byteLength); + } + + /** + * Returns the string represented by this DataView's buffer starting at `byteOffset` and reading until a null byte (or the numeric char code specified as `terminator`) or the end of the buffer is encountered, interpreted using the specified encoding. + * @param dataView + * @param byteOffset + * @param encoding + * @param terminator + * @throws Error + * @returns + */ + getStringNT(dataView: DataView, byteOffset?: number, encoding?: string, terminator?: number) { + return this.getStringDataNT(dataView, byteOffset, encoding, terminator).str; + } + + /** + * Functionally identical to the method `getStringNT`, but returns an object with two properties: `str`, and `byteLength` - the `str` property is the read string (**not including** null byte), and the `byteLength` property indicates the number of bytes that were consumed while reading it (**including** the null byte). Note that if decoding issues are encountered this byte length value may differ from a subsequently calculated byte length for the returned string. + * @param dataView + * @param byteOffset + * @param encoding + * @param terminator + * @throws Error + * @returns + */ + getStringDataNT(dataView: DataView, byteOffset?: number, encoding?: string, terminator?: number) { + encoding = this.#checkEncoding(encoding); + return this.#getReader(encoding)(dataView, byteOffset, undefined, terminator); + } + + /** + * Writes the provided value into this DataView's buffer starting at `byteOffset`. The string will be encoded using the specified encoding. This function will return the number of bytes written to the string, which may be less than the number required to completely represent the string if `byteOffset` is too close to the end of the buffer. Note that this function may write a partial character at the end of the string in the case of truncation. + * @param dataView + * @param byteOffset + * @param value + * @param encoding + * @throws Error + * @returns + */ + setString(dataView: DataView, byteOffset?: number, value?: string, encoding?: string){ + encoding = this.#checkEncoding(encoding); + const arr = this.#getWriter(encoding)(value); + let i; + for(i = 0; i < arr.length && byteOffset + i < dataView.byteLength; i++){ + dataView.setUint8(byteOffset + i, arr[i]); + } + return i; + } + + /** + * Writes the provided value into this DataView's buffer starting at `byteOffset`. The string will be encoded using the specified encoding and terminated with a null byte. This function will return the number of bytes written to the string, which may be less than the number required to completely represent the string if `byteOffset` is too close to the end of the buffer. If the string was truncated it will still be terminated by a null byte. The null byte will be included the the return value. Note that this function may write a partial character at the end of the string in the case of truncation. Note that unlike getStringNT this method does not accept a custom terminator argument - if a custom terminator is required then use `setString` with the desired terminator appended to the string. + * @param dataView + * @param byteOffset + * @param value + * @param encoding + * @throws Error + * @returns + */ + setStringNT(dataView: DataView, byteOffset: number = 0, value?: string, encoding?: string){ + let bytesWritten = this.setString(dataView, byteOffset, value, encoding); + if(byteOffset + bytesWritten >= dataView.byteLength){ + //Incomplete string write, or written up against end of buffer + //Pull back 1 byte to put null term in + bytesWritten -= 1; + } + dataView.setUint8(byteOffset + bytesWritten, 0); + return bytesWritten + 1; + } +} + +export default new StringView();