From c8763dd65eca9e6990255e88591ac33f63c26b77 Mon Sep 17 00:00:00 2001 From: ppc Date: Sun, 12 Jan 2025 12:48:48 +0000 Subject: [PATCH] add ICCard scanning support --- amnet-native/app.json | 7 +- amnet-native/lib/iccard-crypto.ts | 49 ++++++++++++ amnet-native/lib/iccard.ts | 119 ++++++++++++++++++++++++++++++ amnet-native/package-lock.json | 31 ++++++++ amnet-native/package.json | 2 + 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 amnet-native/lib/iccard-crypto.ts create mode 100644 amnet-native/lib/iccard.ts diff --git a/amnet-native/app.json b/amnet-native/app.json index 8a1c884..1aad049 100644 --- a/amnet-native/app.json +++ b/amnet-native/app.json @@ -12,7 +12,12 @@ "applinks:card.ppc.moe" ], "infoPlist": { - "UIViewControllerBasedStatusBarAppearance": "NO" + "UIViewControllerBasedStatusBarAppearance": "NO", + "NFCReaderUsageDescription": "AMNet reads IC cards' contents to use with the program", + "com.apple.developer.nfc.readersession.felica.systemcodes": ["0003", "88b4"] + }, + "entitlements": { + "com.apple.developer.nfc.readersession.formats": ["TAG", "NDEF"] }, "config": { "usesNonExemptEncryption": false diff --git a/amnet-native/lib/iccard-crypto.ts b/amnet-native/lib/iccard-crypto.ts new file mode 100644 index 0000000..d474aec --- /dev/null +++ b/amnet-native/lib/iccard-crypto.ts @@ -0,0 +1,49 @@ +import {Buffer} from "buffer"; + +const s_box_inv = [ + "243cba36e385a4d0934373b9706ec9f1100e9b2c97e70b636c2920fe86f3e1f5f69fb616047b8fabb1392a1beb5ca8ac3811125f893e7dcaec53db6d1ed281789646fff9541c287a4fd3c0dcc16af2bccb57fd4ae4f0b2c79540625241e2ad49a6b51f02c8da92e5b0c564764821de0f45584cdfa784cfd5154e27806b7ffc44714722dd300ca13be037a03523903274bf8dc2ead650bbd9c483b43168558b5df47218b7eff7982d010361cc0a8e1300e814af095175a52f1d0d658acd66073d05d84be99d997c91d1b819c32b426988c679173a4d5ba25afb253fbdf8edcee687a326a92ebe94087e67608c9c5e6fb39e06fa82aeee5977d71a9a34d456aa33", + "f9817c00b93037d590516ef0b206fbcd39145ff81bc74a82708c921deac34ec812e6b610d32f95842542a572e58f55ef86a253aeed262047de786828e445cc35bc3ebb8ec0be34afa60964017b44f5f3b13fa4b33280c94f6f40bf214c74e1115eeb3d712e9d6275d41648771367ad6b5d0787f7a89a59768b9b1ed8eeaae9992dc597f1a783fccadcbab84be88917050ba06523d1ce3603d7e0f291df0e9c2c0d66d673585cb40a4dc23bd2b7e2ac33db6027bd56432404028d7d7ff6b5f4fddd5bec79c4221fa18854e71998947e31a329e35acb6ab0cf6d936c7a08a93c1cd06350c68538c14149ab61522a9fd9181a57462b69dafaff0f15963a8afe9e0c", + "11a29ac394a051015bf7d130ee1f06d57767d3c16ee76a1aa48e9f6566d60c2dc6e469b7565a5760fc791ee11652f007d0cdca78c98bb38827f6e0c084cf2fa304008040a7fa759d4b89462228373413ff20b4da08266c8ff25d7b735c4c9071418a9ebb12e8556d2a254aafbd9519a531baadd9c5a66b83c4b6a9ccf9a1b17adcc7db2e7e7f8d4e620f0339fdb8d418c2ec6102531c3b7dbc1d59f58c98f16f9385f82c74d85e4d97ab87820a21425f0d5833443adde9fb5047c8d7819be51b1754862beff429320e7c2315ebe63c35e296683f0b43cede9cbe4f457276b510e3df92d2a84891b9acbf49b2996470eaf33e6314aeedaa24fe3dcbb009383605", + "c4f02849554af5fd75af2069c843866bc9a8c6544cdd025be89b597734d7c0515ff37ed4f19081ce198a7833cd978cd66a4b8fa480dc1a1714c5078766ad9d85b2f88d98df3e1f3f645840ac6c5c085d53baffd2a92c25abe432389af9cbc29167d0e322292d9248b40e9905a36f0a15efd5104fd8f600e6462b31578394ae03eb8e1324a11e5ed126d9a7ca366e7137eeb1fa7c7606b823be21bc9ec1da7a3b62277bb0e56318827f0feda02a9cbf11bde0880d3a795256f7b6d309161b70b342602f1ca29f72124547bbe10b018b1ddeec0c4ee9cfcc9574f4a593c7db4dfb3565fee739b5ea96c304414484fc6d30e2f268b7897d733db95a50a63c61aa2e", + "1a599cadc8e41154ed370f3ae65f3c4bb81589b1e8da697791568bdb0624cf18f8b087df8c35cb86539da4664a7a710a4838ffdc8320ce9832f6d7af705e738a14721ec129790708e94346d02fde2a4f3d2c50b375fc0b64ae317b61a530a093d2bec255ba6cf36268fdac3b95491f6bb485f7a603ec6e9a81090c6aee9e4ef0ab2d7ea1e1bbc9bc41f2fa2e1bdd2734d36004b501d640f9d50247d9d8e08f2bfbb7c5eb5716e5788df400cd8239c696bdbfe336450e261d63e7843eb69b22a3f58e236dc3997d9097102580d44ce274ccb25c33c7ea05123f51a8fef11c42cad176286f92a767a919a2445baaef587f21c08865b95d4d0d5ac413527c9f9417", + "c72b82615bd09684d34a70a19b59339fc02014532917c50bc97b97020d3a1e7c3f6b52e8753df6e40c8a4fc35f2665733123284874aaa73609b1e291045122fc08a605a4f1121c19eb4037c2a0411dd4dc07438f47afd12e98ab01baf06668acf9e769b6cb8d78871503d5dfa31a9d6aea2f944e9e42d7b83892d8bbdedd9abcb04c79f4583ee98381ffe355fd5db2ef9c6d549960da3becfa11d6c42aed4bae13bfb9068be01f7f5aad90390ff3bd466c2cf2f8fed9e6720e89be5ec6a8cff5577e8cb7e1887ac81b18db6f35d216326463a58ef7b47695867de5a95c8500c1934dfb30a2b5253410773c67712d4445560a50b3cd6280ce214924caee276ecc", + "a5b06db251551fbf3e8ddd1992ea1bbd326529a48967b1906800da0da65954f210142f45e03b23fad750ac93e643010a5c78709846a97bf395072ad374b33fa36082394e3448c01cf6c291644d3cf89d359a94e37af0f96eb912b8042d02281385728087db2bf14f26a2e1497e9ccca7b6a0d09b3677ad8b6b4a031d058a064bafe531b5d4c60c66ba83fd090fae71b76fdc41e8178e407f6230ffa88425fb16ce3744ab991eeb183a47f75f81cfed582c6cfe9e57539720ca791a5a88f5699fe7d90ebe42df56e44c22aa730b15c5eefcc7d6cbcd8ce27621e9d1ecc87dd88f617c2ebcdeb475d2c4633da15e5d6a0824c927bbef33865bd5385211f4c396c1", + "345adb2c5957122b30c2a092bfedbc45de279b96d3e6c5ebd8244ba421cca8d6ce3cbab109e0d732664a831d19ca89670f420771e9bb445e8517c4ae9c3f6b78e76d02a7fe33e3d90a7deaf118877b6203795223003e25b9dd16683b1f90a62ac629918a9def1e8476ee4f39fb111b0b93ad49b705e14dcbe838d055f2f70d8b65cdfad49ec9814e14c826b6f9aaf580f86a98ab58f3b88eb5974372a2da64c1405c13b0e5bd089f1c7e8f06be6e501a2f37a1fc102e705304206348e2fd6f7a7561b3353acf5b888251d247a5a9746c31947c9ac0ec150e28012dc3f0b4e48ddcac3d5f86c79522f4b2a35446d1778c0cffaf565d36697f994cd56073df41f6", + "04da79631ebdeae30b652501cda992ed187557308903096adcc798a450911fb10c778566ca121c67c2e01783599dd5228256a89fc66048b311fca328fb06dd5dba297a4fe8fee910cbf3937b6c6954e744a2841d8dcefffa1a879074a1f814aabcc0cf31b4d9bfd85e262dd0e43f19d68c2fab3958726edf3be63cb66288de40b28f9b7c9543d19eac9cd423e10e4b5333462013341b97f7f1c3614a6f5a217f702e554105c5d776e52715ec425c4d78358befd2eeb5beae023ad35fc424f0f9514eeb000ffdaf3ec19a528681807ef62bccb97d68f2ad99a7072c7338b06bb78e71a0f43da60d37db0a473616966d322a5be24594f5a54cc88a4964bb08c9b8" +]; + +function rotate_right(data: Uint8Array, nBytes: number, nBits: number) { + let prior = data[nBytes - 1]; + + for (let i = 0; i < nBytes; i++) { + let current = data[i]; + + data[i] = (current >> nBits) | ((prior & ((1 << nBits) - 1)) << (8 - nBits)); + prior = current; + } +} + +const S_BOX_INV = s_box_inv.map(x => new Uint8Array(Buffer.from(x, 'hex'))); +const N_TABLES = S_BOX_INV.length - 1; +const ITER_ADD = 5; + +export function performDecrypt(data: Uint8Array) { + const spad = data.map(x => S_BOX_INV[N_TABLES][x]); + + let count = (spad[15] >> 4) + 7; + let table = spad[15] + ITER_ADD * count; + + for (let z = 0; z < count; z++) { + table -= ITER_ADD; + rotate_right(spad, 15, 5); + + for (let i = 0; i < 15; i++) { + spad[i] = S_BOX_INV[table % N_TABLES][spad[i]]; + } + } + + return { + version: spad[0], + accessCodeBytes: spad.slice(6) // access code is a binary-coded decimal + }; +} diff --git a/amnet-native/lib/iccard.ts b/amnet-native/lib/iccard.ts new file mode 100644 index 0000000..88288de --- /dev/null +++ b/amnet-native/lib/iccard.ts @@ -0,0 +1,119 @@ +import {Buffer} from "buffer"; +import NfcManager, {NfcTech} from "react-native-nfc-manager"; + +import {performDecrypt} from "./iccard-crypto"; + +export enum AmusementICVendor { + Sega = 0x0078, + Konami = 0x0068, + BandaiNamcoEntertainment = 0x002A, + BandaiNamcoGames = 0x003A, + Nesica = 0x0079, + Unknown = 0x0000 +} + +type AmusementICCard = { + type: "amusement", + + idm: string, + accessCode: string, + vendor: AmusementICVendor +} + +type TransportICCard = { + type: "transport", + + idm: string +} + +export type ICCard = AmusementICCard | TransportICCard; + +/** + * Converts an IC card IDm to the 20-digit "0008" number used by some networks + * @param idm The IDm to convert + */ +export function icCard0008Number(idm: string): string { + // 0008 is the numerical representation of the idm padded with zeros until it is 20 digits + return BigInt(`0x${idm}`).toString().padStart(20, '0'); +} + +/** + * Reads a FeliCa card, returning access code information. + */ +export async function felicaCardReadAsync(): Promise { + try { + await NfcManager.requestTechnology(NfcTech.FelicaIOS, { + isReaderModeEnabled: true, + invalidateAfterFirstRead: false, + alertMessage: "Scan an Amusement or Transport card to get started" + }); + + const tag = await NfcManager.getTag(); + const idm = tag['idm'] as string; + + switch (tag['systemCode'] as string) { + case "0003": + return { + type: "transport", + idm + }; + + case "88B4": + return await readAmusementICCardContents(idm); + + default: + return null; + } + } catch (ex) { + console.warn(ex); + return null; + } finally { + await NfcManager.cancelTechnologyRequest(); + } +} + +async function readAmusementICCardContents(idm: string): Promise { + const idmBytes = Uint8Array.from(Buffer.from(idm, 'hex')); + const readCommand = [ + 0x06, // read + ...idmBytes, + 0x01, // service count (fixed) + 0x0B, 0x00, // service code (000b, le) + 0x02, // block count + 0x80, 0x00, // S_PAD0 block + 0x80, 0x82 // ID block + ]; + + let response = await NfcManager.sendFelicaCommandIOS(readCommand); + const offset = response.indexOf(0x07); // look for 0x07 as for some reason the response may not start at response[0] + + // response must have 44 bytes + if (offset === -1 || response.length - offset !== 44) { + return null; + } else if (offset !== 0) { + response = response.slice(offset); + } + + // response format: + // 0 - read response (0x07) + // 1-8 - IDm + // 9 - status flag 1 + // 10 - status flag 2 + // 11 - number of blocks (0x02) + // 12-27 - S_PAD0 block + // 28-43 - ID block (DFC is 35-36) + + if (response[9] !== 0x00 || response[11] !== 0x02) { + return null; + } + + const accessCode = performDecrypt(new Uint8Array(response.slice(12, 28))); + const dfcValue = response[37] | (response[36] << 8); + + return { + type: "amusement", + accessCode: [...accessCode.accessCodeBytes].map(x => x.toString(16).padStart(2, '0')).join(''), + vendor: dfcValue in AmusementICVendor ? dfcValue : AmusementICVendor.Unknown, + idm + }; +} diff --git a/amnet-native/package-lock.json b/amnet-native/package-lock.json index 4dd72ff..7fba1f4 100644 --- a/amnet-native/package-lock.json +++ b/amnet-native/package-lock.json @@ -28,11 +28,13 @@ "expo-system-ui": "~4.0.4", "lucide-react-native": "^0.427.0", "patch-package": "^8.0.0", + "pusher-js": "^8.2.0", "react": "18.3.1", "react-native": "0.76.5", "react-native-context-menu-view": "^1.16.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-random-values": "^1.11.0", + "react-native-nfc-manager": "^3.16.1", "react-native-paper": "^5.12.5", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", @@ -9733,6 +9735,15 @@ "node": ">=6" } }, + "node_modules/pusher-js": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.3.0.tgz", + "integrity": "sha512-6GohP06WlVeomAQQe9qWh1IDzd3+InluWt+ZUOcecVK1SEQkg6a8uYVsvxSJm7cbccfmHhE0jDkmhKIhue8vmA==", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", @@ -9972,6 +9983,20 @@ "react-native": ">=0.56" } }, + "node_modules/react-native-nfc-manager": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-native-nfc-manager/-/react-native-nfc-manager-3.16.1.tgz", + "integrity": "sha512-rYhJSThAxAT4AVpZzZJc3X1jiTnZQlmrzk9DeeWLstUTXCoECt/75brLgn+EiXK+AfONnMnFdsSzIyruVWyIdw==", + "license": "MIT", + "peerDependencies": { + "@expo/config-plugins": "8 || 9" + }, + "peerDependenciesMeta": { + "@expo/config-plugins": { + "optional": true + } + } + }, "node_modules/react-native-paper": { "version": "5.12.5", "resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.12.5.tgz", @@ -11812,6 +11837,12 @@ "node": "*" } }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/amnet-native/package.json b/amnet-native/package.json index a7f3e7b..481af55 100644 --- a/amnet-native/package.json +++ b/amnet-native/package.json @@ -28,11 +28,13 @@ "expo-system-ui": "~4.0.4", "lucide-react-native": "^0.427.0", "patch-package": "^8.0.0", + "pusher-js": "^8.2.0", "react": "18.3.1", "react-native": "0.76.5", "react-native-context-menu-view": "^1.16.0", "react-native-gesture-handler": "~2.20.2", "react-native-get-random-values": "^1.11.0", + "react-native-nfc-manager": "^3.16.1", "react-native-paper": "^5.12.5", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0",