From d69adce44a0ba954a9a551e0e9f4e9e88ee3c939 Mon Sep 17 00:00:00 2001 From: taichunmin Date: Tue, 6 Aug 2024 15:24:40 +0800 Subject: [PATCH] v0.3.23: Added demo: mifare-keychain --- package.json | 10 +- pages/demos.md | 16 + pug/include/bootstrapV4.pug | 4 +- pug/src/mifare-keychain.pug | 632 ++++++++++++++++++++++++++++++++ src/ChameleonUltra.ts | 116 ++++-- src/plugin/BufferMockAdapter.ts | 4 +- src/plugin/WebserialAdapter.ts | 14 +- yarn.lock | 41 ++- 8 files changed, 787 insertions(+), 50 deletions(-) create mode 100644 pug/src/mifare-keychain.pug diff --git a/package.json b/package.json index 491462a..290c7d8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "module": "./dist/index.mjs", "name": "chameleon-ultra.js", "type": "commonjs", - "version": "0.3.22", + "version": "0.3.23", "bugs": { "url": "https://github.com/taichunmin/chameleon-ultra.js/issues" }, @@ -20,7 +20,7 @@ } ], "dependencies": { - "@taichunmin/buffer": "^0.13.9", + "@taichunmin/buffer": "^0.13.10", "@taichunmin/crc": "^0.0.14", "debug": "^4.3.7", "jszip": "^3.10.1", @@ -37,7 +37,7 @@ "@types/jest": "^29.5.14", "@types/livereload": "^0.9.5", "@types/lodash": "^4.17.13", - "@types/node": "^22.8.7", + "@types/node": "^22.9.0", "@types/pug": "^2.0.10", "@types/serve-static": "^1.15.7", "@types/uglify-js": "^3.17.5", @@ -54,7 +54,7 @@ "eslint-config-standard": "^17.1.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-local-rules": "^3.0.2", - "eslint-plugin-n": "^17.12.0", + "eslint-plugin-n": "^17.13.1", "eslint-plugin-promise": "^7.1.0", "eslint-plugin-pug": "^1.2.5", "eslint-plugin-tsdoc": "^0.3.0", @@ -75,7 +75,7 @@ "tsup": "^8.3.5", "tsx": "^4.19.2", "typedoc": "^0.26.11", - "typedoc-plugin-mdn-links": "^3.3.6", + "typedoc-plugin-mdn-links": "^3.3.7", "typedoc-plugin-missing-exports": "^3.0.0", "typedoc-plugin-rename-defaults": "^0.7.1", "typescript": "^5.6.3", diff --git a/pages/demos.md b/pages/demos.md index aa9deb7..10870f0 100644 --- a/pages/demos.md +++ b/pages/demos.md @@ -78,6 +78,22 @@ A ChameleonUltra tool for mifare class 1k. - - - +## [mifare-keychain.html](https://taichunmin.idv.tw/chameleon-ultra.js/mifare-keychain.html) + +Keep a few mifare tags in browser with indexedDB. + +![](https://i.imgur.com/1Xe3Fgs.png) + +

Features

+ +- Save/Load tags to/from browser's indexedDB. +- Export/Import tags with CSV format. +- Read/Emulate tag with ChameleonUltra slot. +- Scan Anti-Collision data from tag. +- Write block0 to magic tag. (Gen1a, Gen2) + +- - - + ## [hf14a-scanner.html](https://taichunmin.idv.tw/chameleon-ultra.js/hf14a-scanner.html) A tool to scan uid of ISO/IEC 14443-A tags. diff --git a/pug/include/bootstrapV4.pug b/pug/include/bootstrapV4.pug index 13b5da4..7eba91f 100644 --- a/pug/include/bootstrapV4.pug +++ b/pug/include/bootstrapV4.pug @@ -40,8 +40,10 @@ html(lang="zh-Hant") body include ./livereload block content - script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js") + script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js") script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js") + script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/dexie@4/dist/dexie.min.js") + script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/flexsearch@0.7/dist/flexsearch.bundle.min.js") script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/jquery@3/dist/jquery.min.js") script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/json5@2/dist/index.min.js") script(crossorigin="anonymous", src="https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js") diff --git a/pug/src/mifare-keychain.pug b/pug/src/mifare-keychain.pug new file mode 100644 index 0000000..6976410 --- /dev/null +++ b/pug/src/mifare-keychain.pug @@ -0,0 +1,632 @@ +extends /include/bootstrapV4 + +block beforehtml + - const title = 'Mifare Keychain' + +block style + meta(property="og:description", content="Keep a few mifare tags in browser with indexedDB.") + meta(property="og:locale", content="zh_TW") + meta(property="og:title", content=title) + meta(property="og:type", content="website") + meta(property="og:url", content=`${baseurl}mifare-keychain.html`) + style + :sass + [v-cloak] + display: none + body, .h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 + font-family: 'Noto Sans TC', sans-serif + .input-group-prepend > .input-group-text + width: 80px + .letter-spacing-n1px + &, .btn, textarea, select, input + letter-spacing: -1px + .letter-spacing-n2px + &, .btn, textarea, select, input + letter-spacing: -2px + .text-sm + font-size: 0.875rem + .shield + display: -ms-inline-flexbox + display: inline-flex + font-size: 75% + letter-spacing: -.5px + line-height: 1 + position: relative + vertical-align: middle + white-space: nowrap + .shield-name, .shield-value + padding: .25em .4em + .shield-name + align-items: center + border-bottom-left-radius: .25rem + border-top-left-radius: .25rem + display: -ms-inline-flexbox + display: inline-flex + .shield-value + border-bottom-right-radius: .25rem + border-top-right-radius: .25rem + border: 1px solid #ccc + border-left: 0 + .bg-00A98F + background-color: #00A98F + .bg-B02CCE + background-color: #B02CCE + .bg-004680 + background-color: #004680 + .bg-5D6D7E + background-color: #5D6D7E + +block content + #app.my-3.container.text-monospace(v-cloak) + h4.mb-3.text-center.letter-spacing-n1px #[.bgicon.bgicon-chameleon-ultra.mr-1] #{title} + .form-group.letter-spacing-n1px + label Connect method: + .input-group.input-group-sm.mb-3 + select.form-control(v-model="ls.adapter") + option(value="ble") BLE (PC & Android & iPhone) + option(value="usb") USB Serial (PC only) + .input-group-append: button.btn.btn-outline-secondary(@click="btnAdapterTips") #[i.fa.fa-fw.fa-question] + .card.shadow-sm.mb-3 + h6.card-header #[i.fa.mr-1.fa-tags] Tag Anti Collision + .card-body.px-3.pt-3.pb-2.letter-spacing-n1px + .form-group.was-validated.mb-2 + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center TYPE + select.form-control.form-control-sm.letter-spacing-n1px(v-model.number="ss.tagType") + option(v-for="v, k of tagTypeOptions", :value="k") {{ v }} + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center NAME + input.form-control(placeholder="Name of HF slot", v-model="ss.name") + .input-group-append: button.btn.btn-outline-secondary(type="button", @click="ss.name = ''") #[i.fa.fa-fw.fa-times] + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center UID + input.form-control(pattern="[\\dA-Fa-f]{8}([\\dA-Fa-f]{6})?([\\dA-Fa-f]{6})?", maxlength="20", placeholder="Hex format of UID", required, v-model="ss.uid") + .input-group-append: button.btn.btn-outline-secondary(type="button", @click="ss.uid = ''") #[i.fa.fa-fw.fa-times] + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center ATQA + input.form-control(pattern="[\\dA-Fa-f]{4}", maxlength="4", placeholder="Hex format of ATQA", required, v-model="ss.atqa") + .input-group-append: button.btn.btn-outline-secondary(type="button", @click="ss.atqa = ''") #[i.fa.fa-fw.fa-times] + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center SAK + input.form-control(pattern="[\\dA-Fa-f]{2}", maxlength="2", placeholder="Hex format of SAK", required, v-model="ss.sak") + .input-group-append: button.btn.btn-outline-secondary(type="button", @click="ss.sak = ''") #[i.fa.fa-fw.fa-times] + .input-group.input-group-sm.mb-2.was-validated + .input-group-prepend: span.input-group-text.justify-content-center ATS + input.form-control(pattern="([\\dA-Fa-f]{2})*", placeholder="Hex format of ATS", v-model="ss.ats") + .input-group-append: button.btn.btn-outline-secondary(type="button", @click="ss.ats = ''") #[i.fa.fa-fw.fa-times] + small.form-text.text-muted(v-if="savedTagName") Tag is saved as "{{ savedTagName }}". + small.form-text.text-muted(v-else) Tag is not saved yet. + .row.mx-n1.mb-2 + .col.px-1: button.btn.btn-block.btn-outline-primary.d-flex.flex-column.align-items-center(type="button", @click="btnLoadTag") + i.fa.fa-2x.fa-fw.fa-folder-open-o + .mt-1 Load + .col.px-1: button.btn.btn-block.btn-outline-success.d-flex.flex-column.align-items-center(type="button", @click="btnSaveTag") + i.fa.fa-2x.fa-fw.fa-floppy-o + .mt-1 Save + .col.px-1: button.btn.btn-block.btn-outline-info.d-flex.flex-column.align-items-center(type="button", @click="btnTagCsv") + i.fa.fa-2x.fa-fw.fa-database + .mt-1 CSV + .card.shadow-sm.mb-3 + h6.card-header.bg-light #[.bgicon.bgicon-chameleon-ultra.mr-1] HF Slot Emulator + .card-body.px-3.pt-3.pb-2.letter-spacing-n1px + .input-group.input-group-sm.mb-2 + .input-group-prepend: span.input-group-text.justify-content-center SLOT + select.form-control.form-control-sm.letter-spacing-n1px(v-model.number="ss.slot") + option(value="-1") use actived slot + option(v-for="i of _.range(8)" :value="i") Slot {{ i + 1 }} + .row.mx-n1.mb-2 + .col.px-1: button.btn.btn-block.btn-outline-primary.d-flex.flex-column.align-items-center(type="button", @click="btnReadSlot") + i.fa.fa-2x.fa-fw.fa-upload + .mt-1 Read + .col.px-1: button.btn.btn-block.btn-outline-success.d-flex.flex-column.align-items-center(type="button", @click="btnEmulateSlot") + i.fa.fa-2x.fa-fw.fa-download + .mt-1 Emulate + .card.shadow-sm.mb-3 + h6.card-header.bg-light #[.bgicon.bgicon-nfc.mr-1] Magic Tag (Gen1a, Gen2) + .card-body.px-3.pt-3.pb-2.letter-spacing-n1px + .row.mx-n1.mb-2 + .col.px-1: button.btn.btn-block.btn-outline-primary.d-flex.flex-column.align-items-center(type="button", @click="btnScanTag") + span.fa-stack #[i.fa.fa-tag.fa-stack-2x] #[i.fa.fa-arrow-up.fa-inverse.fa-stack-1x] + .mt-1 Scan + .col.px-1: button.btn.btn-block.btn-outline-success.d-flex.flex-column.align-items-center(type="button", @click="btnWriteTag") + span.fa-stack #[i.fa.fa-tag.fa-stack-2x] #[i.fa.fa-arrow-down.fa-inverse.fa-stack-1x] + .mt-1 Write + .col.px-1: button.btn.btn-block.btn-outline-info.d-flex.flex-column.align-items-center(type="button", @click="btnEditMfkeys") + i.fa.fa-2x.fa-fw.fa-key + .mt-1.letter-spacing-n2px Gen2 Keys + .modal.fade(data-keyboard="false", tabindex="-1", ref="tagPicker") + .modal-dialog.modal-dialog-centered.modal-xl + .modal-content + .modal-header.d-flex.align-items-center + .modal-title.flex-fill + .input-group + .input-group-prepend: span.input-group-text Search + input.form-control#h-keyword(placeholder="name or uid" v-model="tagPicker.keyword") + .input-group-append: button.btn.btn-outline-danger(type="button", @click="tagPicker.keyword = ''") Clear + button.close(type="button", data-dismiss="modal"): span × + .modal-body.p-0 + ul.list-group.list-group-flush + button.list-group-item.list-group-item-action.d-flex.flex-column( + :class="[tagPicker?.cur === tag?.id ? 'list-group-item-secondary' : '']", + :key="tag?.id", + @click="tagPicker?.cb?.(tag)", + type="button", + v-for="tag of filteredTags", + ) + h5.my-1 {{ tag.name }} + .d-flex.flex-wrap.mx-n1 + .shield.mx-1.mb-1 + .shield-name.text-white.bg-00A98F UID + .shield-value.text-dark.bg-white {{ tag.uid }} + .shield.mx-1.mb-1 + .shield-name.text-white.bg-004680 ATQA + .shield-value.text-dark.bg-white {{ tag.atqa }} + .shield.mx-1.mb-1 + .shield-name.text-white.bg-B02CCE SAK + .shield-value.text-dark.bg-white {{ tag.sak }} + .shield.mx-1.mb-1(v-if="tag.ats") + .shield-name.text-white.bg-5D6D7E ATS + .shield-value.text-dark.bg-white {{ tag.ats }} + .modal.fade(data-backdrop="static", data-keyboard="false", tabindex="-1", ref="exportimport") + .modal-dialog.modal-dialog-centered.modal-xl.align-items-stretch + .modal-content + .modal-body.d-flex.flex-column + textarea.form-control.form-control-sm.flex-fill(v-model="exportimport.text") + small.text-muted.form-text Click "Copy" button to copy text, or modify data then click "Apply" button. + .modal-footer + button.btn.btn-outline-success(type="button", @click="btnCopy(exportimport.text, $refs.exportimport)") Copy + button.btn.btn-secondary(type="button", @click="exportimport?.cb?.()") Cancel + button.btn.btn-primary(type="button", @click="exportimport?.cb?.(exportimport.text)") Apply + +block script + script. + const { Dexie, FlexSearch } = window + const { Buffer, ChameleonUltra, Debug, DeviceMode, FreqType, TagType, WebbleAdapter, WebserialAdapter } = window.ChameleonUltraJS + const ultraUsb = new ChameleonUltra() + ultraUsb.use(new Debug()) + ultraUsb.use(new WebserialAdapter()) + const ultraBle = new ChameleonUltra() + ultraBle.use(new Debug()) + ultraBle.use(new WebbleAdapter()) + + const toHex = buf => _.toUpper(buf.toString('hex')) + const WELL_KNOWN_KEYS = ['FFFFFFFFFFFF', 'A0A1A2A3A4A5', 'D3F7D3F7D3F7'] + const tagTypeOptions = { + [TagType.MIFARE_1024]: 'MIFARE_1024', + [TagType.MIFARE_Mini]: 'MIFARE_Mini', + [TagType.MIFARE_2048]: 'MIFARE_2048', + [TagType.MIFARE_4096]: 'MIFARE_4096', + } + const getNxpMifareClassType = sak => { + // https://www.nxp.com/docs/en/application-note/AN10833.pdf + sak &= 0b11001 // bit 1, 4, 5 + if (sak === 0x09) return TagType.MIFARE_Mini + if (sak === 0x08) return TagType.MIFARE_1024 + if (sak === 0x19) return TagType.MIFARE_2048 + if (sak === 0x18) return TagType.MIFARE_4096 + } + const validateTagOrFail = c => { + for (const k of ['uid', 'sak', 'atqa', 'ats']) c[k] = _.toUpper(c[k]) + if (!/^[\dA-F]{4}$/.test(c.atqa)) throw new Error(`invalid atqa: ${c.atqa}`) + if (!/^[\dA-F]{2}$/.test(c.sak)) throw new Error(`invalid sak: ${c.sak}`) + if (!/^(?:[\dA-F]{8}|[\dA-F]{14}|[\dA-F]{20})$/.test(c.uid)) throw new Error(`invalid uid: ${c.uid}`) + if (!/^([\dA-F]{2})*$/.test(c.ats)) throw new Error(`invalid ats: ${c.ats}`) + if (!_.has(tagTypeOptions, c.tagType)) throw new Error(`invalid tagType: ${c.tagType}`) + c.tagType = _.toInteger(c.tagType) + return _.pick(c, ['atqa', 'ats', 'name', 'sak', 'tagType', 'uid']) + } + const validateTag = c => { + try { + return validateTagOrFail(c) + } catch (err) { + return null + } + } + + window.vm = new Vue({ + el: '#app', + data: { + ls: { + adapter: 'ble', + }, + ss: { + atqa: '0004', + ats: '', + name: '', + sak: '08', + slot: -1, + tagType: TagType.MIFARE_1024, + uid: 'DEADBEEF', + }, + idbKeyVal: { + mfkeys: WELL_KNOWN_KEYS.join('\n'), + }, + exportimport: { text: '', cb: null }, + idb: null, + savedTagName: null, + tagPicker: { cb: null, cur: null, flexsearch: null, keyword: '', mTags: null, tags: [] }, + tagTypeOptions, + }, + async mounted () { + const { ultra } = this + // 自動儲存功能 + for (const [storage, key] of [[localStorage, 'ls'], [sessionStorage, 'ss']]) { + try { + const saved = JSON5.parse(storage.getItem(location.pathname)) + if (saved) this.$set(this, key, _.merge(this[key], saved)) + } catch (err) {} + this.$watch(key, () => { + storage.setItem(location.pathname, JSON5.stringify(this[key])) + }, { deep: true }) + } + // indexedDB + const persist = await this.idbPersist() + ultra.emitter.emit('debug', 'web', `persist = ${persist}`) + const idb = this.idb = new Dexie(location.pathname) + idb.version(1).stores({ + tags: '++id, name, &[atqa+ats+sak+tagType+uid]', + keyval: 'key', + }) + try { + const saved = await this.idbLoadKeyVal('keyval') + this.$set(this, 'idbKeyVal', _.merge(this.idbKeyVal, saved)) + } catch (err) {} + this.$watch('idbKeyVal', () => { + void this.idbSaveKeyVal('keyval', this.idbKeyVal) + }, { deep: true }) + // savedTagName + await this.calcSavedTagName() + this.$watch('ssTag', () => { void this.calcSavedTagName() }) + }, + computed: { + ultra () { + return this.ls.adapter === 'usb' ? ultraUsb : ultraBle + }, + ssTag () { + return validateTag(this.ss) + }, + filteredTags () { + const { flexsearch, keyword, mTags, tags } = this.tagPicker + const limit = 100 + if (_.isNil(flexsearch) || _.isNil(mTags) || keyword?.length === 0) return _.take(tags, limit) + return flexsearch.search(keyword, limit).map(id => mTags.get(id)) + }, + mfkeys () { + return Buffer.from(this.idbKeyVal.mfkeys, 'hex').chunk(6) + }, + }, + methods: { + async calcSavedTagName () { + const { idb } = this + try { + this.savedTagName = null + const ssTag = _.omit(this.ssTag, ['name']) + this.savedTagName = (await idb.tags.get(ssTag))?.name + } catch (err) {} + }, + async btnLoadTag () { + const { idb, ultra } = this + try { + this.showLoading({ text: 'Loading tags...' }) + // load tags from indexedDB + const tags = await idb.tags.orderBy('name').toArray() + const cur = _.isNil(this.ssTag) ? null : (await idb.tags.get(_.omit(this.ssTag, ['name'])))?.id + const flexsearch = FlexSearch.Index({ preset: 'default', tokenize: 'full' }) + const mTags = new Map() + for (const tag of tags) { + flexsearch.add(tag.id, `${tag.uid} ${tag.name}`) + mTags.set(tag.id, tag) + } + + // show tag picker + const $ref = window.jQuery(this.$refs.tagPicker) + const newVal = validateTag(await new Promise(resolve => { + this.$set(this, 'tagPicker', { cb: resolve, cur, flexsearch, keyword: '', mTags, tags }) + Swal.close() + $ref.one('hide.bs.modal', () => resolve()).modal('show') + })) + $ref.modal('hide') + await new Promise(resolve => { this.$nextTick(resolve) }) // wait for next tick + if (!_.isNil(newVal)) _.merge(this.ss, newVal) + this.$set(this, 'tagPicker', { cb: null, cur: null, flexsearch: null, keyword: '', mTags: null, tags: [] }) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Load failed', text: err.message }) + } + }, + async btnSaveTag () { + const { ultra } = this + try { + this.showLoading({ text: 'Saving tag...' }) + const { idb } = this + const tag = validateTagOrFail(this.ss) + if (!_.isString(tag.name) || tag.name.length < 1) tag.name = `UID_${tag.uid}` + const oldTag = await idb.tags.get(_.omit(tag, ['name'])) + await idb.tags.put({ ...tag, id: oldTag?.id }) + await this.calcSavedTagName() + await Swal.fire({ icon: 'success', title: 'Save successfully' }) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Save failed', text: err.message }) + } + }, + async btnTagCsv () { + const { idb, ultra } = this + try { + this.showLoading({ text: 'Loading tags...' }) + const mTagType = new Map(_.flatMap(tagTypeOptions, (v, k) => [[v, _.toInteger(k)], [_.toInteger(k), v]])) + // load tags from indexedDB + const oldTags = await idb.tags.orderBy('name').toArray() + for (let i = 0; i < oldTags.length; i++) { + const tag = oldTags[i] = validateTag(oldTags[i]) + if (mTagType.has(tag.tagType)) tag.tagType = mTagType.get(tag.tagType) + } + const oldCsv = Papa.unparse(oldTags, { + columns: ['tagType', 'uid', 'atqa', 'sak', 'ats', 'name'], + header: true, + }) + // show export import + const $ref = window.jQuery(this.$refs.exportimport) + let newVal = await new Promise(resolve => { + this.$set(this, 'exportimport', { cb: resolve, text: oldCsv }) + Swal.close() + $ref.one('hide.bs.modal', () => resolve()).modal('show') + }) + + // import newCsv + while (!_.isNil(newVal)) { + try { + const newTags = Papa.parse(_.trim(newVal), { header: true, skipEmptyLines: true })?.data ?? [] + for (let i = 0; i < newTags.length; i++) { + let tag = newTags[i] + try { + if (mTagType.has(tag.tagType)) tag.tagType = mTagType.get(tag.tagType) + tag = newTags[i] = validateTagOrFail(tag) + if (!_.isString(tag.name) || tag.name.length < 1) tag.name = `UID_${tag.uid}` + } catch (err) { + throw _.update(err, 'message', msg => `${msg}, ${JSON5.stringify(tag)}`) + } + } + await idb.transaction('rw', idb.tags, async () => { + await idb.tags.clear() + await idb.tags.bulkPut(newTags) + }) + await Swal.fire({ icon: 'success', title: `${newTags.length} records imported` }) + break + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Import failed', text: err.message }) + newVal = await new Promise(resolve => { + this.$set(this.exportimport, 'cb', resolve) + }) + } + } + + $ref.modal('hide') + this.$set(this.exportimport, 'cb', null) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Failed', text: err.message }) + } + }, + async btnReadSlot () { + const { ultra } = this + try { + this.showLoading({ text: 'Reading slot...' }) + let { slot } = this.ss + if (slot < 0) slot = await ultra.cmdSlotGetActive() + await ultra.cmdSlotSetActive(slot) + const slotInfo = await ultra.cmdSlotGetInfo() + const tagType = slotInfo[slot].hfTagType + if (!_.has(tagTypeOptions, tagType)) throw new Error(`slot tagType ${TagType[tagType]} is not supported.`) + const slotName = await ultra.cmdSlotGetFreqName(slot, FreqType.HF) + const anticoll = await ultra.cmdHf14aGetAntiCollData() + const tag = validateTag({ + atqa: toHex(anticoll.atqa.toReversed()), + ats: toHex(anticoll.ats), + name: slotName ?? toHex(anticoll.uid), + sak: toHex(anticoll.sak), + tagType, + uid: toHex(anticoll.uid), + }) + ultra.emitter.emit('debug', 'web', `tag = ${JSON.stringify(tag)}`) + _.merge(this.ss, tag) + Swal.close() + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Read failed', text: err.message }) + } + }, + async btnEmulateSlot () { + const { ultra } = this + try { + let { slot } = this.ss + const tag = validateTagOrFail(this.ss) + this.showLoading({ text: 'Emulating slot...' }) + if (slot < 0) slot = await ultra.cmdSlotGetActive() + ultra.emitter.emit('debug', 'web', JSON5.stringify(tag)) + const oldName = await ultra.cmdSlotGetFreqName(slot, FreqType.HF) ?? '(no name)' + const msg1 = `The hf data of slot ${slot + 1} "${oldName}" will be REPLACE! Continue?` + if (!await this.confirm(msg1, 'Yes', 'Cancel')) return + this.showLoading({ text: 'Emulating slot...' }) + await ultra.cmdChangeDeviceMode(DeviceMode.TAG) + // tag anti-coll + const anticoll = { + atqa: Buffer.from(tag.atqa, 'hex').reverse(), + ats: Buffer.from(tag.ats, 'hex'), + sak: Buffer.from(tag.sak, 'hex'), + uid: Buffer.from(tag.uid, 'hex').slice(-4), + } + // reset slot + await ultra.cmdSlotChangeTagType(slot, tag.tagType) + await ultra.cmdSlotResetTagType(slot, tag.tagType) + await ultra.cmdSlotSetEnable(slot, FreqType.HF, true) + await ultra.cmdSlotSetActive(slot) + // set emulated tag + await ultra.cmdMf1SetAntiCollMode(false) + await ultra.cmdHf14aSetAntiCollData(anticoll) + await ultra.cmdMf1EmuWriteBlock(0, this.mf1GenerateBlock0()) // set block0 + await ultra.cmdSlotSetFreqName(slot, FreqType.HF, tag.name) + await ultra.cmdSlotSaveSettings() + await Swal.fire({ icon: 'success', title: 'Emulate successfully!' }) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Emulate Failed', text: err.message }) + } + }, + async btnScanTag () { + const { idb, ultra } = this + try { + this.showLoading({ text: 'Scanning tag...' }) + const anticoll = _.first(await ultra.cmdHf14aScan()) + const tag = validateTagOrFail({ + atqa: toHex(anticoll.atqa.toReversed()), + ats: toHex(anticoll.ats), + name: `UID_${toHex(anticoll.uid)}`, + sak: toHex(anticoll.sak), + tagType: getNxpMifareClassType(anticoll.sak[0]) ?? TagType.MIFARE_1024, + uid: toHex(anticoll.uid), + }) + const savedTagName = (await idb.tags.get(_.omit(tag, ['name'])))?.name + if (!_.isNil(savedTagName)) tag.name = savedTagName + ultra.emitter.emit('debug', 'web', `tag = ${JSON5.stringify(tag)}`) + _.merge(this.ss, tag) + Swal.close() + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Failed to scan tag.', text: err.message }) + } + }, + async btnWriteTag () { + const { mfkeys, ultra } = this + try { + const uid = _.toUpper(this.ss.uid) + if (!/^[\dA-F]{8}$/.test(uid)) throw new Error('Only support 4 bytes UID') + this.showLoading({ text: 'Writing tag...' }) + const errs = [null, null] + const block0 = this.mf1GenerateBlock0() + // try Gen1a + await ultra.mf1Gen1aWriteBlocks(0, block0).catch(err => { + ultra.emitter.emit('error', err) + errs[0] = err + }) + if (!_.isNil(errs[0])) { // try Gen2 + await ultra.mf1WriteBlockByKeys(0, mfkeys, block0).catch(err => { + ultra.emitter.emit('error', err) + errs[1] = err + }) + } + if (!_.some(errs, _.isNil)) { + throw new Error(JSON5.stringify({ + Gen1a: errs[0].message, + Gen2: errs[1].message, + })) + } + // double check + const scanned = toHex(_.first(await ultra.cmdHf14aScan()).uid) + if (scanned !== uid) throw new Error(`UID mismatch after write, scanned = ${scanned}`) + await Swal.fire({ icon: 'success', title: 'Write successfully' }) + } catch (err) { + ultra.emitter.emit('error', err) + await Swal.fire({ icon: 'error', title: 'Write failed', text: err.message }) + } + }, + async btnEditMfkeys () { + const $ref = window.jQuery(this.$refs.exportimport) + const newVal = await new Promise(resolve => { + this.$set(this, 'exportimport', { + cb: resolve, + text: this.idbKeyVal.mfkeys, + }) + $ref.one('hide.bs.modal', () => resolve()).modal('show') + }) + if (!_.isNil(newVal)) this.$set(this.idbKeyVal, 'mfkeys', newVal || WELL_KNOWN_KEYS.join('\n')) + $ref.modal('hide') + this.$set(this.exportimport, 'cb', null) + }, + mf1GenerateBlock0 () { + const { ultra } = this + const { atqa, sak, uid } = this.ss + const tag = { + atqa: Buffer.from(atqa, 'hex').reverse(), + sak: Buffer.from(sak, 'hex'), + uid: Buffer.from(uid, 'hex').slice(-4), + } + const uidLen = tag.uid.length + const block0 = Buffer.pack(`!${uidLen}sBs2s${12 - uidLen}x`, tag.uid, tag.uid.xor(), tag.sak, tag.atqa) + ultra.emitter.emit('debug', 'web', `block0 = ${toHex(block0)}`) + return block0 + }, + async idbLoadKeyVal (collkey) { + try { + const { idb } = this + const saved = {} + const records = await idb[collkey].toArray() + for (const { key, val } of records) saved[key] = val + return saved + } catch (err) { + throw _.update(err, 'data.idbLoadKeyVal', old => old ?? { collkey }) + } + }, + async idbSaveKeyVal (collkey, newVal) { + try { + const { idb } = this + await idb[collkey].bulkPut(_.map(newVal, (val, key) => ({ key, val }))) + } catch (err) { + throw _.update(err, 'data.idbSaveKeyVal', old => old ?? { collkey, newVal }) + } + }, + async idbPersist () { + // https://dexie.org/docs/StorageManager + if (await navigator?.storage?.persisted?.()) return true // already persisted + const permState = (await navigator?.permissions?.query?.({ name: 'persistent-storage' }))?.state ?? 'prompt' + if (!_.includes(['granted', 'prompt'], permState)) throw new Error(`no persistent-storage permission, state = ${permState}`) + if (_.isNil(navigator?.storage?.persist)) throw new Error('indexedDB persist not supported.') + return await navigator.storage.persist() + }, + async btnCopy (text, container = null) { + if (!container) container = document.body + const dom = document.createElement('textarea') + dom.value = text + container.appendChild(dom) + dom.select() + dom.setSelectionRange(0, 1e6) // For mobile devices + document.execCommand('copy') + container.removeChild(dom) + await Swal.fire({ icon: 'success', title: `${text.length} bytes copied.` }) + }, + async btnAdapterTips () { + await Swal.fire({ + title: 'Browser & OS', + html: 'BLE is supported in ChromeOS, Chrome for Windows 10, macOS, Android 6.0, Microsoft Edge for Windows and Bluefy for iPhone and iPad.
USB is supported on all desktop platforms (ChromeOS, Linux, macOS, and Windows).', + }) + }, + async confirm (text, confirmButtonText, cancelButtonText) { + return await new Promise((resolve, reject) => { + let isConfirmed = false + const args = { + cancelButtonColor: '#3085d6', + cancelButtonText, + confirmButtonColor: '#d33', + confirmButtonText, + didDestroy: () => { resolve(isConfirmed) }, + focusCancel: true, + icon: 'warning', + reverseButtons: true, + showCancelButton: true, + text, + } + Swal.fire(args).then(res => { isConfirmed = res.isConfirmed }) + }) + }, + showLoading (opts = {}) { + opts = { + allowOutsideClick: false, + showConfirmButton: false, + ...opts, + } + if (Swal.isVisible()) return Swal.update(_.omit(opts, ['progressStepsDistance'])) + Swal.fire({ ...opts, didRender: () => { Swal.showLoading() } }) + }, + }, + }) diff --git a/src/ChameleonUltra.ts b/src/ChameleonUltra.ts index 5ae686d..44f4be1 100644 --- a/src/ChameleonUltra.ts +++ b/src/ChameleonUltra.ts @@ -131,7 +131,7 @@ function toUpperHex (buf: Buffer): string { export class ChameleonUltra { #deviceMode: DeviceMode | null = null #isDisconnecting: boolean = false - #rxReader: ReadableStreamDefaultReader | null = null + #rxReader: ReadableStreamDefaultReader | null = null #supportedCmds: Set = new Set() readonly #emitErr: (err: Error) => void readonly #hooks = new Map>() @@ -770,7 +770,7 @@ export class ChameleonUltra { const readResp = await this.#createReadRespFn({ cmd }) await this.#sendCmd({ cmd }) const data = (await readResp()).data - return (_.toUpper(data.toString('hex')).match(/.{2}/g) ?? []).join(':') + return (toUpperHex(data).match(/.{2}/g) ?? []).join(':') } /** @@ -3602,7 +3602,42 @@ export class ChameleonUltra { } /** - * Read the sector data of Mifare Classic by given keys. + * Read a block data of Mifare Classic by given keys. + * @param block - The block number to be read. + * @param keys - The keys dictionary. + * @returns The block data read from a mifare tag. An error is thrown if the block cannot be read. + * @group Mifare Classic Related + * @example + * ```js + * async function run (ultra) { + * const { Buffer } = await import('https://cdn.jsdelivr.net/npm/chameleon-ultra.js@0/+esm') + * const keys = Buffer.from('FFFFFFFFFFFF\n000000000000\nA0A1A2A3A4A5\nD3F7D3F7D3F7', 'hex').chunk(6) + * const data = await ultra.mf1ReadBlockByKeys(0, keys) + * console.log(data.toString('hex')) + * } + * + * await run(vm.ultra) // you can run in DevTools of https://taichunmin.idv.tw/chameleon-ultra.js/test.html + * ``` + */ + async mf1ReadBlockByKeys (block: number, keys: Buffer[]): Promise { + const sector = Math.trunc(block / 4) + const sectorKey = await this.mf1CheckSectorKeys(sector, keys) + if (_.isEmpty(sectorKey)) throw new Error('No valid key') + for (const keyType of [Mf1KeyType.KEY_B, Mf1KeyType.KEY_A]) { + const key = sectorKey[keyType] + if (_.isNil(key)) continue + try { + return await this.cmdMf1ReadBlock({ block, keyType, key }) + } catch (err) { + if (!this.isConnected()) throw err + this.#debug('mf1', `Failed to read block ${block} with ${Mf1KeyType[keyType]} = ${toUpperHex(key)}`) + } + } + throw new Error(`Failed to read block ${block}`) + } + + /** + * Read a sector data of Mifare Classic by given keys. * @param sector - The sector number to be read. * @param keys - The keys dictionary. * @returns The sector data and the read status of each block. @@ -3635,7 +3670,7 @@ export class ChameleonUltra { break } catch (err) { if (!this.isConnected()) throw err - this.#debug('mf1', `Failed to read block ${sector * 4 + i} with ${Mf1KeyType[keyType]} = ${key.toString('hex')}`) + this.#debug('mf1', `Failed to read block ${sector * 4 + i} with ${Mf1KeyType[keyType]} = ${toUpperHex(key)}`) } } } @@ -3645,7 +3680,46 @@ export class ChameleonUltra { } /** - * Write the sector data of Mifare Classic by given keys. + * Write a block data to Mifare Classic by given keys. + * @param block - The block number to be written. + * @param keys - The keys dictionary. + * @param data - Block data + * @returns An error is thrown if the block cannot be write. + * @group Mifare Classic Related + * @example + * ```js + * async function run (ultra) { + * const { Buffer } = await import('https://cdn.jsdelivr.net/npm/chameleon-ultra.js@0/+esm') + * const keys = Buffer.from('FFFFFFFFFFFF\n000000000000\nA0A1A2A3A4A5\nD3F7D3F7D3F7', 'hex').chunk(6) + * const data = Buffer.from('00000000000000000000000000000000', 'hex') + * await ultra.mf1WriteBlockByKeys(1, keys, data) + * } + * + * await run(vm.ultra) // you can run in DevTools of https://taichunmin.idv.tw/chameleon-ultra.js/test.html + * ``` + */ + async mf1WriteBlockByKeys (block: number, keys: Buffer[], data: Buffer): Promise { + bufIsLenOrFail(data, 16, 'data') + if (block % 4 === 3 && !this.mf1IsValidAcl(data)) throw new TypeError('Invalid ACL bytes of data') + const sector = Math.trunc(block / 4) + const sectorKey = await this.mf1CheckSectorKeys(sector, keys) + if (_.isEmpty(sectorKey)) throw new Error('No valid key') + for (const keyType of [Mf1KeyType.KEY_B, Mf1KeyType.KEY_A]) { + const key = sectorKey[keyType] + if (_.isNil(key)) continue + try { + await this.cmdMf1WriteBlock({ block, keyType, key, data }) + return + } catch (err) { + if (!this.isConnected()) throw err + this.#debug('mf1', `Failed to write block ${block} with ${Mf1KeyType[keyType]} = ${toUpperHex(key)}`) + } + } + throw new Error(`Failed to write block ${block}`) + } + + /** + * Write a sector data to Mifare Classic by given keys. * @param sector - The sector number to be written. * @param keys - The key dictionary. * @param data - Sector data @@ -3681,12 +3755,12 @@ export class ChameleonUltra { const key = sectorKey[keyType] if (_.isNil(key)) continue try { - await this.cmdMf1WriteBlock({ block: sector * 4 + i, keyType, key, data: data.slice(i * 16, i * 16 + 16) }) + await this.cmdMf1WriteBlock({ block: sector * 4 + i, keyType, key, data: data.subarray(i * 16).subarray(0, 16) }) success[i] = true break } catch (err) { if (!this.isConnected()) throw err - this.#debug('mf1', `Failed to write block ${sector * 4 + i} with ${Mf1KeyType[keyType]} = ${key.toString('hex')}`) + this.#debug('mf1', `Failed to write block ${sector * 4 + i} with ${Mf1KeyType[keyType]} = ${toUpperHex(key)}`) } } } @@ -4010,7 +4084,7 @@ export class ChameleonUltra { const uploaded = await this.cmdDfuSelectObject(type) this.#debug('core', `uploaded = ${JSON.stringify(uploaded)}`) let buf1 = buf.subarray(0, uploaded.offset) - let crc1 = { offset: buf1.length, crc32: crc32(buf1) } + let crc1 = { offset: buf1.length, crc32: crc32(buf1 as any) } let crcFailCnt = 0 if (!_.isMatch(uploaded, crc1)) { // abort this.#debug('core', 'aborted') @@ -4025,7 +4099,7 @@ export class ChameleonUltra { // write object await this.port.dfuWriteObject(buf1, mtu) // check crc - const crc2 = { offset: uploaded.offset + buf1.length, crc32: crc32(buf1, uploaded.crc32) } + const crc2 = { offset: uploaded.offset + buf1.length, crc32: crc32(buf1 as any, uploaded.crc32) } crc1 = await this.cmdDfuGetObjectCrc() if (!_.isMatch(crc1, crc2)) { crcFailCnt++ @@ -4148,7 +4222,7 @@ const DfuErrMsg = new Map([ [DfuResCode.INSUFFICIENT_SPACE, 'The available space on the device is insufficient to hold the firmware'], ]) -export interface ChameleonSerialPort { +export interface ChameleonSerialPort { dfuWriteObject?: (buf: Buffer, mtu?: number) => Promise isDfu?: () => boolean isOpen?: () => boolean @@ -4185,13 +4259,13 @@ class UltraFrame { // sof + sof lrc + cmd (2) + status (2) + data len (2) + head lrc + data + data lrc const { buf } = resp return [ - buf.slice(0, 2).toString('hex'), // sof + sof lrc - buf.slice(2, 4).toString('hex'), // cmd - buf.slice(4, 6).toString('hex'), // status - buf.slice(6, 8).toString('hex'), // data len - buf.slice(8, 9).toString('hex'), // head lrc - buf.readUInt16BE(6) > 0 ? buf.slice(9, -1).toString('hex') : '(no data)', // data - buf.slice(-1).toString('hex'), // data lrc + toUpperHex(buf.subarray(0, 2)), // sof + sof lrc + toUpperHex(buf.subarray(2, 4)), // cmd + toUpperHex(buf.subarray(4, 6)), // status + toUpperHex(buf.subarray(6, 8)), // data len + toUpperHex(buf.subarray(8, 9)), // head lrc + buf.readUInt16BE(6) > 0 ? toUpperHex(buf.subarray(9, -1)) : '(no data)', // data + toUpperHex(buf.subarray(-1)), // data lrc ].join(' ') } @@ -4214,9 +4288,9 @@ export class DfuFrame { } static inspect (frame: DfuFrame): string { - if (frame.isResp === 1) return `op = ${DfuOp[frame.op]}, resCode = ${DfuResCode[frame.result]}, data = ${frame.data.toString('hex')}` + if (frame.isResp === 1) return `op = ${DfuOp[frame.op]}, resCode = ${DfuResCode[frame.result]}, data = ${toUpperHex(frame.data)}` if (frame.op === DfuOp.OBJECT_WRITE) return `op = ${DfuOp[frame.op]}, data.length = ${frame.data.length}` - return `op = ${DfuOp[frame.op]}, data = ${frame.data.toString('hex')}` + return `op = ${DfuOp[frame.op]}, data = ${toUpperHex(frame.data)}` } get isResp (): number { return +(this.buf[0] === DfuOp.RESPONSE) } @@ -4264,10 +4338,10 @@ function bufIsLenOrFail (buf: Buffer, len: number, name: string): void { function mfuCheckRespNakCrc16a (resp: Buffer): Buffer { const createErr = (status: RespStatus, msg: string): Error => _.merge(new Error(msg), { status, data: { resp } }) - if (resp.length === 1 && resp[0] !== 0x0A) throw createErr(RespStatus.HF_ERR_STAT, `received NAK 0x${resp.toString('hex')}`) + if (resp.length === 1 && resp[0] !== 0x0A) throw createErr(RespStatus.HF_ERR_STAT, `received NAK 0x${toUpperHex(resp)}`) if (resp.length < 3) throw createErr(RespStatus.HF_ERR_CRC, 'unexpected resp') const data = resp.subarray(0, -2) - if (crc16a(data) !== resp.readUInt16LE(data.length)) throw createErr(RespStatus.HF_ERR_CRC, 'invalid crc16a of resp') + if (crc16a(data as any) !== resp.readUInt16LE(data.length)) throw createErr(RespStatus.HF_ERR_CRC, 'invalid crc16a of resp') return data } diff --git a/src/plugin/BufferMockAdapter.ts b/src/plugin/BufferMockAdapter.ts index 26a1c39..96180ac 100644 --- a/src/plugin/BufferMockAdapter.ts +++ b/src/plugin/BufferMockAdapter.ts @@ -30,12 +30,12 @@ export default class BufferMockAdapter implements ChameleonPlugin { this.port = { isOpen: () => true, - readable: new ReadableStream1({ + readable: new ReadableStream1({ start: async controller => { this.controller = controller }, }), - writable: new WritableStream1({ + writable: new WritableStream1({ write: async chunk => { this.recv.push(Buffer.isBuffer(chunk) ? chunk : Buffer.fromView(chunk)) if (this.sendIdx >= this.send.length) return // no more data to send diff --git a/src/plugin/WebserialAdapter.ts b/src/plugin/WebserialAdapter.ts index c3ed991..33561ab 100644 --- a/src/plugin/WebserialAdapter.ts +++ b/src/plugin/WebserialAdapter.ts @@ -18,6 +18,12 @@ function u16ToHex (num: number): string { return _.toUpper(`000${num.toString(16)}`.slice(-4)) } +/** + * @see + * - [Web Serial API | MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) + * - [Getting started with the Web Serial API | codelabs](https://codelabs.developers.google.com/codelabs/web-serial#0) + * - [Read from and write to a serial port | Chrome for Developers](https://developer.chrome.com/docs/capabilities/serial) + */ export default class WebserialAdapter implements ChameleonPlugin { #isDfu: boolean = false #isOpen: boolean = false @@ -72,12 +78,12 @@ export default class WebserialAdapter implements ChameleonPlugin { ultra.port = { isOpen: () => this.#isOpen, isDfu: () => this.#isDfu, - readable: this.port.readable.pipeThrough(new this.#TransformStream(new SlipDecodeTransformer(Buffer1))), + readable: this.port.readable.pipeThrough(new this.#TransformStream(new SlipDecodeTransformer(Buffer1)) as any), writable: new this.#WritableStream({ write: async (chunk: Buffer) => { const writer = this.port?.writable?.getWriter() if (_.isNil(writer)) throw new Error('Failed to getWriter(). Did you remember to use adapter plugin?') - await writer.write(slipEncode(chunk, Buffer1)) + await writer.write(slipEncode(chunk, Buffer1) as any) writer.releaseLock() }, }), @@ -93,7 +99,7 @@ export default class WebserialAdapter implements ChameleonPlugin { chunk[0] = DfuOp.OBJECT_WRITE } chunk.set(buf1, 1) - await writer.write(slipEncode(chunk, Buffer1)) + await writer.write(slipEncode(chunk, Buffer1) as any) } writer.releaseLock() }, @@ -102,7 +108,7 @@ export default class WebserialAdapter implements ChameleonPlugin { ultra.port = _.merge(this.port, { isOpen: () => this.#isOpen, isDfu: () => this.#isDfu, - }) + }) as any } return await next() } catch (err) { diff --git a/yarn.lock b/yarn.lock index b6c108e..bbd67bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -556,6 +556,13 @@ dependencies: eslint-visitor-keys "^3.3.0" +"@eslint-community/eslint-utils@^4.4.1": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" + integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA== + dependencies: + eslint-visitor-keys "^3.4.3" + "@eslint-community/regexpp@^4.10.0": version "4.10.0" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" @@ -1173,10 +1180,10 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@taichunmin/buffer@^0.13.9": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@taichunmin/buffer/-/buffer-0.13.9.tgz#be53f99d28f749c867b17227d463aa8f4efdfa6b" - integrity sha512-hcXbhbKGd5YH91Qhpw88olLMs9qgcdTxXfe/CLrNdnOzNHlSD80Ng8vZKmRXXW8fhu+Fg8h2jUDtwapXH7nz0w== +"@taichunmin/buffer@^0.13.10": + version "0.13.10" + resolved "https://registry.yarnpkg.com/@taichunmin/buffer/-/buffer-0.13.10.tgz#e7d14d65a7c8549b49bf93fc0969b25a7009ce68" + integrity sha512-wFounwBY+jt/QUA9CmO+jMfQnSYUtoiRATWkpgvIOGAKwKLZmkLGq0x9avOjVq75QPse5Ph5YBYotv2a+DawKQ== dependencies: lodash "^4.17.21" @@ -1364,10 +1371,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9" integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA== -"@types/node@^22.8.7": - version "22.8.7" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.8.7.tgz#04ab7a073d95b4a6ee899f235d43f3c320a976f4" - integrity sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q== +"@types/node@^22.9.0": + version "22.9.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.0.tgz#b7f16e5c3384788542c72dc3d561a7ceae2c0365" + integrity sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ== dependencies: undici-types "~6.19.8" @@ -2933,12 +2940,12 @@ eslint-plugin-local-rules@^3.0.2: resolved "https://registry.yarnpkg.com/eslint-plugin-local-rules/-/eslint-plugin-local-rules-3.0.2.tgz#84c02ea1d604ecb00970779ad27f00738ff361ae" integrity sha512-IWME7GIYHXogTkFsToLdBCQVJ0U4kbSuVyDT+nKoR4UgtnVrrVeNWuAZkdEu1nxkvi9nsPccGehEEF6dgA28IQ== -eslint-plugin-n@^17.12.0: - version "17.12.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.12.0.tgz#a6518a2806a21801ac4d532d23c1ac7f8d49ee49" - integrity sha512-zNAtz/erDn0v78bIY3MASSQlyaarV4IOTvP5ldHsqblRFrXriikB6ghkDTkHjUad+nMRrIbOy9euod2azjRfBg== +eslint-plugin-n@^17.13.1: + version "17.13.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.13.1.tgz#3178c87989ad23417d22c5f66a13ecb1e9c5245e" + integrity sha512-97qzhk1z3DdSJNCqT45EslwCu5+LB9GDadSyBItgKUfGsXAmN/aa7LRQ0ZxHffUxUzvgbTPJL27/pE9ZQWHy7A== dependencies: - "@eslint-community/eslint-utils" "^4.4.0" + "@eslint-community/eslint-utils" "^4.4.1" enhanced-resolve "^5.17.1" eslint-plugin-es-x "^7.8.0" get-tsconfig "^4.8.1" @@ -6585,10 +6592,10 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" -typedoc-plugin-mdn-links@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-3.3.6.tgz#c8ea359eefb39548c8c4228d0762b83ae22877c0" - integrity sha512-iz/+UBDEDqtymjgO6AQA7P4A/kiGmHVKSStGFz7rueZClfbpYaJB5T+xePMF4i0N7PxWMPrWzQd1+B6pn40w1w== +typedoc-plugin-mdn-links@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-3.3.7.tgz#d85315e07af0df8a71d11caa1bb067cf0e282b20" + integrity sha512-iFSnYj3XPuc0wh0/VjU2M/sHtNv5pSEysUXrylHxgd5PqTAOZTUswJAcbB7shg+SfxMCqGaiyA0duNmnGs/LQg== typedoc-plugin-missing-exports@^3.0.0: version "3.0.0"