Compare commits
14 Commits
874a25d7e4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a89f4cd9 | ||
|
|
b558231d6c | ||
|
|
cce7629eb6 | ||
|
|
77bce6a815 | ||
|
|
3e9411fc7d | ||
|
|
83e5e1ceeb | ||
|
|
199146d0b9 | ||
|
|
2409ec3625 | ||
|
|
b27baf5dec | ||
|
|
e597728883 | ||
|
|
bb6a9bbc22 | ||
|
|
63d815c1c2 | ||
|
|
7596e4cbfa | ||
|
|
fb7f836abd |
6
app.json
6
app.json
@@ -6,7 +6,7 @@
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"newArchEnabled": false,
|
||||
"splash": {
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
@@ -14,10 +14,12 @@
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.dangerousthings.nfcidentifier",
|
||||
"infoPlist": {
|
||||
"NFCReaderUsageDescription": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants."
|
||||
"NFCReaderUsageDescription": "This app uses NFC to scan transponders and identify compatible Dangerous Things implants.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 8,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#000000"
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
const plugins = [];
|
||||
|
||||
// Remove console.log and console.warn in production builds
|
||||
// Keep console.error for actual error reporting
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
plugins.push([
|
||||
'transform-remove-console',
|
||||
{exclude: ['error']},
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
||||
6
eas.json
6
eas.json
@@ -11,6 +11,12 @@
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview-apk": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
|
||||
3623
package-lock.json
generated
3623
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -17,35 +17,58 @@
|
||||
"dependencies": {
|
||||
"@react-navigation/native": "^7.1.27",
|
||||
"@react-navigation/native-stack": "^7.9.1",
|
||||
"babel-preset-expo": "^54.0.9",
|
||||
"expo": "^54.0.31",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo": "~54.0.32",
|
||||
"expo-dev-client": "^6.0.20",
|
||||
"expo-status-bar": "^3.0.9",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.1",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-dt-theme": "file:react-native-dt-theme-0.2.2.tgz",
|
||||
"react-native-nfc-manager": "^3.17.2",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-vector-icons": "^10.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.3",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@react-native/babel-preset": "0.83.1",
|
||||
"@react-native/eslint-config": "0.83.1",
|
||||
"@react-native/babel-preset": "0.81.1",
|
||||
"@react-native/eslint-config": "0.81.1",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@types/react-test-renderer": "~19.1.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.6.3",
|
||||
"prettier": "2.8.8",
|
||||
"react-test-renderer": "19.2.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react",
|
||||
"react-native",
|
||||
"@types/react",
|
||||
"@react-native/babel-preset",
|
||||
"@react-native/eslint-config",
|
||||
"react-test-renderer"
|
||||
]
|
||||
},
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": [
|
||||
"react-native-nfc-manager",
|
||||
"react-native-vector-icons"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
react-native-dt-theme-0.2.2.tgz
Normal file
BIN
react-native-dt-theme-0.2.2.tgz
Normal file
Binary file not shown.
1
src/components/index.ts
Normal file
1
src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './scan';
|
||||
177
src/components/scan/ScanAnimation.tsx
Normal file
177
src/components/scan/ScanAnimation.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Animated NFC Scan Indicator
|
||||
*
|
||||
* Shows pulsing concentric rings during NFC scanning
|
||||
*/
|
||||
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {View, StyleSheet, Animated, Easing} from 'react-native';
|
||||
import {DTColors} from '../../theme';
|
||||
|
||||
interface ScanAnimationProps {
|
||||
/** Whether the animation is active */
|
||||
isActive: boolean;
|
||||
/** Color for the rings (defaults to modeNormal/cyan) */
|
||||
color?: string;
|
||||
/** Size of the component */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function ScanAnimation({
|
||||
isActive,
|
||||
color = DTColors.modeNormal,
|
||||
size = 200,
|
||||
}: ScanAnimationProps) {
|
||||
// Animation values for each ring
|
||||
const ring1 = useRef(new Animated.Value(0)).current;
|
||||
const ring2 = useRef(new Animated.Value(0)).current;
|
||||
const ring3 = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
// Reset animations when not active
|
||||
ring1.setValue(0);
|
||||
ring2.setValue(0);
|
||||
ring3.setValue(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create staggered pulsing animations
|
||||
const createPulse = (animatedValue: Animated.Value, delay: number) => {
|
||||
return Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(delay),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const animation1 = createPulse(ring1, 0);
|
||||
const animation2 = createPulse(ring2, 666);
|
||||
const animation3 = createPulse(ring3, 1333);
|
||||
|
||||
animation1.start();
|
||||
animation2.start();
|
||||
animation3.start();
|
||||
|
||||
return () => {
|
||||
animation1.stop();
|
||||
animation2.stop();
|
||||
animation3.stop();
|
||||
};
|
||||
}, [isActive, ring1, ring2, ring3]);
|
||||
|
||||
const createRingStyle = (animatedValue: Animated.Value) => {
|
||||
const scale = animatedValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 1],
|
||||
});
|
||||
|
||||
const opacity = animatedValue.interpolate({
|
||||
inputRange: [0, 0.5, 1],
|
||||
outputRange: [0.8, 0.4, 0],
|
||||
});
|
||||
|
||||
return {
|
||||
transform: [{scale}],
|
||||
opacity,
|
||||
borderColor: color,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, {width: size, height: size}]}>
|
||||
{/* Pulsing rings */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ring,
|
||||
{width: size, height: size, borderRadius: size / 2},
|
||||
createRingStyle(ring1),
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ring,
|
||||
{width: size, height: size, borderRadius: size / 2},
|
||||
createRingStyle(ring2),
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.ring,
|
||||
{width: size, height: size, borderRadius: size / 2},
|
||||
createRingStyle(ring3),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Center dot */}
|
||||
<View style={[styles.centerDot, {backgroundColor: color}]} />
|
||||
|
||||
{/* NFC icon representation */}
|
||||
<View style={styles.nfcIcon}>
|
||||
<View style={[styles.nfcArc, styles.nfcArc1, {borderColor: color}]} />
|
||||
<View style={[styles.nfcArc, styles.nfcArc2, {borderColor: color}]} />
|
||||
<View style={[styles.nfcArc, styles.nfcArc3, {borderColor: color}]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
ring: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
},
|
||||
centerDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
position: 'absolute',
|
||||
},
|
||||
nfcIcon: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: 10,
|
||||
},
|
||||
nfcArc: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderLeftWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
borderBottomWidth: 0,
|
||||
borderTopRightRadius: 50,
|
||||
borderBottomRightRadius: 50,
|
||||
},
|
||||
nfcArc1: {
|
||||
width: 15,
|
||||
height: 20,
|
||||
left: 20,
|
||||
},
|
||||
nfcArc2: {
|
||||
width: 25,
|
||||
height: 34,
|
||||
left: 20,
|
||||
},
|
||||
nfcArc3: {
|
||||
width: 35,
|
||||
height: 48,
|
||||
left: 20,
|
||||
},
|
||||
});
|
||||
1
src/components/scan/index.ts
Normal file
1
src/components/scan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {ScanAnimation} from './ScanAnimation';
|
||||
243
src/data/chipInfo.ts
Normal file
243
src/data/chipInfo.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Educational Chip Information
|
||||
*
|
||||
* Provides user-friendly explanations of NFC chip types
|
||||
*/
|
||||
|
||||
import {ChipType, ChipFamily} from '../types/detection';
|
||||
|
||||
export interface ChipInfo {
|
||||
/** Brief description of the chip */
|
||||
description: string;
|
||||
/** What this chip is commonly used for */
|
||||
commonUses: string[];
|
||||
/** Key capabilities */
|
||||
capabilities: string[];
|
||||
/** Security level indicator */
|
||||
securityLevel: 'low' | 'medium' | 'high';
|
||||
/** Memory capacity description */
|
||||
memoryNote?: string;
|
||||
}
|
||||
|
||||
export const CHIP_FAMILY_INFO: Record<ChipFamily, string> = {
|
||||
[ChipFamily.NTAG]:
|
||||
'NTAG chips are NXP-branded NFC Type 2 tags designed for consumer applications like sharing URLs, contact info, and app launches. This family includes MIFARE Ultralight variants.',
|
||||
[ChipFamily.MIFARE_CLASSIC]:
|
||||
'MIFARE Classic is a legacy contactless smart card technology commonly used in access control and transit systems.',
|
||||
[ChipFamily.MIFARE_DESFIRE]:
|
||||
'MIFARE DESFire is a high-security contactless smart card with advanced encryption, used in transit, access control, and payment applications.',
|
||||
[ChipFamily.MIFARE_PLUS]:
|
||||
'MIFARE Plus offers enhanced security over Classic, with optional AES encryption while maintaining backward compatibility.',
|
||||
[ChipFamily.ISO15693]:
|
||||
'ISO 15693 (NFC-V) tags operate at longer read distances than standard NFC, commonly used in library systems and industrial applications.',
|
||||
[ChipFamily.JAVACARD]:
|
||||
'JavaCard chips are programmable secure elements that can run multiple cryptographic applications, used in identity and payment systems.',
|
||||
[ChipFamily.UNKNOWN]:
|
||||
'This chip type could not be fully identified. It may be a less common or proprietary tag.',
|
||||
};
|
||||
|
||||
export const CHIP_INFO: Partial<Record<ChipType, ChipInfo>> = {
|
||||
// NTAG family
|
||||
[ChipType.NTAG213]: {
|
||||
description:
|
||||
'Entry-level NTAG with 144 bytes of user memory, ideal for simple NFC applications.',
|
||||
commonUses: ['URL sharing', 'Smart posters', 'Product authentication'],
|
||||
capabilities: ['NFC Forum Type 2', 'Password protection', 'Counter function'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '144 bytes user memory',
|
||||
},
|
||||
[ChipType.NTAG215]: {
|
||||
description:
|
||||
'Mid-range NTAG commonly used for amiibo and similar applications.',
|
||||
commonUses: ['Gaming (amiibo)', 'Access tokens', 'Loyalty cards'],
|
||||
capabilities: ['NFC Forum Type 2', 'Password protection', 'Counter function'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '504 bytes user memory',
|
||||
},
|
||||
[ChipType.NTAG216]: {
|
||||
description:
|
||||
'Largest standard NTAG with 888 bytes, popular for implants and data storage.',
|
||||
commonUses: ['NFC implants', 'vCards', 'Complex data storage'],
|
||||
capabilities: ['NFC Forum Type 2', 'Password protection', 'Counter function'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '888 bytes user memory',
|
||||
},
|
||||
[ChipType.NTAG_I2C_1K]: {
|
||||
description:
|
||||
'NTAG with I2C interface allowing microcontroller communication alongside NFC.',
|
||||
commonUses: ['IoT devices', 'Energy harvesting', 'Field detection'],
|
||||
capabilities: ['NFC + I2C interface', 'Energy harvesting', 'Field detection'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '888 bytes user memory',
|
||||
},
|
||||
[ChipType.NTAG_I2C_2K]: {
|
||||
description:
|
||||
'Extended memory NTAG I2C with 1904 bytes and dual interface capability.',
|
||||
commonUses: ['IoT devices', 'Data logging', 'Connected products'],
|
||||
capabilities: ['NFC + I2C interface', 'Energy harvesting', 'SRAM buffer'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '1904 bytes user memory',
|
||||
},
|
||||
|
||||
// MIFARE Ultralight family
|
||||
[ChipType.ULTRALIGHT]: {
|
||||
description:
|
||||
'Original MIFARE Ultralight - a simple, low-cost NFC tag with minimal memory.',
|
||||
commonUses: ['Single-use tickets', 'Event passes', 'Disposable tags'],
|
||||
capabilities: ['NFC Forum Type 2', 'OTP area', 'Lock bits'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '48 bytes user memory',
|
||||
},
|
||||
[ChipType.ULTRALIGHT_C]: {
|
||||
description:
|
||||
'Ultralight with 3DES authentication for basic security applications.',
|
||||
commonUses: ['Limited-use tickets', 'Access tokens', 'Loyalty cards'],
|
||||
capabilities: ['NFC Forum Type 2', '3DES authentication', 'Counter'],
|
||||
securityLevel: 'medium',
|
||||
memoryNote: '144 bytes user memory',
|
||||
},
|
||||
[ChipType.ULTRALIGHT_EV1]: {
|
||||
description:
|
||||
'Enhanced Ultralight with password protection and originality check.',
|
||||
commonUses: ['Event tickets', 'Brand protection', 'Gaming tokens'],
|
||||
capabilities: ['Password protection', 'Originality signature', 'Counter'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '48 or 128 bytes user memory',
|
||||
},
|
||||
[ChipType.ULTRALIGHT_AES]: {
|
||||
description:
|
||||
'Ultralight with AES-128 authentication for secure applications.',
|
||||
commonUses: ['Secure tickets', 'Brand protection', 'Limited-use passes'],
|
||||
capabilities: ['AES-128 authentication', 'Counter', 'Originality signature'],
|
||||
securityLevel: 'high',
|
||||
memoryNote: '540 bytes user memory',
|
||||
},
|
||||
|
||||
// MIFARE Classic
|
||||
[ChipType.MIFARE_CLASSIC_1K]: {
|
||||
description:
|
||||
'Classic 1K is the most common access card, using Crypto-1 encryption (now considered weak).',
|
||||
commonUses: ['Building access', 'Hotel keys', 'Transit cards'],
|
||||
capabilities: ['16 sectors', 'Key-based authentication', 'Sector permissions'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '1KB (752 bytes usable)',
|
||||
},
|
||||
[ChipType.MIFARE_CLASSIC_4K]: {
|
||||
description:
|
||||
'Extended capacity Classic card with 4KB memory, same security as 1K.',
|
||||
commonUses: ['Building access', 'Parking systems', 'Legacy transit'],
|
||||
capabilities: ['40 sectors', 'Key-based authentication', 'Sector permissions'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '4KB (3440 bytes usable)',
|
||||
},
|
||||
|
||||
// DESFire
|
||||
[ChipType.DESFIRE_EV1]: {
|
||||
description:
|
||||
'First generation DESFire with AES-128 encryption for secure applications.',
|
||||
commonUses: ['Secure access control', 'Transit systems', 'Campus cards'],
|
||||
capabilities: ['AES-128/3DES', 'Multiple applications', 'Flexible file system'],
|
||||
securityLevel: 'high',
|
||||
memoryNote: '2KB, 4KB, or 8KB options',
|
||||
},
|
||||
[ChipType.DESFIRE_EV2]: {
|
||||
description:
|
||||
'Enhanced DESFire with improved security features and proximity check.',
|
||||
commonUses: ['High-security access', 'Payment cards', 'Government ID'],
|
||||
capabilities: [
|
||||
'AES-128',
|
||||
'Proximity check',
|
||||
'Transaction MAC',
|
||||
'Virtual Card Architecture',
|
||||
],
|
||||
securityLevel: 'high',
|
||||
memoryNote: '2KB, 4KB, 8KB, 16KB, or 32KB options',
|
||||
},
|
||||
[ChipType.DESFIRE_EV3]: {
|
||||
description:
|
||||
'Latest DESFire generation with enhanced cryptography and secure messaging.',
|
||||
commonUses: ['Transit', 'Access control', 'Micropayment', 'Loyalty'],
|
||||
capabilities: [
|
||||
'AES-128',
|
||||
'Secure Dynamic Messaging',
|
||||
'Transaction MAC',
|
||||
'Sun authentication',
|
||||
],
|
||||
securityLevel: 'high',
|
||||
memoryNote: '2KB, 4KB, 8KB, 16KB, or 32KB options',
|
||||
},
|
||||
|
||||
// ISO 15693
|
||||
[ChipType.SLIX]: {
|
||||
description:
|
||||
'Basic ISO 15693 (NFC-V) tag with longer read range than standard NFC.',
|
||||
commonUses: ['Library systems', 'Inventory tracking', 'Industrial tags'],
|
||||
capabilities: ['Long range', 'Anti-collision', 'AFI/DSFID support'],
|
||||
securityLevel: 'low',
|
||||
memoryNote: '128 bytes',
|
||||
},
|
||||
[ChipType.SLIX2]: {
|
||||
description:
|
||||
'Enhanced SLIX with more memory and improved security features.',
|
||||
commonUses: ['Asset tracking', 'Brand protection', 'Library systems'],
|
||||
capabilities: ['Long range', 'Password protection', 'Originality signature'],
|
||||
securityLevel: 'medium',
|
||||
memoryNote: '316 bytes',
|
||||
},
|
||||
[ChipType.ICODE_DNA]: {
|
||||
description:
|
||||
'Cryptographically secured NFC-V tag for brand protection and authentication.',
|
||||
commonUses: ['Brand protection', 'Anti-counterfeiting', 'Secure supply chain'],
|
||||
capabilities: [
|
||||
'Cryptographic authentication',
|
||||
'Rolling code',
|
||||
'Originality check',
|
||||
],
|
||||
securityLevel: 'high',
|
||||
memoryNote: '256 bytes',
|
||||
},
|
||||
|
||||
// JavaCard
|
||||
[ChipType.JCOP4]: {
|
||||
description:
|
||||
'Programmable secure element supporting Java Card applets for cryptographic applications.',
|
||||
commonUses: ['Identity cards', 'Digital signatures', 'Secure authentication'],
|
||||
capabilities: [
|
||||
'Java Card OS',
|
||||
'Multiple applets',
|
||||
'FIDO2/WebAuthn',
|
||||
'OpenPGP',
|
||||
],
|
||||
securityLevel: 'high',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get educational info for a chip type
|
||||
*/
|
||||
export function getChipInfo(chipType: ChipType): ChipInfo | undefined {
|
||||
return CHIP_INFO[chipType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get family description for a chip family
|
||||
*/
|
||||
export function getChipFamilyInfo(family: ChipFamily): string {
|
||||
return CHIP_FAMILY_INFO[family];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security level description
|
||||
*/
|
||||
export function getSecurityLevelDescription(
|
||||
level: 'low' | 'medium' | 'high',
|
||||
): string {
|
||||
switch (level) {
|
||||
case 'low':
|
||||
return 'Basic security - data can be read/written with standard tools';
|
||||
case 'medium':
|
||||
return 'Moderate security - some protection against casual attacks';
|
||||
case 'high':
|
||||
return 'Strong security - cryptographic protection against cloning';
|
||||
}
|
||||
}
|
||||
2
src/data/index.ts
Normal file
2
src/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './products';
|
||||
export * from './chipInfo';
|
||||
519
src/data/products.ts
Normal file
519
src/data/products.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Dangerous Things Product Catalog
|
||||
*
|
||||
* This contains the implant products available from Dangerous Things
|
||||
* and their chip compatibility information.
|
||||
*
|
||||
* Updated based on https://dangerousthings.com/category/implants/
|
||||
* Discontinued products removed per https://forum.dangerousthings.com/t/shes-dead-jim/26308
|
||||
*/
|
||||
|
||||
import { ChipType } from '../types/detection';
|
||||
import {
|
||||
Product,
|
||||
FormFactor,
|
||||
ProductCategory,
|
||||
ChipProductMap,
|
||||
} from '../types/products';
|
||||
|
||||
/**
|
||||
* Chip types that can be cloned to NTAG216-based implants
|
||||
* Any NTAG21x data can be written to an NTAG216 (same NFC Type 2 protocol)
|
||||
*/
|
||||
const NTAG21X_COMPATIBLE: ChipType[] = [
|
||||
ChipType.NTAG213,
|
||||
ChipType.NTAG215,
|
||||
ChipType.NTAG216,
|
||||
];
|
||||
|
||||
/**
|
||||
* NTAG I2C compatible chips
|
||||
*/
|
||||
const NTAG_I2C_COMPATIBLE: ChipType[] = [
|
||||
ChipType.NTAG_I2C_1K,
|
||||
ChipType.NTAG_I2C_2K,
|
||||
ChipType.NTAG_I2C_PLUS_1K,
|
||||
ChipType.NTAG_I2C_PLUS_2K,
|
||||
];
|
||||
|
||||
/**
|
||||
* MIFARE Classic compatible chips (for magic card cloning)
|
||||
*/
|
||||
const MIFARE_CLASSIC_COMPATIBLE: ChipType[] = [
|
||||
ChipType.MIFARE_CLASSIC_1K,
|
||||
ChipType.MIFARE_CLASSIC_4K,
|
||||
ChipType.MIFARE_CLASSIC_MINI,
|
||||
];
|
||||
|
||||
/**
|
||||
* All DESFire chips - any DESFire product works with any DESFire chip
|
||||
*/
|
||||
const DESFIRE_ALL: ChipType[] = [
|
||||
ChipType.DESFIRE_EV1,
|
||||
ChipType.DESFIRE_EV2,
|
||||
ChipType.DESFIRE_EV3,
|
||||
];
|
||||
|
||||
/**
|
||||
* ICODE SLIX compatible
|
||||
*/
|
||||
const SLIX_COMPATIBLE: ChipType[] = [
|
||||
ChipType.SLIX,
|
||||
ChipType.SLIX2,
|
||||
ChipType.SLIX_S,
|
||||
ChipType.SLIX_L,
|
||||
];
|
||||
|
||||
/**
|
||||
* MIFARE Ultralight compatible chips (for Gen4 magic implants)
|
||||
* Note: Ultralight AES cannot be cloned if AES protection is active
|
||||
*/
|
||||
const ULTRALIGHT_COMPATIBLE: ChipType[] = [
|
||||
ChipType.ULTRALIGHT,
|
||||
ChipType.ULTRALIGHT_C,
|
||||
ChipType.ULTRALIGHT_EV1,
|
||||
ChipType.ULTRALIGHT_NANO,
|
||||
ChipType.ULTRALIGHT_AES,
|
||||
];
|
||||
|
||||
export const PRODUCTS: Product[] = [
|
||||
// ==========================================================================
|
||||
// X-Series (Glass Capsule) Implants - NFC
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'xnt',
|
||||
name: 'xNT',
|
||||
description:
|
||||
'NFC Type 2 implant with 888 bytes of user memory.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.NFC],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'NTAG216 chip',
|
||||
'888 bytes user memory',
|
||||
'NFC Type 2 tag',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xnt/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: "UID cannot be changed"
|
||||
},
|
||||
{
|
||||
id: 'xsiid',
|
||||
name: 'xSIID',
|
||||
description:
|
||||
'NFC implant with built-in LED that blinks when scanned. Multiple color options available.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.NFC, ProductCategory.LED],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'NTAG I2C chip',
|
||||
'1kB user memory',
|
||||
'URL/contact sharing',
|
||||
'Red, Green, Blue, and White LEDs available',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xsiid/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: "UID cannot be changed"
|
||||
},
|
||||
{
|
||||
id: 'xslx',
|
||||
name: 'xSLX',
|
||||
description: 'NFC Type 5 implant using ICODE SLIX chip.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.NFC],
|
||||
compatibleChips: [...SLIX_COMPATIBLE],
|
||||
features: [
|
||||
'ICODE SLIX chip',
|
||||
'320 bbytes user memory',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xslx/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: "UID cannot be changed"
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// X-Series - Dual Frequency (NFC + 125kHz)
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'next',
|
||||
name: 'NExT',
|
||||
description:
|
||||
'Original dual-frequency implant combining NTAG216 (NFC) with T5577 (125kHz).',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'Dual-frequency',
|
||||
'NTAG216 NFC chip',
|
||||
'888 bytes user memory',
|
||||
'URL/contact sharing',
|
||||
'T5577 125kHz chip',
|
||||
'Access control (LF)',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/next/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: 'HF UID cannot be changed. Also includes T5577 for 125kHz access systems',
|
||||
},
|
||||
{
|
||||
id: 'next-v2',
|
||||
name: 'NExT v2',
|
||||
description:
|
||||
'Updated dual-frequency implant with NTAG I2C, T5577, and built-in LED.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS, ProductCategory.LED],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'NTAG I2C NFC chip',
|
||||
'1kB user memory',
|
||||
'URL/contact sharing',
|
||||
'Green, Blue, and White LEDs available (HF)',
|
||||
'T5577 125kHz chip',
|
||||
'Access control (LF)',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/next-v2/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: 'HF UID cannot be changed. Also includes T5577 for 125kHz access systems',
|
||||
},
|
||||
{
|
||||
id: 'xmagic',
|
||||
name: 'xMagic',
|
||||
description:
|
||||
'Dual-frequency implant with magic MIFARE Classic (changeable UID) and T5577.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS],
|
||||
compatibleChips: [...MIFARE_CLASSIC_COMPATIBLE],
|
||||
features: [
|
||||
'Magic MIFARE Classic chip',
|
||||
'Changeable UID (4-byte)',
|
||||
'Gen1a and gen2 magic options',
|
||||
'T5577 125kHz chip',
|
||||
'Access control (LF)',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xmagic/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: false,
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// X-Series - DESFire (Secure NFC)
|
||||
// Note: xDF (EV1) and xDF2 (EV2) discontinued - see flexDF/flexDF2 for those
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'xdf3',
|
||||
name: 'xDF3',
|
||||
description: 'Latest generation secure NFC implant using MIFARE DESFire EV3.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.SECURE, ProductCategory.ACCESS],
|
||||
compatibleChips: [...DESFIRE_ALL],
|
||||
features: [
|
||||
'DESFire EV3 chip',
|
||||
'8KB memory',
|
||||
'Latest security features',
|
||||
'Access control (HF)',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xdf3/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: true,
|
||||
notes: 'UID cannot be changed. Cannot clone data due to cryptographic protection',
|
||||
desfireEvLevel: 3,
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// X-Series - Magic/Cloning
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'xm1',
|
||||
name: 'xM1',
|
||||
description: 'Magic MIFARE Classic 1K implant with changeable UID for cloning access cards.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.ACCESS],
|
||||
compatibleChips: [...MIFARE_CLASSIC_COMPATIBLE],
|
||||
features: [
|
||||
'Magic MIFARE Classic 1K',
|
||||
'Gen1a and gen2 magic options',
|
||||
'Changeable UID (4-byte)',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/xm1/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: false,
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// X-Series - Cryptographic/Identity
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'spark2',
|
||||
name: 'VivoKey Spark 2',
|
||||
description:
|
||||
'Cryptobionic identity implant for secure authentication and digital identity.',
|
||||
formFactor: FormFactor.X_SERIES,
|
||||
categories: [ProductCategory.SECURE],
|
||||
compatibleChips: [ChipType.NTAG424_DNA, ChipType.NTAG424_DNA_TT, ChipType.ICODE_DNA],
|
||||
features: [
|
||||
'VivoKey Ecosystem',
|
||||
'Spark Actions',
|
||||
'Verify API',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/vivokey-spark/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: true,
|
||||
notes: 'UID cannot be changed. Uses cryptographic authentication - cannot clone',
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Bioresin Implants - Larger capsules (incision install)
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'dnext',
|
||||
name: 'dNExT',
|
||||
description:
|
||||
'Jumbo-sized bioresin dual-frequency implant with NTAG216 (NFC) and T5577 (125kHz). Enhanced read range.',
|
||||
formFactor: FormFactor.BIORESIN,
|
||||
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'NTAG216 NFC chip',
|
||||
'888 bytes user memory',
|
||||
'URL/contact sharing',
|
||||
'T5577 125kHz chip',
|
||||
'Bioresin encapsulation',
|
||||
'Enhanced read range',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/dnext/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: 'HF UID cannot be changed. Larger size provides improved performance over x-series',
|
||||
},
|
||||
{
|
||||
id: 'dug4t',
|
||||
name: 'dUG4T',
|
||||
description:
|
||||
'Bioresin dual-frequency implant with Ultimate Gen4 magic MIFARE and T5577 for maximum compatibility.',
|
||||
formFactor: FormFactor.BIORESIN,
|
||||
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS],
|
||||
compatibleChips: [...MIFARE_CLASSIC_COMPATIBLE, ...ULTRALIGHT_COMPATIBLE],
|
||||
features: [
|
||||
'Ultimate Gen4 magic chip',
|
||||
'Changeable UID',
|
||||
'Access control (HF)',
|
||||
'T5577 125kHz chip',
|
||||
'Changeable UID',
|
||||
'Enhanced cloning capability',
|
||||
'Access control (LF)',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/dug4t/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: false,
|
||||
notes: 'Supports 1K and 4K cloning. Also includes T5577 for LF.',
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Flex Implants - NFC
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'flexnt',
|
||||
name: 'flexNT',
|
||||
description:
|
||||
'Flexible NTAG216 implant with larger antenna for improved read range.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.NFC],
|
||||
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
|
||||
features: [
|
||||
'NTAG216 chip',
|
||||
'888 bytes user memory',
|
||||
'URL/contact sharing',
|
||||
'Extended read range',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexnt/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: true,
|
||||
notes: "UID cannot be changed"
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Flex Implants - DESFire (Secure)
|
||||
// Note: flexDF (EV1) discontinued - flexDF2 is current
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'flexdf2',
|
||||
name: 'flexDF2',
|
||||
description: 'Flexible DESFire EV2 implant with enhanced security.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.SECURE, ProductCategory.ACCESS],
|
||||
compatibleChips: [...DESFIRE_ALL],
|
||||
features: [
|
||||
'DESFire EV2 chip',
|
||||
'8KB memory',
|
||||
'AES-128 encryption',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexdf2/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: true,
|
||||
notes: 'UID cannot be changed. Cannot clone data due to cryptographic protection',
|
||||
desfireEvLevel: 2,
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Flex Implants - Magic/Cloning
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'flexm1-v2',
|
||||
name: 'flexM1 v2',
|
||||
description: 'Updated flexible magic MIFARE Classic 1K implant.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.ACCESS],
|
||||
compatibleChips: [...MIFARE_CLASSIC_COMPATIBLE],
|
||||
features: [
|
||||
'Magic MIFARE Classic 1K',
|
||||
'Improved antenna design',
|
||||
'Changeable UID',
|
||||
'Better compatibility',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexm1-v2/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: false,
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Flex Implants - Secure Element / JavaCard
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'apex-flex',
|
||||
name: 'Apex Flex',
|
||||
description:
|
||||
'The ultimate subdermal security key for digital identity, cryptography, and blockchain applications.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.SECURE],
|
||||
compatibleChips: [ChipType.JCOP4, ChipType.JAVACARD_UNKNOWN],
|
||||
features: [
|
||||
'JCOP4 secure element',
|
||||
'Fidesmo platform',
|
||||
'FIDO2/WebAuthn',
|
||||
'OTP support',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/apex-flex/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: true,
|
||||
notes: 'UID cannot be changed. Cannot clone - programmable via Fidesmo app',
|
||||
},
|
||||
{
|
||||
id: 'flexsecure',
|
||||
name: 'flexSecure',
|
||||
description:
|
||||
'Developer-friendly JavaCard implant for custom secure applications.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.SECURE],
|
||||
compatibleChips: [ChipType.JCOP4, ChipType.JAVACARD_UNKNOWN],
|
||||
features: [
|
||||
'SmartMX3 P71 chip',
|
||||
'Full JavaCard access',
|
||||
'GlobalPlatformPro compatible',
|
||||
'Custom applet support',
|
||||
'FIDO2/WebAuthn',
|
||||
'OTP support',
|
||||
'URL/contact sharing',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexsecure/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: true,
|
||||
notes: 'UID cannot be changed. Developer-focused - requires technical knowledge',
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Flex Implants - Access Control
|
||||
// ==========================================================================
|
||||
{
|
||||
id: 'flexclass',
|
||||
name: 'flexClass',
|
||||
description: 'Flexible HID iCLASS compatible implant for enterprise access control.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.SECURE, ProductCategory.ACCESS],
|
||||
compatibleChips: [], // iCLASS uses proprietary protocol
|
||||
features: [
|
||||
'HID iCLASS SE compatible',
|
||||
'Enterprise access control',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexclass/',
|
||||
canReceiveClone: false,
|
||||
exactMatch: false,
|
||||
notes: 'For HID iCLASS systems only',
|
||||
},
|
||||
{
|
||||
id: 'flexug4',
|
||||
name: 'flexUG4',
|
||||
description: 'Flexible Ultimate Gen4 magic MIFARE implant for cloning access cards.',
|
||||
formFactor: FormFactor.FLEX,
|
||||
categories: [ProductCategory.ACCESS],
|
||||
compatibleChips: [...MIFARE_CLASSIC_COMPATIBLE, ...ULTRALIGHT_COMPATIBLE],
|
||||
features: [
|
||||
'Ultimate Gen4 magic chip',
|
||||
'Changeable UID',
|
||||
'Enhanced cloning capability',
|
||||
],
|
||||
url: 'https://dangerousthings.com/product/flexug4/',
|
||||
canReceiveClone: true,
|
||||
exactMatch: false,
|
||||
notes: 'Supports 1K and 4K cloning.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a map of chip types to compatible products
|
||||
*/
|
||||
export function buildChipProductMap(): ChipProductMap {
|
||||
const map: ChipProductMap = new Map();
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
for (const chipType of product.compatibleChips) {
|
||||
const existing = map.get(chipType) || [];
|
||||
existing.push(product);
|
||||
map.set(chipType, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached chip-to-product map
|
||||
*/
|
||||
let chipProductMapCache: ChipProductMap | null = null;
|
||||
|
||||
/**
|
||||
* Get the chip-to-product map (cached)
|
||||
*/
|
||||
export function getChipProductMap(): ChipProductMap {
|
||||
if (!chipProductMapCache) {
|
||||
chipProductMapCache = buildChipProductMap();
|
||||
}
|
||||
return chipProductMapCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversion service URL for chips that don't have direct matches
|
||||
*/
|
||||
export const CONVERSION_SERVICE_URL = 'https://dngr.us/conversion';
|
||||
|
||||
/**
|
||||
* Get all products
|
||||
*/
|
||||
export function getAllProducts(): Product[] {
|
||||
return PRODUCTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product by ID
|
||||
*/
|
||||
export function getProductById(id: string): Product | undefined {
|
||||
return PRODUCTS.find(p => p.id === id);
|
||||
}
|
||||
@@ -5,17 +5,33 @@
|
||||
|
||||
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||
import {nfcManager} from '../services/nfc';
|
||||
import {detectChip} from '../services/detection';
|
||||
import type {ScanState, RawTagData, ScanError, NFCStatus} from '../types/nfc';
|
||||
import type {Transponder} from '../types/detection';
|
||||
|
||||
/** Progress update during scanning */
|
||||
export interface ScanProgress {
|
||||
/** Current step description */
|
||||
step: string;
|
||||
/** Step number (1-indexed) */
|
||||
current: number;
|
||||
/** Total steps (if known) */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface UseScanResult {
|
||||
/** Current scan state */
|
||||
state: ScanState;
|
||||
/** Scanned tag data (when state is 'success') */
|
||||
tag: RawTagData | null;
|
||||
/** Detected transponder info (when detection succeeds) */
|
||||
transponder: Transponder | null | undefined;
|
||||
/** Scan error (when state is 'error') */
|
||||
error: ScanError | null;
|
||||
/** NFC status (supported and enabled) */
|
||||
nfcStatus: NFCStatus;
|
||||
/** Current scan progress (during 'scanning' state) */
|
||||
scanProgress: ScanProgress | null;
|
||||
/** Start scanning for a tag */
|
||||
startScan: () => Promise<void>;
|
||||
/** Cancel ongoing scan */
|
||||
@@ -29,14 +45,25 @@ export interface UseScanResult {
|
||||
/**
|
||||
* Hook for managing NFC scanning
|
||||
*/
|
||||
// Combined scan result to ensure atomic updates
|
||||
interface ScanResult {
|
||||
tag: RawTagData | null;
|
||||
transponder: Transponder | null | undefined;
|
||||
}
|
||||
|
||||
export function useScan(): UseScanResult {
|
||||
const [state, setState] = useState<ScanState>('idle');
|
||||
const [tag, setTag] = useState<RawTagData | null>(null);
|
||||
// Use combined state to ensure tag and transponder update atomically
|
||||
const [scanResult, setScanResult] = useState<ScanResult>({
|
||||
tag: null,
|
||||
transponder: undefined,
|
||||
});
|
||||
const [error, setError] = useState<ScanError | null>(null);
|
||||
const [nfcStatus, setNfcStatus] = useState<NFCStatus>({
|
||||
isSupported: false,
|
||||
isEnabled: false,
|
||||
});
|
||||
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
|
||||
|
||||
// Track if component is mounted to prevent state updates after unmount
|
||||
const isMounted = useRef(true);
|
||||
@@ -75,8 +102,9 @@ export function useScan(): UseScanResult {
|
||||
|
||||
scanInProgress.current = true;
|
||||
setState('scanning');
|
||||
setTag(null);
|
||||
setScanResult({ tag: null, transponder: undefined });
|
||||
setError(null);
|
||||
setScanProgress({step: 'Waiting for tag...', current: 1});
|
||||
|
||||
try {
|
||||
// Check NFC status first
|
||||
@@ -107,8 +135,27 @@ export function useScan(): UseScanResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const result = await nfcManager.scanTag();
|
||||
// Perform scan with detection in one session
|
||||
const result = await nfcManager.scanWithDetection(async tagData => {
|
||||
// Update progress: tag detected
|
||||
if (isMounted.current) {
|
||||
setScanProgress({step: 'Tag detected, identifying chip...', current: 2});
|
||||
}
|
||||
|
||||
// Run chip detection while NFC session is still active
|
||||
const detectionResult = await detectChip(tagData, step => {
|
||||
// Progress callback from detection
|
||||
if (isMounted.current) {
|
||||
setScanProgress({step, current: 3});
|
||||
}
|
||||
});
|
||||
|
||||
if (isMounted.current) {
|
||||
setScanProgress({step: 'Detection complete', current: 4});
|
||||
}
|
||||
|
||||
return detectionResult.success ? detectionResult.transponder : null;
|
||||
});
|
||||
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
@@ -123,8 +170,18 @@ export function useScan(): UseScanResult {
|
||||
setError(result.error);
|
||||
}
|
||||
} else if (result.tag) {
|
||||
const detectedTransponder = result.detection ?? null;
|
||||
console.log('[useScan] Scan complete:', {
|
||||
hasTag: !!result.tag,
|
||||
hasDetection: !!result.detection,
|
||||
transponderType: detectedTransponder?.type,
|
||||
});
|
||||
// Update tag and transponder atomically in a single state update
|
||||
setScanResult({
|
||||
tag: result.tag,
|
||||
transponder: detectedTransponder,
|
||||
});
|
||||
setState('success');
|
||||
setTag(result.tag);
|
||||
} else {
|
||||
setState('error');
|
||||
setError({
|
||||
@@ -159,8 +216,9 @@ export function useScan(): UseScanResult {
|
||||
// Reset state to idle
|
||||
const reset = useCallback(() => {
|
||||
setState('idle');
|
||||
setTag(null);
|
||||
setScanResult({ tag: null, transponder: undefined });
|
||||
setError(null);
|
||||
setScanProgress(null);
|
||||
}, []);
|
||||
|
||||
// Open device NFC settings
|
||||
@@ -175,9 +233,11 @@ export function useScan(): UseScanResult {
|
||||
|
||||
return {
|
||||
state,
|
||||
tag,
|
||||
tag: scanResult.tag,
|
||||
transponder: scanResult.transponder,
|
||||
error,
|
||||
nfcStatus,
|
||||
scanProgress,
|
||||
startScan,
|
||||
cancelScan,
|
||||
reset,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
import {Button, Text, Surface} from 'react-native-paper';
|
||||
import {Text, Surface} from 'react-native-paper';
|
||||
import {DTButton, DTColors} from 'react-native-dt-theme';
|
||||
import type {HomeScreenProps} from '../types/navigation';
|
||||
import {DTColors} from '../theme';
|
||||
|
||||
export function HomeScreen({navigation}: HomeScreenProps) {
|
||||
return (
|
||||
@@ -21,14 +21,11 @@ export function HomeScreen({navigation}: HomeScreenProps) {
|
||||
Scan any NFC transponder to find compatible Dangerous Things implants.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={() => navigation.navigate('Scan')}
|
||||
style={styles.scanButton}
|
||||
labelStyle={styles.scanButtonLabel}
|
||||
contentStyle={styles.scanButtonContent}>
|
||||
<DTButton
|
||||
variant="normal"
|
||||
onPress={() => navigation.navigate('Scan')}>
|
||||
START SCAN
|
||||
</Button>
|
||||
</DTButton>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
@@ -74,21 +71,6 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.9,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
scanButton: {
|
||||
borderColor: DTColors.modeNormal,
|
||||
borderWidth: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
scanButtonLabel: {
|
||||
color: DTColors.modeNormal,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
scanButtonContent: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
paddingBottom: 20,
|
||||
|
||||
@@ -1,43 +1,478 @@
|
||||
import React from 'react';
|
||||
import {StyleSheet, View, ScrollView, Linking} from 'react-native';
|
||||
import {Button, Text, Surface, Divider} from 'react-native-paper';
|
||||
import type {ResultScreenProps} from '../types/navigation';
|
||||
import {DTColors} from '../theme';
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, View, ScrollView, Linking, TouchableOpacity, Animated } from 'react-native';
|
||||
import { Button, Text, Surface, Divider, Chip } from 'react-native-paper';
|
||||
import { DTCard, DTButton, DTColors, DTLabel, DTChip } from 'react-native-dt-theme';
|
||||
|
||||
export function ResultScreen({route, navigation}: ResultScreenProps) {
|
||||
const {tagData} = route.params;
|
||||
// Animated section wrapper for staggered entry
|
||||
interface AnimatedSectionProps {
|
||||
children: React.ReactNode;
|
||||
delay: number;
|
||||
duration?: number;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
function AnimatedSection({ children, delay, duration = 400, style }: AnimatedSectionProps) {
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(20)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration,
|
||||
delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, [delay, duration, opacity, translateY]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[{ opacity, transform: [{ translateY }] }, style]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
import type { ResultScreenProps } from '../types/navigation';
|
||||
import { matchChipToProducts, getMatchSummary, getDesfireEvMismatchWarning, getMifareClassicCapacityWarning } from '../services/matching';
|
||||
import { Product } from '../types/products';
|
||||
import { getChipInfo, getChipFamilyInfo, getSecurityLevelDescription } from '../data/chipInfo';
|
||||
import { getChipFamily } from '../types/detection';
|
||||
|
||||
export function ResultScreen({ route, navigation }: ResultScreenProps) {
|
||||
const { tagData, transponder } = route.params;
|
||||
const [showChipInfo, setShowChipInfo] = useState(false);
|
||||
|
||||
// Match chip to products
|
||||
const matchResult = useMemo(() => {
|
||||
if (!transponder) return null;
|
||||
return matchChipToProducts(transponder);
|
||||
}, [transponder]);
|
||||
|
||||
// Get educational chip info
|
||||
const chipInfo = useMemo(() => {
|
||||
if (!transponder) return null;
|
||||
return getChipInfo(transponder.type);
|
||||
}, [transponder]);
|
||||
|
||||
const chipFamilyInfo = useMemo(() => {
|
||||
if (!transponder) return null;
|
||||
return getChipFamilyInfo(getChipFamily(transponder.type));
|
||||
}, [transponder]);
|
||||
|
||||
// Calculate product count for animation timing
|
||||
const productCount = useMemo(() => {
|
||||
if (!matchResult) return 0;
|
||||
const exactCount = matchResult.exactMatches.length;
|
||||
const cloneCount = matchResult.cloneTargets.filter(
|
||||
p => !matchResult.exactMatches.find(e => e.id === p.id)
|
||||
).length;
|
||||
return exactCount + cloneCount;
|
||||
}, [matchResult]);
|
||||
|
||||
// Animation delays - sequential flow
|
||||
const delays = useMemo(() => {
|
||||
const base = {
|
||||
chipIdentified: 0,
|
||||
aboutChip: 150,
|
||||
compatibleLabel: 300,
|
||||
productBase: 450,
|
||||
productIncrement: 150,
|
||||
};
|
||||
const afterProducts = base.productBase + (productCount * base.productIncrement);
|
||||
return {
|
||||
...base,
|
||||
noMatch: base.compatibleLabel, // Shows in place of products
|
||||
sakSwap: base.aboutChip, // Shows after chip info
|
||||
rawData: productCount > 0 ? afterProducts : 300,
|
||||
actions: productCount > 0 ? afterProducts + 150 : 450,
|
||||
conversion: productCount > 0 ? afterProducts + 75 : 375, // Between raw data and actions
|
||||
};
|
||||
}, [productCount]);
|
||||
|
||||
// Build URL with UTM tracking parameters for analytics
|
||||
const buildTrackedUrl = (baseUrl: string, content?: string) => {
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.set('utm_source', 'dt_nfc_identifier');
|
||||
url.searchParams.set('utm_medium', 'app');
|
||||
url.searchParams.set('utm_campaign', 'chip_scan');
|
||||
if (content) {
|
||||
url.searchParams.set('utm_content', content);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const handleConversionLink = () => {
|
||||
Linking.openURL('https://dngr.us/conversion');
|
||||
const baseUrl = matchResult?.conversionUrl || 'https://dngr.us/conversion';
|
||||
const content = transponder?.chipName || transponder?.type;
|
||||
Linking.openURL(buildTrackedUrl(baseUrl, content));
|
||||
};
|
||||
|
||||
const handleProductLink = (product: Product) => {
|
||||
Linking.openURL(buildTrackedUrl(product.url, product.name));
|
||||
};
|
||||
|
||||
// Determine card colors based on detection
|
||||
const getCloneabilityColor = () => {
|
||||
if (!transponder) return DTColors.light;
|
||||
return transponder.isCloneable ? DTColors.modeSuccess : DTColors.modeWarning;
|
||||
};
|
||||
|
||||
const getConfidenceLabel = () => {
|
||||
if (!transponder) return null;
|
||||
const colors = {
|
||||
high: DTColors.modeSuccess,
|
||||
medium: DTColors.modeEmphasis,
|
||||
low: DTColors.modeWarning,
|
||||
};
|
||||
return { color: colors[transponder.confidence], label: `${transponder.confidence.toUpperCase()} CONFIDENCE` };
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Surface style={styles.resultCard} elevation={1}>
|
||||
<Text variant="labelLarge" style={styles.cardLabel}>
|
||||
TAG DETECTED
|
||||
{/* Chip Identification Card */}
|
||||
{transponder && (
|
||||
<AnimatedSection delay={delays.chipIdentified}>
|
||||
<DTCard mode="success" title="CHIP IDENTIFIED" style={{ marginBottom: 20 }}>
|
||||
|
||||
<Text variant="headlineMedium" style={styles.chipName}>
|
||||
{transponder.chipName}
|
||||
</Text>
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
{/* Show implant name if detected from memory, or payment device */}
|
||||
{transponder.implantName && (
|
||||
transponder.implantName.includes('Payment Card') ? (
|
||||
<View style={styles.paymentDeviceRow}>
|
||||
<Text variant="labelMedium" style={styles.paymentDeviceLabel}>
|
||||
PAYMENT DEVICE DETECTED
|
||||
</Text>
|
||||
<Text variant="titleLarge" style={styles.paymentDeviceValue}>
|
||||
{transponder.implantName.replace(' Payment Card', '')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.implantNameRow}>
|
||||
<Text variant="labelMedium" style={styles.implantNameLabel}>
|
||||
IMPLANT DETECTED
|
||||
</Text>
|
||||
<Text variant="titleLarge" style={styles.implantNameValue}>
|
||||
{transponder.implantName}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
<View style={styles.chipMeta}>
|
||||
<DTChip
|
||||
variant="normal">
|
||||
{transponder.family}
|
||||
</DTChip>
|
||||
{getConfidenceLabel() && (
|
||||
<DTChip
|
||||
variant='other'
|
||||
// style={[styles.confidenceChip, { borderColor: getConfidenceLabel()!.color }]}
|
||||
// textStyle={[styles.confidenceChipText, { color: getConfidenceLabel()!.color }]}>
|
||||
>
|
||||
{getConfidenceLabel()!.label}
|
||||
</DTChip>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{transponder.memorySize && (
|
||||
<View style={styles.detailRow}>
|
||||
<Text variant="bodyMedium" style={styles.detailLabel}>
|
||||
MEMORY
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.detailValue}>
|
||||
{transponder.memorySize} bytes
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.detailRow}>
|
||||
<Text variant="bodyMedium" style={styles.detailLabel}>
|
||||
CLONEABLE
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
style={[styles.detailValue, { color: getCloneabilityColor() }]}>
|
||||
{transponder.isCloneable ? 'YES' : 'NO'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{transponder.cloneabilityNote && (
|
||||
<Text variant="bodySmall" style={styles.cloneabilityNote}>
|
||||
{transponder.cloneabilityNote}
|
||||
</Text>
|
||||
)}
|
||||
</DTCard>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* Educational Chip Info Card */}
|
||||
{transponder && (chipInfo || chipFamilyInfo) && (
|
||||
<AnimatedSection delay={delays.aboutChip}>
|
||||
<Surface style={styles.chipInfoCard} elevation={1}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowChipInfo(!showChipInfo)}
|
||||
style={styles.chipInfoHeader}
|
||||
activeOpacity={0.7}>
|
||||
<Text variant="labelLarge" style={styles.chipInfoLabel}>
|
||||
ABOUT THIS CHIP
|
||||
</Text>
|
||||
<Text style={styles.expandIndicator}>
|
||||
{showChipInfo ? '▼' : '▶'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showChipInfo && (
|
||||
<>
|
||||
<Divider style={[styles.divider, { backgroundColor: DTColors.modeOther }]} />
|
||||
|
||||
{/* Family description */}
|
||||
{chipFamilyInfo && (
|
||||
<Text variant="bodyMedium" style={styles.chipFamilyDescription}>
|
||||
{chipFamilyInfo}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Detailed chip info */}
|
||||
{chipInfo && (
|
||||
<>
|
||||
<Text variant="bodyMedium" style={styles.chipDescription}>
|
||||
{chipInfo.description}
|
||||
</Text>
|
||||
|
||||
{chipInfo.memoryNote && (
|
||||
<View style={styles.chipInfoRow}>
|
||||
<Text variant="bodySmall" style={styles.chipInfoRowLabel}>
|
||||
Memory:
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={styles.chipInfoRowValue}>
|
||||
{chipInfo.memoryNote}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.chipInfoRow}>
|
||||
<Text variant="bodySmall" style={styles.chipInfoRowLabel}>
|
||||
Security:
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[
|
||||
styles.chipInfoRowValue,
|
||||
{
|
||||
color:
|
||||
chipInfo.securityLevel === 'high'
|
||||
? DTColors.modeSuccess
|
||||
: chipInfo.securityLevel === 'medium'
|
||||
? DTColors.modeEmphasis
|
||||
: DTColors.modeWarning,
|
||||
},
|
||||
]}>
|
||||
{chipInfo.securityLevel.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text variant="bodySmall" style={styles.securityNote}>
|
||||
{getSecurityLevelDescription(chipInfo.securityLevel)}
|
||||
</Text>
|
||||
|
||||
{chipInfo.commonUses.length > 0 && (
|
||||
<>
|
||||
<Text variant="labelSmall" style={styles.chipInfoSectionLabel}>
|
||||
COMMON USES
|
||||
</Text>
|
||||
{chipInfo.commonUses.map((use, idx) => (
|
||||
<Text key={idx} variant="bodySmall" style={styles.chipInfoListItem}>
|
||||
• {use}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{chipInfo.capabilities.length > 0 && (
|
||||
<>
|
||||
<Text variant="labelSmall" style={styles.chipInfoSectionLabel}>
|
||||
CAPABILITIES
|
||||
</Text>
|
||||
{chipInfo.capabilities.map((cap, idx) => (
|
||||
<Text key={idx} variant="bodySmall" style={styles.chipInfoListItem}>
|
||||
• {cap}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Surface>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* Product Matches Card - hide for payment devices */}
|
||||
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && !transponder.implantName?.includes('Payment Card') && (
|
||||
<AnimatedSection delay={delays.compatibleLabel}>
|
||||
<DTLabel mode='emphasis' primaryText="Compatible Implants" style={{ marginBottom: 25, }} size="large" animated={false} />
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* Individual Product Cards - staggered */}
|
||||
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && !transponder.implantName?.includes('Payment Card') && (
|
||||
[...matchResult.exactMatches, ...matchResult.cloneTargets.filter(
|
||||
p => !matchResult.exactMatches.find(e => e.id === p.id)
|
||||
)].map((product, index) => (
|
||||
<AnimatedSection key={product.id} delay={delays.productBase + (index * delays.productIncrement)}>
|
||||
<DTCard mode='emphasis' title={product.name} style={{ marginBottom: 25 }}>
|
||||
<View style={styles.productHeader}>
|
||||
{/* <Text variant="titleMedium" style={styles.productName}>
|
||||
{product.name}
|
||||
</Text> */}
|
||||
<DTChip
|
||||
style={styles.formFactorChip}
|
||||
textStyle={styles.formFactorChipText}>
|
||||
{product.formFactor.replace('_', ' ')}
|
||||
</DTChip>
|
||||
{product.formFactor.startsWith("X") && <Text style={styles.chipDescription}>Injectable</Text>}
|
||||
{product.formFactor.startsWith("B") && <Text style={styles.chipDescription}>Large, rigid</Text>}
|
||||
{product.formFactor.startsWith("F") && <Text style={styles.chipDescription}>Large, semi-rigid</Text>}
|
||||
</View>
|
||||
|
||||
<Text variant="bodySmall" style={styles.productDescription}>
|
||||
{product.description}
|
||||
</Text>
|
||||
|
||||
{product.notes && (
|
||||
<Text variant="bodySmall" style={styles.productNote}>
|
||||
Note: {product.notes}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* DESFire EV mismatch warning */}
|
||||
{getDesfireEvMismatchWarning(transponder.type, product) && (
|
||||
<Text variant="bodySmall" style={styles.evMismatchWarning}>
|
||||
⚠️ {getDesfireEvMismatchWarning(transponder.type, product)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* MIFARE Classic 4K → 1K capacity warning */}
|
||||
{getMifareClassicCapacityWarning(transponder.type, product) && (
|
||||
<Text variant="bodySmall" style={styles.evMismatchWarning}>
|
||||
⚠️ {getMifareClassicCapacityWarning(transponder.type, product)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.productFeatures}>
|
||||
{product.features.slice(0, 3).map((feature, idx) => (
|
||||
<Text key={idx} variant="bodySmall" style={styles.productFeature}>
|
||||
• {feature}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<DTButton
|
||||
onPress={() => handleProductLink(product)}
|
||||
>
|
||||
VIEW PRODUCT
|
||||
|
||||
</DTButton>
|
||||
</DTCard>
|
||||
</AnimatedSection>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* No Match Card - show when chip detected but no products match */}
|
||||
{transponder && matchResult && matchResult.exactMatches.length === 0 && matchResult.cloneTargets.length === 0 && (
|
||||
<AnimatedSection delay={delays.noMatch}>
|
||||
<Surface style={styles.noMatchCard} elevation={1}>
|
||||
<Text variant="labelLarge" style={styles.noMatchLabel}>
|
||||
NO DIRECT MATCH
|
||||
</Text>
|
||||
<Divider style={[styles.divider, { backgroundColor: DTColors.modeOther }]} />
|
||||
|
||||
<Text variant="bodyMedium" style={styles.noMatchText}>
|
||||
{getMatchSummary(matchResult, transponder.chipName)}
|
||||
</Text>
|
||||
|
||||
{matchResult.familyMatches.length > 0 && (
|
||||
<>
|
||||
<Text variant="bodySmall" style={styles.familyMatchesLabel}>
|
||||
Related products in the same chip family:
|
||||
</Text>
|
||||
{matchResult.familyMatches.slice(0, 2).map(product => (
|
||||
<Text key={product.id} variant="bodySmall" style={styles.familyMatchItem}>
|
||||
• {product.name}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Surface>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* SAK Swap Detection Card */}
|
||||
{transponder?.sakSwapInfo?.hasSakSwap && (
|
||||
<AnimatedSection delay={delays.sakSwap}>
|
||||
<Surface style={styles.sakSwapCard} elevation={1}>
|
||||
<Text variant="labelLarge" style={styles.sakSwapLabel}>
|
||||
SAK SWAP DETECTED
|
||||
</Text>
|
||||
<Divider style={[styles.divider, { backgroundColor: DTColors.modeEmphasis }]} />
|
||||
|
||||
<Text variant="bodyLarge" style={styles.sakSwapType}>
|
||||
{transponder.sakSwapInfo.swapType?.replace(/_/g, ' ').toUpperCase()}
|
||||
</Text>
|
||||
|
||||
<Text variant="bodyMedium" style={styles.sakSwapDescription}>
|
||||
{transponder.sakSwapInfo.description}
|
||||
</Text>
|
||||
|
||||
{transponder.sakSwapInfo.notes && transponder.sakSwapInfo.notes.length > 0 && (
|
||||
<View style={styles.sakSwapNotes}>
|
||||
{transponder.sakSwapInfo.notes.map((note, index) => (
|
||||
<Text key={index} variant="bodySmall" style={styles.sakSwapNote}>
|
||||
• {note}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</Surface>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* Raw Tag Data Card */}
|
||||
<AnimatedSection delay={delays.rawData}>
|
||||
<DTCard mode="normal" title="RAW TAG DATA" style={{ marginBottom: 20 }}>
|
||||
|
||||
{tagData ? (
|
||||
<>
|
||||
<View style={styles.dataRow}>
|
||||
<Text variant="bodyMedium" style={styles.dataLabel}>
|
||||
<Text variant="bodyLarge" style={styles.dataLabel}>
|
||||
UID
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.dataValue}>
|
||||
<Text variant="bodyMedium" style={styles.dataValue}>
|
||||
{tagData.uid || 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataRow}>
|
||||
<Text variant="bodyMedium" style={styles.dataLabel}>
|
||||
<Text variant="bodyLarge" style={styles.dataLabel}>
|
||||
TECHNOLOGIES
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.dataValue}>
|
||||
{tagData.techTypes.join(', ') || 'N/A'}
|
||||
</Text>
|
||||
{
|
||||
tagData.techTypes.map((t, i) =>
|
||||
<Text key={i} variant="bodyMedium" style={styles.dataValue}>
|
||||
{t}
|
||||
</Text>)
|
||||
}
|
||||
</View>
|
||||
|
||||
{tagData.sak !== undefined && (
|
||||
@@ -78,27 +513,45 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
|
||||
No tag data available
|
||||
</Text>
|
||||
)}
|
||||
</DTCard>
|
||||
</AnimatedSection>
|
||||
|
||||
{/* No Detection Card (fallback) */}
|
||||
{!transponder && (
|
||||
<AnimatedSection delay={0}>
|
||||
<Surface style={styles.noDetectionCard} elevation={1}>
|
||||
<Text variant="labelLarge" style={styles.noDetectionLabel}>
|
||||
CHIP NOT IDENTIFIED
|
||||
</Text>
|
||||
<Divider style={[styles.divider, { backgroundColor: DTColors.modeWarning }]} />
|
||||
<Text variant="bodyMedium" style={styles.noDetectionText}>
|
||||
Unable to identify this chip type. It may be unsupported or require advanced detection.
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.noDetectionText}>
|
||||
Need help? Ask on our forum for assistance.
|
||||
</Text>
|
||||
<DTButton
|
||||
onPress={() => Linking.openURL(buildTrackedUrl('https://dngr.us/forum', 'unknown_chip'))}
|
||||
>
|
||||
VISIT FORUM
|
||||
</DTButton>
|
||||
</Surface>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
<Surface style={styles.matchCard} elevation={1}>
|
||||
<Text variant="labelLarge" style={styles.matchLabel}>
|
||||
COMPATIBLE IMPLANTS
|
||||
</Text>
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
<Text variant="bodyLarge" style={styles.placeholderText}>
|
||||
Detection coming in Phase 3-5
|
||||
</Text>
|
||||
|
||||
<Text variant="bodyMedium" style={styles.hintText}>
|
||||
Full chip identification and product matching will be implemented in
|
||||
later phases.
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
{/* Conversion Service Card - show for payment devices, or when recommended (but NOT for unidentified chips) */}
|
||||
{transponder && (transponder?.implantName?.includes('Payment Card') ||
|
||||
(matchResult?.conversionRecommended && !transponder?.implantName)) && (
|
||||
<AnimatedSection delay={delays.conversion}>
|
||||
<Surface style={styles.conversionCard} elevation={1}>
|
||||
<Text variant="bodyMedium" style={styles.conversionText}>
|
||||
Can't find a match? Check out our conversion service.
|
||||
{transponder?.implantName?.includes('Payment Card')
|
||||
? "Payment cards can't be copied/cloned to implants. Our conversion service might be an option."
|
||||
: !transponder
|
||||
? 'Unknown chip? Our conversion service can help.'
|
||||
: matchResult?.isCloneable === false
|
||||
? "This chip uses cryptographic protection and can't be cloned. Our conversion service can help find alternatives."
|
||||
: "No direct product match found. Our conversion service can help."}
|
||||
</Text>
|
||||
<Button
|
||||
mode="outlined"
|
||||
@@ -108,15 +561,18 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
|
||||
CONVERSION SERVICE
|
||||
</Button>
|
||||
</Surface>
|
||||
</AnimatedSection>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<AnimatedSection delay={delays.actions}>
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
mode="outlined"
|
||||
<DTButton
|
||||
variant="normal"
|
||||
onPress={() => navigation.navigate('Scan')}
|
||||
style={styles.actionButton}
|
||||
labelStyle={styles.actionButtonLabel}>
|
||||
style={{ width: '100%' }}>
|
||||
SCAN ANOTHER
|
||||
</Button>
|
||||
</DTButton>
|
||||
|
||||
<Button
|
||||
mode="text"
|
||||
@@ -125,8 +581,9 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
|
||||
HOME
|
||||
</Button>
|
||||
</View>
|
||||
</AnimatedSection>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +595,98 @@ const styles = StyleSheet.create({
|
||||
content: {
|
||||
padding: 24,
|
||||
},
|
||||
// Identification Card
|
||||
identificationCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderColor: DTColors.modeSuccess,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
identificationLabel: {
|
||||
color: DTColors.modeSuccess,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
chipName: {
|
||||
color: DTColors.light,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
chipMeta: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
familyChip: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
},
|
||||
familyChipText: {
|
||||
color: DTColors.modeNormal,
|
||||
fontSize: 12,
|
||||
},
|
||||
confidenceChip: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
},
|
||||
confidenceChipText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
detailLabel: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.6,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
detailValue: {
|
||||
color: DTColors.light,
|
||||
},
|
||||
cloneabilityNote: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.5,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
// SAK Swap Card
|
||||
sakSwapCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
sakSwapLabel: {
|
||||
color: DTColors.modeEmphasis,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sakSwapType: {
|
||||
color: DTColors.modeEmphasis,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sakSwapDescription: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.9,
|
||||
marginBottom: 12,
|
||||
},
|
||||
sakSwapNotes: {
|
||||
marginTop: 8,
|
||||
},
|
||||
sakSwapNote: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.7,
|
||||
marginBottom: 4,
|
||||
},
|
||||
// Raw Data Card
|
||||
resultCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
@@ -174,29 +723,142 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.6,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
matchCard: {
|
||||
// No Detection Card
|
||||
noDetectionCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeWarning,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
noDetectionLabel: {
|
||||
color: DTColors.modeWarning,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
noDetectionText: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
forumButton: {
|
||||
borderColor: DTColors.modeWarning,
|
||||
borderWidth: 1,
|
||||
marginTop: 8,
|
||||
},
|
||||
forumButtonLabel: {
|
||||
color: DTColors.modeWarning,
|
||||
fontSize: 12,
|
||||
},
|
||||
// Products Card
|
||||
productsCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
matchLabel: {
|
||||
productsLabel: {
|
||||
color: DTColors.modeEmphasis,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
placeholderText: {
|
||||
matchSummary: {
|
||||
color: DTColors.light,
|
||||
marginBottom: 16,
|
||||
},
|
||||
productItem: {
|
||||
backgroundColor: '#111111',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeNormal,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
productHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
productName: {
|
||||
color: DTColors.modeEmphasis,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
formFactorChip: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeNormal,
|
||||
height: 35,
|
||||
},
|
||||
formFactorChipText: {
|
||||
color: DTColors.modeNormal,
|
||||
fontSize: 10,
|
||||
},
|
||||
productDescription: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
hintText: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.5,
|
||||
productNote: {
|
||||
color: DTColors.modeOther,
|
||||
fontStyle: 'italic',
|
||||
marginBottom: 8,
|
||||
},
|
||||
evMismatchWarning: {
|
||||
color: DTColors.modeEmphasis,
|
||||
backgroundColor: 'rgba(255, 255, 0, 0.1)',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
productFeatures: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
productFeature: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.7,
|
||||
marginBottom: 2,
|
||||
},
|
||||
productButton: {
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
borderWidth: 1,
|
||||
},
|
||||
productButtonLabel: {
|
||||
color: DTColors.modeEmphasis,
|
||||
fontSize: 12,
|
||||
},
|
||||
// No Match Card
|
||||
noMatchCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeOther,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
noMatchLabel: {
|
||||
color: DTColors.modeOther,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
noMatchText: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
familyMatchesLabel: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
familyMatchItem: {
|
||||
color: DTColors.modeNormal,
|
||||
marginBottom: 4,
|
||||
},
|
||||
// Conversion Card
|
||||
conversionCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
@@ -220,6 +882,7 @@ const styles = StyleSheet.create({
|
||||
color: DTColors.modeOther,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Actions
|
||||
actions: {
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
@@ -237,4 +900,109 @@ const styles = StyleSheet.create({
|
||||
color: DTColors.light,
|
||||
opacity: 0.6,
|
||||
},
|
||||
// Chip Info Card
|
||||
chipInfoCard: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeOther,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
chipInfoHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chipInfoLabel: {
|
||||
color: DTColors.modeOther,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
expandIndicator: {
|
||||
color: DTColors.modeOther,
|
||||
fontSize: 12,
|
||||
},
|
||||
chipFamilyDescription: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.8,
|
||||
marginBottom: 12,
|
||||
lineHeight: 22,
|
||||
},
|
||||
chipDescription: {
|
||||
color: DTColors.light,
|
||||
marginBottom: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
chipInfoRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 4,
|
||||
},
|
||||
chipInfoRowLabel: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.6,
|
||||
marginRight: 8,
|
||||
},
|
||||
chipInfoRowValue: {
|
||||
color: DTColors.light,
|
||||
},
|
||||
securityNote: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.5,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
chipInfoSectionLabel: {
|
||||
color: DTColors.modeOther,
|
||||
letterSpacing: 1,
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
chipInfoListItem: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
// Implant name styles
|
||||
implantNameRow: {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeSuccess,
|
||||
padding: 12,
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
implantNameLabel: {
|
||||
color: DTColors.modeSuccess,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 4,
|
||||
},
|
||||
implantNameValue: {
|
||||
color: DTColors.modeSuccess,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
// Payment device styles
|
||||
paymentDeviceRow: {
|
||||
backgroundColor: 'rgba(255, 255, 0, 0.1)',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
padding: 12,
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
paymentDeviceLabel: {
|
||||
color: DTColors.modeEmphasis,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 4,
|
||||
},
|
||||
paymentDeviceValue: {
|
||||
color: DTColors.modeEmphasis,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {StyleSheet, View, Platform} from 'react-native';
|
||||
import {Button, Text, ActivityIndicator} from 'react-native-paper';
|
||||
import type {ScanScreenProps} from '../types/navigation';
|
||||
import {DTColors} from '../theme';
|
||||
import {useScan} from '../hooks';
|
||||
import {getScanInstructions} from '../services/nfc';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { Button, Text } from 'react-native-paper';
|
||||
import { DTButton, DTColors } from 'react-native-dt-theme';
|
||||
import type { ScanScreenProps } from '../types/navigation';
|
||||
import { useScan } from '../hooks';
|
||||
import { getScanInstructions } from '../services/nfc';
|
||||
import { ScanAnimation } from '../components';
|
||||
|
||||
export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
export function ScanScreen({ navigation }: ScanScreenProps) {
|
||||
const {
|
||||
state,
|
||||
tag,
|
||||
transponder,
|
||||
error,
|
||||
nfcStatus,
|
||||
scanProgress,
|
||||
startScan,
|
||||
cancelScan,
|
||||
openSettings,
|
||||
} = useScan();
|
||||
|
||||
// Navigate to results when scan succeeds
|
||||
// Track if we've already navigated to prevent double navigation
|
||||
const hasNavigated = React.useRef(false);
|
||||
|
||||
// Navigate to results when scan succeeds AND transponder is set
|
||||
// We need transponder to be set (even if null for failed detection) before navigating
|
||||
useEffect(() => {
|
||||
if (state === 'success' && tag) {
|
||||
// Only navigate once per scan session
|
||||
if (hasNavigated.current) return;
|
||||
|
||||
// Wait for success state, tag data, AND transponder to be set
|
||||
// transponder: undefined = not yet set, null = detection failed, Transponder = success
|
||||
if (state === 'success' && tag && transponder !== undefined) {
|
||||
hasNavigated.current = true;
|
||||
console.log('[ScanScreen] Navigating to Result:', {
|
||||
hasTag: !!tag,
|
||||
hasTransponder: !!transponder,
|
||||
transponderType: transponder?.type,
|
||||
transponderChipName: transponder?.chipName,
|
||||
});
|
||||
navigation.replace('Result', {
|
||||
tagData: {
|
||||
uid: tag.uid,
|
||||
@@ -29,9 +48,17 @@ export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
ats: tag.ats,
|
||||
historicalBytes: tag.historicalBytes,
|
||||
},
|
||||
transponder: transponder ?? undefined,
|
||||
});
|
||||
}
|
||||
}, [state, tag, navigation]);
|
||||
}, [state, tag, transponder, navigation]);
|
||||
|
||||
// Reset navigation flag when starting a new scan
|
||||
useEffect(() => {
|
||||
if (state === 'scanning') {
|
||||
hasNavigated.current = false;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Auto-start scan when screen loads
|
||||
useEffect(() => {
|
||||
@@ -54,16 +81,46 @@ export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
startScan();
|
||||
}, [startScan]);
|
||||
|
||||
// Get contextual error hints based on error type
|
||||
const getErrorHint = () => {
|
||||
if (!error?.type) return null;
|
||||
|
||||
switch (error.type) {
|
||||
case 'TAG_LOST':
|
||||
return 'Hold the tag steady against the back of your phone until scanning completes.';
|
||||
case 'TIMEOUT':
|
||||
return 'The tag took too long to respond. Try repositioning it closer to the NFC reader.';
|
||||
case 'SCAN_CANCELLED':
|
||||
return null; // User cancelled, no hint needed
|
||||
case 'NFC_NOT_ENABLED':
|
||||
return Platform.OS === 'ios'
|
||||
? 'Go to Settings > NFC to enable NFC scanning.'
|
||||
: 'Enable NFC in your device settings.';
|
||||
case 'PERMISSION_DENIED':
|
||||
return 'NFC permission was denied. Please grant NFC access in your device settings.';
|
||||
case 'UNKNOWN':
|
||||
return 'An unexpected error occurred. Make sure the tag is positioned correctly and try again.';
|
||||
default:
|
||||
return 'Make sure the tag is positioned correctly and try again.';
|
||||
}
|
||||
};
|
||||
|
||||
// Render NFC not supported state
|
||||
if (!nfcStatus.isSupported && state !== 'scanning') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.errorIcon}>
|
||||
<Text style={styles.errorIconText}>✕</Text>
|
||||
</View>
|
||||
<Text variant="headlineMedium" style={styles.errorText}>
|
||||
NFC NOT SUPPORTED
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.errorMessage}>
|
||||
This device does not support NFC scanning.
|
||||
This device does not have NFC hardware.
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.hintText}>
|
||||
NFC scanning requires a device with NFC capability. Most modern smartphones have NFC, but it may need to be enabled in settings.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
@@ -83,20 +140,27 @@ export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.warningIcon}>
|
||||
<Text style={styles.warningIconText}>⚡</Text>
|
||||
</View>
|
||||
<Text variant="headlineMedium" style={styles.warningText}>
|
||||
NFC DISABLED
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.errorMessage}>
|
||||
Please enable NFC in your device settings to scan tags.
|
||||
NFC is turned off on this device.
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.hintText}>
|
||||
{Platform.OS === 'ios'
|
||||
? 'Go to Settings and enable NFC scanning to continue.'
|
||||
: 'Enable NFC in your device settings to scan tags.'}
|
||||
</Text>
|
||||
{Platform.OS === 'android' && (
|
||||
<Button
|
||||
mode="outlined"
|
||||
<DTButton
|
||||
variant="emphasis"
|
||||
onPress={openSettings}
|
||||
style={styles.settingsButton}
|
||||
labelStyle={styles.buttonLabel}>
|
||||
style={styles.settingsButton}>
|
||||
OPEN SETTINGS
|
||||
</Button>
|
||||
</DTButton>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
@@ -122,43 +186,54 @@ export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
<View style={styles.content}>
|
||||
{state === 'scanning' && (
|
||||
<>
|
||||
<View style={styles.scanIndicator}>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={DTColors.modeNormal}
|
||||
style={styles.spinner}
|
||||
/>
|
||||
<View style={styles.pulseRing} />
|
||||
</View>
|
||||
<ScanAnimation isActive={true} size={180} />
|
||||
<Text variant="headlineMedium" style={styles.scanningText}>
|
||||
SCANNING
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.instructionText}>
|
||||
{getScanInstructions()}
|
||||
</Text>
|
||||
<Text variant="headlineSmall" style={{ paddingTop: 15, paddingBottom: 10 }}>
|
||||
Not Scanning?
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.instructionText}>
|
||||
Phones can only read high frequency (13.56 MHz) -- low and ultra high frequency transponders cannot be scanned.
|
||||
</Text>
|
||||
{scanProgress && scanProgress.current > 1 && (
|
||||
<Text variant="bodySmall" style={styles.progressText}>
|
||||
{scanProgress.step}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<>
|
||||
<View style={styles.errorIcon}>
|
||||
<Text style={styles.errorIconText}>!</Text>
|
||||
</View>
|
||||
<Text variant="headlineMedium" style={styles.errorText}>
|
||||
SCAN FAILED
|
||||
{error?.type === 'TAG_LOST'
|
||||
? 'TAG LOST'
|
||||
: error?.type === 'TIMEOUT'
|
||||
? 'TIMEOUT'
|
||||
: error?.type === 'PERMISSION_DENIED'
|
||||
? 'PERMISSION DENIED'
|
||||
: 'SCAN FAILED'}
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={styles.errorMessage}>
|
||||
{error?.message || 'Unable to read NFC tag'}
|
||||
</Text>
|
||||
{error?.type === 'TAG_LOST' && (
|
||||
{getErrorHint() && (
|
||||
<Text variant="bodyMedium" style={styles.hintText}>
|
||||
Hold the tag steady until the scan completes
|
||||
{getErrorHint()}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleRetry}
|
||||
style={styles.retryButton}
|
||||
labelStyle={styles.buttonLabel}>
|
||||
<DTButton
|
||||
variant="normal"
|
||||
onPress={handleRetry}>
|
||||
TRY AGAIN
|
||||
</Button>
|
||||
</DTButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -167,25 +242,26 @@ export function ScanScreen({navigation}: ScanScreenProps) {
|
||||
<Text variant="headlineMedium" style={styles.readyText}>
|
||||
READY TO SCAN
|
||||
</Text>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={startScan}
|
||||
style={styles.startButton}
|
||||
labelStyle={styles.buttonLabel}>
|
||||
<DTButton
|
||||
variant="normal"
|
||||
onPress={startScan}>
|
||||
START
|
||||
</Button>
|
||||
</DTButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
<ScanAnimation
|
||||
isActive={true}
|
||||
size={180}
|
||||
color={DTColors.modeEmphasis}
|
||||
style={styles.processingSpinner}
|
||||
/>
|
||||
<Text variant="headlineMedium" style={styles.processingText}>
|
||||
PROCESSING
|
||||
IDENTIFYING
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.detectingHint}>
|
||||
Analyzing chip type...
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -214,27 +290,10 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanIndicator: {
|
||||
width: 150,
|
||||
height: 150,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
spinner: {
|
||||
position: 'absolute',
|
||||
},
|
||||
pulseRing: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
borderWidth: 2,
|
||||
borderColor: DTColors.modeNormal,
|
||||
opacity: 0.3,
|
||||
},
|
||||
scanningText: {
|
||||
color: DTColors.modeNormal,
|
||||
letterSpacing: 4,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
},
|
||||
instructionText: {
|
||||
@@ -243,11 +302,47 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
progressText: {
|
||||
color: DTColors.modeNormal,
|
||||
opacity: 0.7,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
errorIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
borderWidth: 3,
|
||||
borderColor: DTColors.modeWarning,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorIconText: {
|
||||
color: DTColors.modeWarning,
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorText: {
|
||||
color: DTColors.modeWarning,
|
||||
letterSpacing: 2,
|
||||
marginBottom: 16,
|
||||
},
|
||||
warningIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
borderWidth: 3,
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
warningIconText: {
|
||||
color: DTColors.modeEmphasis,
|
||||
fontSize: 36,
|
||||
},
|
||||
warningText: {
|
||||
color: DTColors.modeEmphasis,
|
||||
letterSpacing: 2,
|
||||
@@ -268,13 +363,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
retryButton: {
|
||||
borderColor: DTColors.modeNormal,
|
||||
borderWidth: 2,
|
||||
},
|
||||
settingsButton: {
|
||||
borderColor: DTColors.modeEmphasis,
|
||||
borderWidth: 2,
|
||||
marginTop: 16,
|
||||
},
|
||||
readyText: {
|
||||
@@ -282,20 +371,15 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 2,
|
||||
marginBottom: 32,
|
||||
},
|
||||
startButton: {
|
||||
borderColor: DTColors.modeNormal,
|
||||
borderWidth: 2,
|
||||
},
|
||||
buttonLabel: {
|
||||
color: DTColors.modeNormal,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
processingSpinner: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
processingText: {
|
||||
color: DTColors.modeEmphasis,
|
||||
letterSpacing: 4,
|
||||
marginTop: 32,
|
||||
},
|
||||
detectingHint: {
|
||||
color: DTColors.light,
|
||||
opacity: 0.7,
|
||||
marginTop: 12,
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
|
||||
649
src/services/detection/desfire.ts
Normal file
649
src/services/detection/desfire.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* DESFire Detector
|
||||
* Identifies MIFARE DESFire EV1/EV2/EV3, DESFire DNA variants,
|
||||
* DESFire Light, and NTAG 424 DNA using GET_VERSION command
|
||||
*/
|
||||
|
||||
import {ChipType, DesfireVersionInfo} from '../../types/detection';
|
||||
import type {NdefRecord} from '../../types/nfc';
|
||||
import {
|
||||
DESFIRE_GET_VERSION,
|
||||
DESFIRE_GET_VERSION_CONTINUE,
|
||||
sendIsoDepCommand,
|
||||
parseApduResponse,
|
||||
selectAid,
|
||||
KNOWN_AIDS,
|
||||
} from '../nfc/commands';
|
||||
|
||||
/**
|
||||
* URI record type name format prefixes
|
||||
* https://www.nfc-forum.org/specs/
|
||||
*/
|
||||
const URI_PREFIXES: Record<number, string> = {
|
||||
0x00: '',
|
||||
0x01: 'http://www.',
|
||||
0x02: 'https://www.',
|
||||
0x03: 'http://',
|
||||
0x04: 'https://',
|
||||
0x05: 'tel:',
|
||||
0x06: 'mailto:',
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect Spark 2 implant from cached NDEF records
|
||||
* This should be called BEFORE any APDU commands to avoid putting the tag
|
||||
* into native mode which breaks ISO 7816-4 NDEF reading
|
||||
*/
|
||||
export function detectSpark2FromNdef(
|
||||
ndefRecords?: NdefRecord[],
|
||||
): Spark2DetectionResult {
|
||||
if (!ndefRecords || ndefRecords.length === 0) {
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
console.log('[DESFire] Checking cached NDEF records for Spark 2...');
|
||||
|
||||
for (const record of ndefRecords) {
|
||||
// Check for URI record (TNF=1, type="U")
|
||||
if (record.tnf === 1 && record.type === 'U' && record.payload.length > 0) {
|
||||
// First byte is URI prefix code
|
||||
const prefixCode = record.payload[0];
|
||||
const prefix = URI_PREFIXES[prefixCode] || '';
|
||||
const uriBody = record.payload
|
||||
.slice(1)
|
||||
.map(b => String.fromCharCode(b))
|
||||
.join('');
|
||||
const fullUri = prefix + uriBody;
|
||||
|
||||
console.log('[DESFire] Found URI record:', fullUri);
|
||||
|
||||
// Check for vivokey.co pattern
|
||||
const vivokeyMatch = fullUri.match(/vivokey\.co\/([A-Za-z0-9_\-]+)/i);
|
||||
if (vivokeyMatch) {
|
||||
const code = vivokeyMatch[1];
|
||||
const url = `https://vivokey.co/${code}`;
|
||||
console.log('[DESFire] Found Spark 2 URL:', url);
|
||||
return {
|
||||
found: true,
|
||||
name: 'Spark 2',
|
||||
url,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DESFire] No vivokey.co URL in NDEF records');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Product type values from GET_VERSION byte 1
|
||||
* Per NXP datasheets:
|
||||
* - NTAG 413 DNA (NT4H1321): Type 0x04, Subtype 0x02
|
||||
* - NTAG 424 DNA (NT4H2421): Type 0x04, Subtype 0x05
|
||||
* - NTAG 424 DNA TT: Type 0x04, Subtype 0x04
|
||||
* - DESFire EV1/EV2/EV3: Type 0x01
|
||||
*/
|
||||
const PRODUCT_TYPES = {
|
||||
DESFIRE: 0x01, // Standard DESFire
|
||||
NTAG_DNA: 0x04, // NTAG DNA family (413/424) - distinguished by subtype
|
||||
NTAG_I2C: 0x05, // NTAG I2C family (can have ISO-DEP on Plus variants)
|
||||
DESFIRE_LIGHT: 0x08, // DESFire Light
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* NTAG DNA subtype values from GET_VERSION byte 2
|
||||
* Per NXP NT4H1321 and NT4H2421 datasheets
|
||||
*/
|
||||
const NTAG_DNA_SUBTYPES = {
|
||||
NTAG413_DNA: 0x02, // NTAG 413 DNA
|
||||
NTAG424_DNA_TT: 0x04, // NTAG 424 DNA TagTamper
|
||||
NTAG424_DNA: 0x05, // NTAG 424 DNA
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* DESFire hardware major version to chip type mapping
|
||||
* Per NXP MF3D(H)x1, MF3D(H)x2, MF3DHx3 datasheets
|
||||
*
|
||||
* Byte 3 (hwMajor) of GET_VERSION response indicates the version:
|
||||
* - EV1: 0x00, 0x01
|
||||
* - EV2: 0x12, 0x22 (standard), 0x32 (DNA capable)
|
||||
* - EV3: 0x30, 0x31, 0x32, 0x33, 0x34 (various configs)
|
||||
*
|
||||
* Note: The version byte encoding changed between generations
|
||||
*/
|
||||
const DESFIRE_VERSION_MAP: Record<number, ChipType> = {
|
||||
// DESFire EV1 (and legacy EV0)
|
||||
0x00: ChipType.DESFIRE_EV1, // EV0/Legacy
|
||||
0x01: ChipType.DESFIRE_EV1, // EV1 standard
|
||||
|
||||
// DESFire EV2
|
||||
0x10: ChipType.DESFIRE_EV2, // Some EV2 variants
|
||||
0x11: ChipType.DESFIRE_EV2, // Some EV2 variants
|
||||
0x12: ChipType.DESFIRE_EV2, // EV2 standard
|
||||
0x20: ChipType.DESFIRE_EV2, // Some EV2 variants
|
||||
0x21: ChipType.DESFIRE_EV2, // Some EV2 variants
|
||||
0x22: ChipType.DESFIRE_EV2, // EV2 alternative
|
||||
|
||||
// DESFire EV3 - per NXP MF3DHx3 datasheet
|
||||
0x30: ChipType.DESFIRE_EV3, // EV3 standard
|
||||
0x31: ChipType.DESFIRE_EV3, // EV3 variant
|
||||
0x32: ChipType.DESFIRE_EV3, // EV3 (may also be EV2 DNA in some docs)
|
||||
0x33: ChipType.DESFIRE_EV3, // EV3 variant
|
||||
0x34: ChipType.DESFIRE_EV3, // EV3 variant
|
||||
0x35: ChipType.DESFIRE_EV3, // EV3 variant
|
||||
0x36: ChipType.DESFIRE_EV3, // EV3 variant
|
||||
};
|
||||
|
||||
/**
|
||||
* DESFire DNA detection notes:
|
||||
* DNA variants have originality signature capability, but this CANNOT be
|
||||
* reliably determined from GET_VERSION alone. The major version ranges
|
||||
* overlap between standard and DNA variants.
|
||||
*
|
||||
* To properly detect DNA, you would need to:
|
||||
* 1. Attempt to read the originality signature (command 0x3C)
|
||||
* 2. Check if it succeeds (DNA) or fails (non-DNA)
|
||||
*
|
||||
* For now, we report the base EV version without assuming DNA status.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DESFire storage size decoding
|
||||
* The storage size byte encodes capacity
|
||||
*/
|
||||
const DESFIRE_STORAGE_SIZES: Record<number, number> = {
|
||||
0x10: 256, // 256 bytes (DESFire Light)
|
||||
0x12: 512, // 512 bytes (DESFire Light)
|
||||
0x13: 888, // NTAG I2C 1K user memory
|
||||
0x14: 1024, // 1K
|
||||
0x15: 1912, // NTAG I2C 2K user memory
|
||||
0x16: 2048, // 2K
|
||||
0x18: 4096, // 4K
|
||||
0x1a: 8192, // 8K
|
||||
0x1c: 16384, // 16K
|
||||
0x1e: 32768, // 32K
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of DESFire detection
|
||||
*/
|
||||
export interface DesfireDetectionResult {
|
||||
success: boolean;
|
||||
chipType?: ChipType;
|
||||
versionInfo?: DesfireVersionInfo;
|
||||
storageSize?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect DESFire chip version using GET_VERSION command
|
||||
*
|
||||
* DESFire GET_VERSION returns data in 3 frames:
|
||||
* Frame 1: Hardware version info (7 bytes)
|
||||
* Frame 2: Software version info (7 bytes)
|
||||
* Frame 3: Production info (14 bytes)
|
||||
*/
|
||||
export async function detectDesfire(): Promise<DesfireDetectionResult> {
|
||||
try {
|
||||
console.log('[DESFire] Sending GET_VERSION command:', DESFIRE_GET_VERSION);
|
||||
|
||||
// Send GET_VERSION command (wrapped in ISO 7816-4 format)
|
||||
const response1 = await sendIsoDepCommand(DESFIRE_GET_VERSION);
|
||||
console.log('[DESFire] GET_VERSION response:', response1);
|
||||
|
||||
const parsed1 = parseApduResponse(response1);
|
||||
console.log('[DESFire] Parsed response:', {
|
||||
data: parsed1.data,
|
||||
sw1: parsed1.sw1.toString(16),
|
||||
sw2: parsed1.sw2.toString(16),
|
||||
isSuccess: parsed1.isSuccess,
|
||||
});
|
||||
|
||||
// Check for success or "more data" response
|
||||
if (!parsed1.isSuccess && parsed1.sw1 !== 0xaf) {
|
||||
return {
|
||||
success: false,
|
||||
error: `GET_VERSION failed: SW=${parsed1.sw1.toString(16)}${parsed1.sw2.toString(16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed1.data.length < 7) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid GET_VERSION response length: ${parsed1.data.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse hardware version info
|
||||
// Byte 0: Vendor ID (0x04 = NXP)
|
||||
// Byte 1: Product type (0x01 = DESFire, 0x08 = DESFire Light, 0x21 = NTAG 424 DNA)
|
||||
// Byte 2: Subtype
|
||||
// Byte 3: Major version
|
||||
// Byte 4: Minor version
|
||||
// Byte 5: Storage size
|
||||
// Byte 6: Protocol
|
||||
const hwVendorId = parsed1.data[0];
|
||||
const hwProductType = parsed1.data[1];
|
||||
const hwSubtype = parsed1.data[2];
|
||||
const hwMajor = parsed1.data[3];
|
||||
const hwMinor = parsed1.data[4];
|
||||
const hwStorageSize = parsed1.data[5];
|
||||
|
||||
// Verify this is NXP
|
||||
if (hwVendorId !== 0x04) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.DESFIRE_UNKNOWN,
|
||||
error: `Non-NXP vendor: 0x${hwVendorId.toString(16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get software version (second frame)
|
||||
let swMajor = 0;
|
||||
let swMinor = 0;
|
||||
|
||||
if (parsed1.sw1 === 0xaf || parsed1.sw2 === 0xaf) {
|
||||
try {
|
||||
const response2 = await sendIsoDepCommand(DESFIRE_GET_VERSION_CONTINUE);
|
||||
const parsed2 = parseApduResponse(response2);
|
||||
|
||||
if (parsed2.data.length >= 7) {
|
||||
swMajor = parsed2.data[3];
|
||||
swMinor = parsed2.data[4];
|
||||
}
|
||||
} catch {
|
||||
// Continue without software version
|
||||
}
|
||||
}
|
||||
|
||||
// Determine chip type based on product type and version
|
||||
let chipType: ChipType;
|
||||
|
||||
if (hwProductType === PRODUCT_TYPES.NTAG_DNA) {
|
||||
// NTAG DNA family (413/424) - distinguish by subtype per NXP datasheets
|
||||
// NT4H1321 (NTAG 413 DNA): Subtype 0x02, Major 0x04
|
||||
// NT4H2421 (NTAG 424 DNA): Subtype 0x05, Major 0x04
|
||||
// NT4H2421 TT (NTAG 424 DNA TagTamper): Subtype 0x04, Major 0x04
|
||||
switch (hwSubtype) {
|
||||
case NTAG_DNA_SUBTYPES.NTAG413_DNA:
|
||||
console.log('[DESFire] Detected NTAG 413 DNA (product 0x04, subtype 0x02)');
|
||||
chipType = ChipType.NTAG413_DNA;
|
||||
break;
|
||||
case NTAG_DNA_SUBTYPES.NTAG424_DNA_TT:
|
||||
console.log('[DESFire] Detected NTAG 424 DNA TT (product 0x04, subtype 0x04)');
|
||||
chipType = ChipType.NTAG424_DNA_TT;
|
||||
break;
|
||||
case NTAG_DNA_SUBTYPES.NTAG424_DNA:
|
||||
console.log('[DESFire] Detected NTAG 424 DNA (product 0x04, subtype 0x05)');
|
||||
chipType = ChipType.NTAG424_DNA;
|
||||
break;
|
||||
default:
|
||||
// Unknown NTAG DNA subtype - log and fall back to 424 DNA as most common
|
||||
console.warn(
|
||||
`[DESFire] Unknown NTAG DNA subtype: 0x${hwSubtype.toString(16)}, major: 0x${hwMajor.toString(16)}`,
|
||||
);
|
||||
chipType = ChipType.NTAG424_DNA;
|
||||
}
|
||||
} else if (hwProductType === PRODUCT_TYPES.NTAG_I2C) {
|
||||
// NTAG I2C family (can have ISO-DEP on Plus variants)
|
||||
// Per NXP NT3H2111/NT3H2211 datasheet:
|
||||
// - Storage size 0x13 = 1K variant (NT3H1101, NT3H2111)
|
||||
// - Storage size 0x15 = 2K variant (NT3H1201, NT3H2211)
|
||||
// - Subtype 0x01 = non-Plus, 0x02 = Plus
|
||||
const isPlus = hwSubtype === 0x02;
|
||||
|
||||
if (hwStorageSize === 0x13) {
|
||||
chipType = isPlus ? ChipType.NTAG_I2C_PLUS_1K : ChipType.NTAG_I2C_1K;
|
||||
} else if (hwStorageSize === 0x15) {
|
||||
chipType = isPlus ? ChipType.NTAG_I2C_PLUS_2K : ChipType.NTAG_I2C_2K;
|
||||
} else {
|
||||
// Unknown storage size - report as unknown NTAG
|
||||
console.warn(
|
||||
`[DESFire] NTAG I2C with unknown storage size: 0x${hwStorageSize.toString(16)}`,
|
||||
);
|
||||
chipType = ChipType.NTAG_UNKNOWN;
|
||||
}
|
||||
} else if (hwProductType === PRODUCT_TYPES.DESFIRE_LIGHT) {
|
||||
// DESFire Light
|
||||
chipType = ChipType.DESFIRE_LIGHT;
|
||||
} else if (hwProductType === PRODUCT_TYPES.DESFIRE) {
|
||||
// Standard DESFire - determine version from hwMajor
|
||||
chipType = DESFIRE_VERSION_MAP[hwMajor];
|
||||
|
||||
// If not in our map, try to infer from the version range
|
||||
if (!chipType) {
|
||||
console.warn(
|
||||
`[DESFire] Unknown hardware major version: 0x${hwMajor.toString(16)}`,
|
||||
);
|
||||
// Infer based on version ranges per NXP conventions
|
||||
if (hwMajor <= 0x01) {
|
||||
chipType = ChipType.DESFIRE_EV1;
|
||||
} else if (hwMajor >= 0x10 && hwMajor < 0x30) {
|
||||
chipType = ChipType.DESFIRE_EV2;
|
||||
} else if (hwMajor >= 0x30) {
|
||||
chipType = ChipType.DESFIRE_EV3;
|
||||
} else {
|
||||
chipType = ChipType.DESFIRE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: DNA variants cannot be reliably detected from GET_VERSION.
|
||||
// To detect DNA, you would need to try reading the originality signature.
|
||||
} else {
|
||||
// Unknown product type
|
||||
chipType = ChipType.DESFIRE_UNKNOWN;
|
||||
}
|
||||
|
||||
// Decode storage size
|
||||
const storageSize = DESFIRE_STORAGE_SIZES[hwStorageSize];
|
||||
|
||||
const versionInfo: DesfireVersionInfo = {
|
||||
hardwareMajor: hwMajor,
|
||||
hardwareMinor: hwMinor,
|
||||
hardwareStorageSize: hwStorageSize,
|
||||
softwareMajor: swMajor,
|
||||
softwareMinor: swMinor,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
versionInfo,
|
||||
storageSize,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `DESFire detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag might be DESFire based on SAK and tech types
|
||||
*/
|
||||
export function mightBeDesfire(sak?: number, techTypes?: string[]): boolean {
|
||||
// DESFire has SAK 0x20 (ISO 14443-4 compliant)
|
||||
// But SAK 0x20 can also be other ISO-DEP chips
|
||||
if (sak === 0x20) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for IsoDep technology
|
||||
if (techTypes?.some(t => t.includes('IsoDep'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect DESFire from ATS/historical bytes when GET_VERSION fails
|
||||
* This is useful for iOS where commands may not work reliably
|
||||
*
|
||||
* DESFire ATS patterns:
|
||||
* - Historical bytes starting with 0x75 (format byte) + 0x77 (card type)
|
||||
* - "DESFire" text in historical bytes (some older cards)
|
||||
* - SAK 0x20 with specific ATQA patterns
|
||||
*/
|
||||
export function detectDesfireFromAts(
|
||||
historicalBytes?: string,
|
||||
ats?: string,
|
||||
sak?: number,
|
||||
atqa?: string,
|
||||
): DesfireDetectionResult {
|
||||
// Convert hex string to bytes for analysis
|
||||
const histBytes = historicalBytes
|
||||
? historicalBytes
|
||||
.replace(/[:\s-]/g, '')
|
||||
.match(/.{1,2}/g)
|
||||
?.map(h => parseInt(h, 16)) || []
|
||||
: [];
|
||||
|
||||
// Check for DESFire historical bytes patterns
|
||||
// Pattern 1: 0x75 0x77 0x81 0x02 0x80 (DESFire EV1/2/3 typical pattern)
|
||||
if (histBytes.length >= 5) {
|
||||
if (histBytes[0] === 0x75 && histBytes[1] === 0x77) {
|
||||
// This is likely DESFire
|
||||
// Byte 4 often indicates storage size
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.DESFIRE_UNKNOWN,
|
||||
error: 'Identified as DESFire from ATS (version unknown)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Check for specific DESFire identifiers
|
||||
// Some DESFire cards have 0x06 as format byte followed by capabilities
|
||||
if (histBytes.length >= 3 && histBytes[0] === 0x06) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.DESFIRE_UNKNOWN,
|
||||
error: 'Identified as DESFire from ATS format byte',
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 3: Check for NTAG 424 DNA historical bytes
|
||||
// NTAG 424 DNA typically has historical bytes starting with specific patterns
|
||||
if (histBytes.length >= 4) {
|
||||
// NTAG 424 DNA pattern: 0x80 0x77 0xC1 or similar
|
||||
if (histBytes[0] === 0x80 && histBytes[1] === 0x77) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG424_DNA,
|
||||
error: 'Identified as NTAG 424 DNA from ATS',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 4: Check for "DESFire" ASCII in historical bytes
|
||||
const histString = histBytes.map(b => String.fromCharCode(b)).join('');
|
||||
if (histString.includes('DESFire') || histString.includes('DESFIRE')) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.DESFIRE_UNKNOWN,
|
||||
error: 'Identified as DESFire from ATS string',
|
||||
};
|
||||
}
|
||||
|
||||
// SAK-based inference
|
||||
if (sak === 0x20) {
|
||||
// SAK 0x20 with ISO-DEP capability but no other identification
|
||||
// Could be DESFire, NTAG 424 DNA, or other ISO 14443-4 card
|
||||
// Parse ATQA for more info
|
||||
if (atqa) {
|
||||
const atqaClean = atqa.replace(/[:\s-]/g, '');
|
||||
// DESFire typically has ATQA 0x0344 or 0x0304
|
||||
if (atqaClean === '0344' || atqaClean === '4403') {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.DESFIRE_UNKNOWN,
|
||||
error: 'Likely DESFire based on SAK/ATQA',
|
||||
};
|
||||
}
|
||||
// NTAG 424 DNA typically has ATQA 0x0004
|
||||
if (atqaClean === '0004' || atqaClean === '0400') {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG424_DNA,
|
||||
error: 'Likely NTAG 424 DNA based on SAK/ATQA',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Could not identify DESFire from ATS',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format DESFire version info for display
|
||||
*/
|
||||
export function formatDesfireVersionInfo(info: DesfireVersionInfo): string {
|
||||
const version = `HW: ${info.hardwareMajor}.${info.hardwareMinor}`;
|
||||
const software =
|
||||
info.softwareMajor > 0
|
||||
? `, SW: ${info.softwareMajor}.${info.softwareMinor}`
|
||||
: '';
|
||||
return version + software;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of Spark 2 detection
|
||||
*/
|
||||
export interface Spark2DetectionResult {
|
||||
found: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Spark 2 implant by reading NDEF from NTAG 424 DNA
|
||||
* Spark 2 implants have a URI record pointing to vivokey.co/$code
|
||||
*
|
||||
* Uses Type 4 Tag NDEF reading:
|
||||
* 1. SELECT NDEF application
|
||||
* 2. SELECT NDEF file (E104)
|
||||
* 3. READ BINARY to get NDEF message
|
||||
*/
|
||||
export async function detectSpark2Implant(): Promise<Spark2DetectionResult> {
|
||||
try {
|
||||
console.log('[DESFire] Checking for Spark 2 implant NDEF...');
|
||||
|
||||
// Step 1: Select NDEF application (D2760000850101)
|
||||
console.log('[DESFire] Selecting NDEF application...');
|
||||
const selectNdefApp = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ndefTag));
|
||||
const selectAppResponse = parseApduResponse(selectNdefApp);
|
||||
console.log('[DESFire] NDEF app select result:', {
|
||||
sw: `${selectAppResponse.sw1.toString(16)}${selectAppResponse.sw2.toString(16)}`,
|
||||
success: selectAppResponse.isSuccess,
|
||||
});
|
||||
|
||||
if (!selectAppResponse.isSuccess) {
|
||||
console.log('[DESFire] NDEF app selection failed');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
// Step 2: Select NDEF file (file ID E104 for standard Type 4 Tag)
|
||||
// SELECT by file ID: 00 A4 00 0C 02 E104
|
||||
console.log('[DESFire] Selecting NDEF file E104...');
|
||||
const selectFileCmd = [0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x04];
|
||||
const selectFileResponse = await sendIsoDepCommand(selectFileCmd);
|
||||
const selectFileParsed = parseApduResponse(selectFileResponse);
|
||||
console.log('[DESFire] NDEF file select result:', {
|
||||
sw: `${selectFileParsed.sw1.toString(16)}${selectFileParsed.sw2.toString(16)}`,
|
||||
success: selectFileParsed.isSuccess,
|
||||
});
|
||||
|
||||
if (!selectFileParsed.isSuccess) {
|
||||
// Try alternate file ID (some Type 4 tags use E102 or just 02)
|
||||
console.log('[DESFire] Trying alternate NDEF file 0002...');
|
||||
const altSelectCmd = [0x00, 0xa4, 0x00, 0x0c, 0x02, 0x00, 0x02];
|
||||
const altSelectResponse = await sendIsoDepCommand(altSelectCmd);
|
||||
const altSelectParsed = parseApduResponse(altSelectResponse);
|
||||
console.log('[DESFire] Alternate file select result:', {
|
||||
sw: `${altSelectParsed.sw1.toString(16)}${altSelectParsed.sw2.toString(16)}`,
|
||||
success: altSelectParsed.isSuccess,
|
||||
});
|
||||
|
||||
if (!altSelectParsed.isSuccess) {
|
||||
console.log('[DESFire] NDEF file selection failed');
|
||||
return {found: false};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Read NDEF length (first 2 bytes)
|
||||
// READ BINARY: 00 B0 00 00 02
|
||||
console.log('[DESFire] Reading NDEF length...');
|
||||
const readLengthCmd = [0x00, 0xb0, 0x00, 0x00, 0x02];
|
||||
const readLengthResponse = await sendIsoDepCommand(readLengthCmd);
|
||||
const lengthParsed = parseApduResponse(readLengthResponse);
|
||||
console.log('[DESFire] NDEF length read result:', {
|
||||
sw: `${lengthParsed.sw1.toString(16)}${lengthParsed.sw2.toString(16)}`,
|
||||
data: lengthParsed.data,
|
||||
});
|
||||
|
||||
if (!lengthParsed.isSuccess || lengthParsed.data.length < 2) {
|
||||
console.log('[DESFire] NDEF length read failed');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
const ndefLength = (lengthParsed.data[0] << 8) | lengthParsed.data[1];
|
||||
console.log('[DESFire] NDEF message length:', ndefLength, 'bytes');
|
||||
|
||||
if (ndefLength === 0 || ndefLength > 500) {
|
||||
console.log('[DESFire] Invalid or empty NDEF length');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
// Step 4: Read NDEF message (starting after length bytes)
|
||||
// For longer NDEF messages, we may need to read in chunks
|
||||
const allNdefData: number[] = [];
|
||||
let offset = 2; // Start after the 2-byte length field
|
||||
let remaining = ndefLength;
|
||||
|
||||
while (remaining > 0) {
|
||||
const chunkSize = Math.min(remaining, 128); // Read up to 128 bytes at a time
|
||||
// READ BINARY: 00 B0 <offset high> <offset low> <length>
|
||||
const readDataCmd = [0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, chunkSize];
|
||||
const readDataResponse = await sendIsoDepCommand(readDataCmd);
|
||||
const dataParsed = parseApduResponse(readDataResponse);
|
||||
|
||||
if (!dataParsed.isSuccess || dataParsed.data.length === 0) {
|
||||
console.log('[DESFire] NDEF data read failed at offset', offset);
|
||||
break;
|
||||
}
|
||||
|
||||
allNdefData.push(...dataParsed.data);
|
||||
offset += dataParsed.data.length;
|
||||
remaining -= dataParsed.data.length;
|
||||
}
|
||||
|
||||
if (allNdefData.length === 0) {
|
||||
console.log('[DESFire] No NDEF data read');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[DESFire] NDEF data (' + allNdefData.length + ' bytes):',
|
||||
allNdefData.slice(0, 50).map(b => b.toString(16).padStart(2, '0')).join(' ') +
|
||||
(allNdefData.length > 50 ? '...' : ''),
|
||||
);
|
||||
|
||||
// Convert to ASCII and look for vivokey.co pattern
|
||||
const asciiStr = allNdefData
|
||||
.filter(b => b >= 0x20 && b <= 0x7e)
|
||||
.map(b => String.fromCharCode(b))
|
||||
.join('');
|
||||
|
||||
console.log('[DESFire] NDEF ASCII:', asciiStr);
|
||||
|
||||
// Check for vivokey.co URL pattern - match any characters after the domain
|
||||
// Pattern allows alphanumeric, hyphens, underscores, and other URL-safe chars
|
||||
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9_\-]+)/i);
|
||||
if (vivokeyMatch) {
|
||||
const code = vivokeyMatch[1];
|
||||
const url = `https://vivokey.co/${code}`;
|
||||
console.log('[DESFire] Found Spark 2 URL:', url);
|
||||
|
||||
return {
|
||||
found: true,
|
||||
name: 'Spark 2',
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[DESFire] No vivokey.co URL found');
|
||||
return {found: false};
|
||||
} catch (error) {
|
||||
console.warn('[DESFire] Spark 2 detection failed:', error);
|
||||
return {found: false};
|
||||
}
|
||||
}
|
||||
553
src/services/detection/detector.ts
Normal file
553
src/services/detection/detector.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Detection Orchestrator
|
||||
* Main entry point for chip detection using waterfall approach
|
||||
*/
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import type {RawTagData} from '../../types/nfc';
|
||||
import {
|
||||
ChipType,
|
||||
Transponder,
|
||||
DetectionResult,
|
||||
getChipFamily,
|
||||
CHIP_NAMES,
|
||||
CHIP_MEMORY_SIZES,
|
||||
CHIP_CLONEABILITY,
|
||||
} from '../../types/detection';
|
||||
import {detectNtag, mightBeNtag, detectImplantNameInMemory} from './ntag';
|
||||
import {
|
||||
detectMifareClassic,
|
||||
isMifareClassicSak,
|
||||
hasIsoDepCapability,
|
||||
detectSakSwap,
|
||||
} from './mifare';
|
||||
import {detectDesfire, detectDesfireFromAts, detectSpark2Implant, detectSpark2FromNdef} from './desfire';
|
||||
import {detectIso15693, isIso15693, detectSparkImplant} from './iso15693';
|
||||
import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard';
|
||||
|
||||
/**
|
||||
* Create a Transponder object from detection results
|
||||
*/
|
||||
function createTransponder(
|
||||
type: ChipType,
|
||||
rawData: RawTagData,
|
||||
options: {
|
||||
memorySize?: number;
|
||||
versionInfo?: Transponder['versionInfo'];
|
||||
confidence?: Transponder['confidence'];
|
||||
sakSwapInfo?: Transponder['sakSwapInfo'];
|
||||
implantName?: string;
|
||||
} = {},
|
||||
): Transponder {
|
||||
const cloneInfo = CHIP_CLONEABILITY[type];
|
||||
|
||||
// Run SAK swap detection if we have SAK
|
||||
let sakSwapInfo = options.sakSwapInfo;
|
||||
if (!sakSwapInfo && rawData.sak !== undefined) {
|
||||
sakSwapInfo = detectSakSwap(
|
||||
rawData.sak,
|
||||
rawData.atqa,
|
||||
rawData.historicalBytes,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
family: getChipFamily(type),
|
||||
chipName: CHIP_NAMES[type],
|
||||
memorySize: options.memorySize ?? CHIP_MEMORY_SIZES[type],
|
||||
isCloneable: cloneInfo.cloneable,
|
||||
cloneabilityNote: cloneInfo.note,
|
||||
rawData: {
|
||||
uid: rawData.uid,
|
||||
sak: rawData.sak,
|
||||
atqa: rawData.atqa,
|
||||
ats: rawData.ats,
|
||||
historicalBytes: rawData.historicalBytes,
|
||||
techTypes: rawData.techTypes,
|
||||
},
|
||||
versionInfo: options.versionInfo,
|
||||
sakSwapInfo,
|
||||
implantName: options.implantName,
|
||||
confidence: options.confidence ?? 'medium',
|
||||
detectedOn: Platform.OS as 'ios' | 'android',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine implant name based on detected JavaCard applets
|
||||
* - Fidesmo indicates Apex (only Apex has Fidesmo platform)
|
||||
* - JavaCard Memory indicates Apex or flexSecure (both have it)
|
||||
* - Payment applets indicate this is a payment card, not an implant
|
||||
*/
|
||||
function getJavacardImplantName(installedApplets?: string[]): string | undefined {
|
||||
if (!installedApplets || installedApplets.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Payment card detection - not an implant
|
||||
if (installedApplets.includes('Payment (PPSE)')) {
|
||||
const network = installedApplets.find(a =>
|
||||
['Visa', 'Mastercard', 'American Express', 'Discover', 'Maestro'].includes(a),
|
||||
);
|
||||
return network ? `${network} Payment Card` : 'Payment Card';
|
||||
}
|
||||
|
||||
// Fidesmo is only on Apex
|
||||
if (installedApplets.includes('Fidesmo')) {
|
||||
return 'Apex';
|
||||
}
|
||||
|
||||
// JavaCard Memory is on both Apex and flexSecure
|
||||
if (installedApplets.includes('JavaCard Memory')) {
|
||||
return 'Apex/flexSecure';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Progress callback type for detection updates */
|
||||
export type DetectionProgressCallback = (step: string) => void;
|
||||
|
||||
/**
|
||||
* Detect chip type using waterfall approach
|
||||
*
|
||||
* Detection order:
|
||||
* 1. Check for MIFARE Classic (SAK-based, quick)
|
||||
* 2. Check for NTAG (GET_VERSION command)
|
||||
* 3. Check for ISO-DEP capable chips (Phase 4: DESFire, Plus, JavaCard)
|
||||
* 4. Fall back to generic type based on technology
|
||||
*/
|
||||
export async function detectChip(
|
||||
rawData: RawTagData,
|
||||
onProgress?: DetectionProgressCallback,
|
||||
): Promise<DetectionResult> {
|
||||
try {
|
||||
const {sak, techTypes} = rawData;
|
||||
|
||||
console.log('[Detector] Starting detection with:', {
|
||||
uid: rawData.uid,
|
||||
sak: sak !== undefined ? `0x${sak.toString(16)}` : 'undefined',
|
||||
techTypes,
|
||||
hasIsoDep: techTypes.some(t => t.includes('IsoDep')),
|
||||
hasNfcA: techTypes.some(t => t.includes('NfcA')),
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// Step 1: Check for MIFARE Classic
|
||||
// Use tech type detection first (most reliable on Android), then SAK
|
||||
// ========================================================================
|
||||
onProgress?.('Checking MIFARE Classic...');
|
||||
|
||||
const hasMifareClassicTech = techTypes.some(t =>
|
||||
t.includes('MifareClassic'),
|
||||
);
|
||||
const hasIsoDepTech = techTypes.some(t => t.includes('IsoDep'));
|
||||
|
||||
// If we have MifareClassic tech type and NO IsoDep, it's definitely Classic
|
||||
if (hasMifareClassicTech && !hasIsoDepTech) {
|
||||
// Determine 1K vs 4K vs Mini
|
||||
// Priority: mifareClassic.size (most reliable on Android) > SAK > UID length
|
||||
let chipType = ChipType.MIFARE_CLASSIC_1K; // Default to 1K
|
||||
let memorySize = 1024;
|
||||
|
||||
// First, check mifareClassic object from Android (most reliable)
|
||||
if (rawData.mifareClassic?.size) {
|
||||
const size = rawData.mifareClassic.size;
|
||||
console.log('[Detector] Using mifareClassic.size:', size);
|
||||
if (size >= 4096) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_4K;
|
||||
memorySize = 4096;
|
||||
} else if (size >= 1024) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_1K;
|
||||
memorySize = 1024;
|
||||
} else if (size >= 320) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_MINI;
|
||||
memorySize = 320;
|
||||
}
|
||||
}
|
||||
// Fallback to SAK-based detection
|
||||
// SAK 0x18, 0x38, or 0x98 indicates 4K
|
||||
else if (sak === 0x18 || sak === 0x38 || sak === 0x98) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_4K;
|
||||
memorySize = 4096;
|
||||
}
|
||||
// SAK 0x09 indicates Mini
|
||||
else if (sak === 0x09) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_MINI;
|
||||
memorySize = 320;
|
||||
}
|
||||
// 7-byte UID often indicates 4K (but not always)
|
||||
else if (rawData.uid && rawData.uid.replace(/[:\s-]/g, '').length === 14) {
|
||||
// 14 hex chars = 7 bytes - could be 4K, but use SAK if available
|
||||
if (sak === undefined) {
|
||||
chipType = ChipType.MIFARE_CLASSIC_4K;
|
||||
memorySize = 4096;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(chipType, rawData, {
|
||||
memorySize,
|
||||
confidence: 'high',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// SAK-based MIFARE Classic detection (fallback, includes iOS)
|
||||
if (sak !== undefined && isMifareClassicSak(sak) && !hasIsoDepTech) {
|
||||
const result = detectMifareClassic(sak);
|
||||
if (result.success && result.chipType) {
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(result.chipType, rawData, {
|
||||
memorySize: result.memorySize,
|
||||
confidence: 'high',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 2: Check for NTAG (Type 2 tags with GET_VERSION)
|
||||
// ========================================================================
|
||||
const couldBeNtag = mightBeNtag(sak, techTypes);
|
||||
console.log('[Detector] mightBeNtag result:', couldBeNtag);
|
||||
|
||||
if (couldBeNtag) {
|
||||
onProgress?.('Reading NTAG version...');
|
||||
console.log('[Detector] Attempting NTAG detection...');
|
||||
const ntagResult = await detectNtag();
|
||||
console.log('[Detector] NTAG detection result:', {
|
||||
success: ntagResult.success,
|
||||
chipType: ntagResult.chipType,
|
||||
error: ntagResult.error,
|
||||
});
|
||||
|
||||
if (ntagResult.success && ntagResult.chipType) {
|
||||
// Try to detect implant name in last memory pages
|
||||
let implantName: string | undefined;
|
||||
try {
|
||||
onProgress?.('Checking for implant signature...');
|
||||
const implantResult = await detectImplantNameInMemory(ntagResult.chipType);
|
||||
if (implantResult.found && implantResult.name) {
|
||||
implantName = implantResult.name;
|
||||
console.log('[Detector] Found implant name in memory:', implantName);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Detector] Implant name detection failed:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ntagResult.chipType, rawData, {
|
||||
memorySize: ntagResult.memorySize,
|
||||
versionInfo: ntagResult.versionInfo,
|
||||
confidence:
|
||||
ntagResult.chipType === ChipType.NTAG_UNKNOWN ? 'medium' : 'high',
|
||||
implantName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// If GET_VERSION failed, check for MIFARE Ultralight
|
||||
// Original Ultralight doesn't support GET_VERSION (only EV1+ does)
|
||||
// SAK can be 0x00 or undefined for Ultralight
|
||||
if ((sak === 0x00 || sak === undefined) && techTypes.some(t => t.includes('NfcA'))) {
|
||||
// Check for MifareUltralight tech type (Android provides this)
|
||||
const hasMifareUltralightTech = techTypes.some(t =>
|
||||
t.includes('MifareUltralight'),
|
||||
);
|
||||
|
||||
if (hasMifareUltralightTech) {
|
||||
console.log('[Detector] MifareUltralight tech detected, identifying as original Ultralight');
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.ULTRALIGHT, rawData, {
|
||||
memorySize: 48, // Original Ultralight has 48 bytes user memory
|
||||
confidence: 'medium',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// No MifareUltralight tech - fall back to unknown NTAG
|
||||
console.log('[Detector] NTAG detection failed, falling back to NTAG_UNKNOWN');
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.NTAG_UNKNOWN, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 3: Check for ISO-DEP capable chips (DESFire, NTAG 424 DNA, JavaCard)
|
||||
// ========================================================================
|
||||
if (
|
||||
(sak !== undefined && hasIsoDepCapability(sak)) ||
|
||||
hasIsoDepTech
|
||||
) {
|
||||
// 3a: Always try DESFire/NTAG 424 DNA detection first via GET_VERSION
|
||||
// This command works on DESFire EV1/2/3, DESFire Light, NTAG 424 DNA
|
||||
onProgress?.('Reading DESFire version...');
|
||||
const desfireResult = await detectDesfire();
|
||||
if (desfireResult.success && desfireResult.chipType) {
|
||||
// Check for Spark 2 implant on NTAG 424 DNA / NTAG 413 DNA
|
||||
let implantName: string | undefined;
|
||||
const isNtagDna =
|
||||
desfireResult.chipType === ChipType.NTAG424_DNA ||
|
||||
desfireResult.chipType === ChipType.NTAG424_DNA_TT ||
|
||||
desfireResult.chipType === ChipType.NTAG413_DNA;
|
||||
|
||||
if (isNtagDna) {
|
||||
// First, try to detect from cached NDEF records (doesn't require APDU)
|
||||
// This works even after DESFire GET_VERSION puts the tag in native mode
|
||||
const cachedNdefResult = detectSpark2FromNdef(rawData.ndefRecords);
|
||||
if (cachedNdefResult.found && cachedNdefResult.name) {
|
||||
implantName = cachedNdefResult.name;
|
||||
console.log('[Detector] Found Spark 2 implant from cached NDEF:', implantName);
|
||||
} else {
|
||||
// Fallback: Try APDU-based NDEF reading (may fail if tag is in native mode)
|
||||
try {
|
||||
onProgress?.('Reading NDEF for Spark 2...');
|
||||
const spark2Result = await detectSpark2Implant();
|
||||
if (spark2Result.found && spark2Result.name) {
|
||||
implantName = spark2Result.name;
|
||||
console.log('[Detector] Found Spark 2 implant via APDU:', implantName);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Detector] Spark 2 APDU detection failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(desfireResult.chipType, rawData, {
|
||||
memorySize: desfireResult.storageSize,
|
||||
versionInfo: desfireResult.versionInfo,
|
||||
confidence:
|
||||
desfireResult.chipType === ChipType.DESFIRE_UNKNOWN
|
||||
? 'medium'
|
||||
: 'high',
|
||||
implantName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 3b: DESFire command failed - try ATS-based detection
|
||||
const desfireAtsResult = detectDesfireFromAts(
|
||||
rawData.historicalBytes,
|
||||
rawData.ats,
|
||||
sak,
|
||||
rawData.atqa,
|
||||
);
|
||||
if (desfireAtsResult.success && desfireAtsResult.chipType) {
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(desfireAtsResult.chipType, rawData, {
|
||||
memorySize: desfireAtsResult.storageSize,
|
||||
confidence: 'medium', // Lower confidence since no version command
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 3c: Try JavaCard detection (check historical bytes and CPLC)
|
||||
if (mightBeJavaCard(rawData.historicalBytes, rawData.ats)) {
|
||||
onProgress?.('Probing JavaCard applets...');
|
||||
const jcResult = await detectJavaCard();
|
||||
if (jcResult.success && jcResult.chipType) {
|
||||
// Determine implant name based on detected applets
|
||||
const implantName = getJavacardImplantName(jcResult.installedApplets);
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(jcResult.chipType, rawData, {
|
||||
confidence:
|
||||
jcResult.chipType === ChipType.JCOP4 ? 'high' : 'medium',
|
||||
implantName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// JavaCard CPLC failed - try ATS-based detection
|
||||
const jcAtsResult = detectJavaCardFromAts(
|
||||
rawData.historicalBytes,
|
||||
rawData.ats,
|
||||
);
|
||||
if (jcAtsResult.success && jcAtsResult.chipType) {
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(jcAtsResult.chipType, rawData, {
|
||||
confidence: 'medium',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3d: If DESFire and likely JavaCard checks failed, try JavaCard as general fallback
|
||||
// (some JavaCards don't have obvious historical bytes)
|
||||
onProgress?.('Probing for smartcard applets...');
|
||||
const jcFallback = await detectJavaCard();
|
||||
if (jcFallback.success && jcFallback.chipType) {
|
||||
// Determine implant name based on detected applets
|
||||
const implantName = getJavacardImplantName(jcFallback.installedApplets);
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(jcFallback.chipType, rawData, {
|
||||
confidence: 'medium',
|
||||
implantName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 3e: Last resort - try ATS-based JavaCard detection without mightBeJavaCard check
|
||||
const jcAtsFallback = detectJavaCardFromAts(
|
||||
rawData.historicalBytes,
|
||||
rawData.ats,
|
||||
);
|
||||
if (jcAtsFallback.success && jcAtsFallback.chipType) {
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(jcAtsFallback.chipType, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ISO-DEP but couldn't identify - mark as unknown ISO 14443-A
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.ISO14443A_UNKNOWN, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 4: Check for ISO 15693 (NFC-V) - SLIX and NTAG 5 detection
|
||||
// ========================================================================
|
||||
if (isIso15693(techTypes)) {
|
||||
onProgress?.('Reading ISO 15693 system info...');
|
||||
const iso15693Result = await detectIso15693();
|
||||
if (iso15693Result.success && iso15693Result.chipType) {
|
||||
// Determine confidence based on whether we got a specific chip type
|
||||
const knownTypes = [
|
||||
ChipType.SLIX,
|
||||
ChipType.SLIX2,
|
||||
ChipType.SLIX_S,
|
||||
ChipType.SLIX_L,
|
||||
ChipType.NTAG5_LINK,
|
||||
ChipType.NTAG5_BOOST,
|
||||
ChipType.NTAG5_SWITCH,
|
||||
];
|
||||
const confidence =
|
||||
iso15693Result.chipType === ChipType.ISO15693_UNKNOWN
|
||||
? 'low'
|
||||
: knownTypes.includes(iso15693Result.chipType)
|
||||
? 'high'
|
||||
: 'medium';
|
||||
|
||||
// Check for Spark implant by reading NDEF for vivokey.co URL
|
||||
let implantName: string | undefined;
|
||||
try {
|
||||
onProgress?.('Reading NDEF for Spark 1...');
|
||||
const sparkResult = await detectSparkImplant(iso15693Result.chipType);
|
||||
if (sparkResult.found && sparkResult.name) {
|
||||
implantName = sparkResult.name;
|
||||
console.log('[Detector] Found Spark implant:', implantName);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Detector] Spark detection failed:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(iso15693Result.chipType, rawData, {
|
||||
confidence,
|
||||
implantName,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to unknown ISO 15693
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.ISO15693_UNKNOWN, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Step 5: Check for ISO 14443-B
|
||||
// ========================================================================
|
||||
if (techTypes.some(t => t.includes('NfcB'))) {
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.ISO14443B_UNKNOWN, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Fallback: Unknown chip
|
||||
// ========================================================================
|
||||
return {
|
||||
success: true,
|
||||
transponder: createTransponder(ChipType.UNKNOWN, rawData, {
|
||||
confidence: 'low',
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a brief description of what was detected
|
||||
*/
|
||||
export function getDetectionSummary(transponder: Transponder): string {
|
||||
const parts: string[] = [transponder.chipName];
|
||||
|
||||
if (transponder.memorySize) {
|
||||
parts.push(`(${transponder.memorySize} bytes)`);
|
||||
}
|
||||
|
||||
if (transponder.isCloneable) {
|
||||
parts.push('- Cloneable');
|
||||
} else {
|
||||
parts.push('- Not cloneable');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can do advanced detection on this platform
|
||||
*/
|
||||
export function canDoAdvancedDetection(
|
||||
chipType: ChipType,
|
||||
): {canDetect: boolean; reason?: string} {
|
||||
// MIFARE Classic sector operations need Android
|
||||
if (
|
||||
chipType === ChipType.MIFARE_CLASSIC_1K ||
|
||||
chipType === ChipType.MIFARE_CLASSIC_4K ||
|
||||
chipType === ChipType.MIFARE_CLASSIC_MINI
|
||||
) {
|
||||
if (Platform.OS === 'ios') {
|
||||
return {
|
||||
canDetect: false,
|
||||
reason: 'MIFARE Classic sector operations require Android',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {canDetect: true};
|
||||
}
|
||||
25
src/services/detection/index.ts
Normal file
25
src/services/detection/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Detection Module
|
||||
* Re-exports all detection functionality
|
||||
*/
|
||||
|
||||
export {detectChip, getDetectionSummary, canDoAdvancedDetection} from './detector';
|
||||
export type {DetectionProgressCallback} from './detector';
|
||||
export {detectNtag, mightBeNtag, formatNtagVersionInfo, detectImplantNameInMemory} from './ntag';
|
||||
export type {ImplantNameResult} from './ntag';
|
||||
export {
|
||||
detectMifareClassic,
|
||||
isMifareClassicSak,
|
||||
hasIsoDepCapability,
|
||||
describeSak,
|
||||
detectSakSwap,
|
||||
mightBeMagicCard,
|
||||
IOS_MIFARE_CLASSIC_NOTE,
|
||||
} from './mifare';
|
||||
export type {SakSwapDetection} from './mifare';
|
||||
export {detectDesfire, mightBeDesfire, formatDesfireVersionInfo, detectSpark2Implant} from './desfire';
|
||||
export type {DesfireDetectionResult, Spark2DetectionResult} from './desfire';
|
||||
export {detectIso15693, isIso15693, getIcManufacturerName, detectSparkImplant} from './iso15693';
|
||||
export type {Iso15693DetectionResult, SparkDetectionResult} from './iso15693';
|
||||
export {detectJavaCard, mightBeJavaCard, formatCPLC, hasJavacardMemory} from './javacard';
|
||||
export type {JavaCardDetectionResult, CPLCData} from './javacard';
|
||||
851
src/services/detection/iso15693.ts
Normal file
851
src/services/detection/iso15693.ts
Normal file
@@ -0,0 +1,851 @@
|
||||
/**
|
||||
* ISO 15693 (NFC-V) Detector
|
||||
* Identifies ICODE SLIX, SLIX2, NTAG 5, and other ISO 15693 tags
|
||||
*
|
||||
* Per NXP AN11042, identification uses:
|
||||
* 1. GET_SYSTEM_INFO command (0x2B) - returns IC reference directly
|
||||
* 2. UID parsing - IC manufacturer code and reference in UID bytes
|
||||
*/
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import {ChipType} from '../../types/detection';
|
||||
import {getIso15693SystemInfo, transceiveNfcV, iso15693ReadSingleBlock} from '../nfc/commands';
|
||||
|
||||
/**
|
||||
* NXP ICODE IC manufacturer code
|
||||
*/
|
||||
const NXP_IC_MFG_CODE = 0x04;
|
||||
|
||||
/**
|
||||
* NXP ISO 15693 product family identification based on IC reference
|
||||
* The IC reference encoding varies by product family.
|
||||
*
|
||||
* Note: ISO 15693 UID is transmitted LSB first, but NFC libraries may
|
||||
* return bytes in different orders. We try multiple interpretations.
|
||||
*
|
||||
* Reference: NXP Product Short Form Specifications, AN11042
|
||||
*/
|
||||
const NXP_ISO15693_IC_REFERENCES: Record<number, ChipType> = {
|
||||
// ICODE SLIX (SL2S2002) - standard SLIX
|
||||
// IC reference values: 0x01, 0x02 (variants)
|
||||
0x01: ChipType.SLIX,
|
||||
0x02: ChipType.SLIX,
|
||||
0x03: ChipType.SLIX, // Additional variant
|
||||
|
||||
// ICODE SLIX-S (SL2S2102) - SLIX with 32-bit password protection
|
||||
0x0a: ChipType.SLIX_S,
|
||||
0x0b: ChipType.SLIX_S, // Additional variant
|
||||
|
||||
// ICODE SLIX-L (SL2S2702) - SLIX low-memory variant (512 bits)
|
||||
0x0c: ChipType.SLIX_L,
|
||||
0x0d: ChipType.SLIX_L,
|
||||
|
||||
// ICODE SLIX2 (SL2S2602) - enhanced SLIX with multiple passwords
|
||||
// IC reference range: 0x14-0x1F
|
||||
0x14: ChipType.SLIX2,
|
||||
0x15: ChipType.SLIX2,
|
||||
0x16: ChipType.SLIX2,
|
||||
0x17: ChipType.SLIX2,
|
||||
0x18: ChipType.SLIX2,
|
||||
0x19: ChipType.SLIX2,
|
||||
0x1a: ChipType.SLIX2,
|
||||
0x1b: ChipType.SLIX2,
|
||||
0x1c: ChipType.SLIX2,
|
||||
0x1d: ChipType.SLIX2,
|
||||
0x1e: ChipType.SLIX2,
|
||||
0x1f: ChipType.SLIX2,
|
||||
|
||||
// ICODE DNA (SL2S4001) - ICODE with crypto authentication
|
||||
// IC reference range per NXP: 0x24-0x27 (documented range)
|
||||
// GET_SYSTEM_INFO may return different values: observed 0x96 on real chips
|
||||
// Extended range based on real-world observations: 0x90-0xBF
|
||||
0x24: ChipType.ICODE_DNA,
|
||||
0x25: ChipType.ICODE_DNA,
|
||||
0x26: ChipType.ICODE_DNA,
|
||||
0x27: ChipType.ICODE_DNA,
|
||||
// Extended ICODE DNA range (observed in real chips via GET_SYSTEM_INFO)
|
||||
0x90: ChipType.ICODE_DNA,
|
||||
0x91: ChipType.ICODE_DNA,
|
||||
0x92: ChipType.ICODE_DNA,
|
||||
0x93: ChipType.ICODE_DNA,
|
||||
0x94: ChipType.ICODE_DNA,
|
||||
0x95: ChipType.ICODE_DNA,
|
||||
0x96: ChipType.ICODE_DNA, // Observed on ICODE DNA via GET_SYSTEM_INFO
|
||||
0x97: ChipType.ICODE_DNA,
|
||||
0x98: ChipType.ICODE_DNA,
|
||||
0x99: ChipType.ICODE_DNA,
|
||||
0x9a: ChipType.ICODE_DNA,
|
||||
0x9b: ChipType.ICODE_DNA,
|
||||
0x9c: ChipType.ICODE_DNA,
|
||||
0x9d: ChipType.ICODE_DNA,
|
||||
0x9e: ChipType.ICODE_DNA,
|
||||
0x9f: ChipType.ICODE_DNA,
|
||||
// UID-based IC reference range (0xA0-0xAF)
|
||||
0xa0: ChipType.ICODE_DNA,
|
||||
0xa1: ChipType.ICODE_DNA,
|
||||
0xa2: ChipType.ICODE_DNA, // Observed in UID of ICODE DNA
|
||||
0xa3: ChipType.ICODE_DNA,
|
||||
0xa4: ChipType.ICODE_DNA,
|
||||
0xa5: ChipType.ICODE_DNA,
|
||||
0xa6: ChipType.ICODE_DNA,
|
||||
0xa7: ChipType.ICODE_DNA,
|
||||
0xa8: ChipType.ICODE_DNA,
|
||||
0xa9: ChipType.ICODE_DNA,
|
||||
0xaa: ChipType.ICODE_DNA,
|
||||
0xab: ChipType.ICODE_DNA,
|
||||
0xac: ChipType.ICODE_DNA,
|
||||
0xad: ChipType.ICODE_DNA,
|
||||
0xae: ChipType.ICODE_DNA,
|
||||
0xaf: ChipType.ICODE_DNA,
|
||||
|
||||
// NTAG 5 link (NT3H2111/NT3H2211) - NFC+I2C bridge
|
||||
// IC reference range: 0x20-0x23
|
||||
0x20: ChipType.NTAG5_LINK,
|
||||
0x21: ChipType.NTAG5_LINK,
|
||||
0x22: ChipType.NTAG5_LINK,
|
||||
0x23: ChipType.NTAG5_LINK,
|
||||
|
||||
// NTAG 5 boost (NT3H3111/NT3H3211) - extended range, larger memory
|
||||
// IC reference range: 0x40-0x4F
|
||||
0x40: ChipType.NTAG5_BOOST,
|
||||
0x41: ChipType.NTAG5_BOOST,
|
||||
0x42: ChipType.NTAG5_BOOST,
|
||||
0x43: ChipType.NTAG5_BOOST,
|
||||
0x44: ChipType.NTAG5_BOOST,
|
||||
0x45: ChipType.NTAG5_BOOST,
|
||||
0x46: ChipType.NTAG5_BOOST,
|
||||
0x47: ChipType.NTAG5_BOOST,
|
||||
0x48: ChipType.NTAG5_BOOST,
|
||||
0x49: ChipType.NTAG5_BOOST,
|
||||
|
||||
// NTAG 5 switch (NT3H1101/NT3H1201) - energy harvesting
|
||||
// IC reference range: 0x50-0x5F
|
||||
0x50: ChipType.NTAG5_SWITCH,
|
||||
0x51: ChipType.NTAG5_SWITCH,
|
||||
0x52: ChipType.NTAG5_SWITCH,
|
||||
0x53: ChipType.NTAG5_SWITCH,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine ICODE variant from IC reference, with fallback for unknown values.
|
||||
* Some ICODE chips have non-standard IC references that don't match NXP docs.
|
||||
*/
|
||||
function getIcodeTypeFromIcReference(icRef: number): ChipType | null {
|
||||
// Check direct mapping first
|
||||
if (NXP_ISO15693_IC_REFERENCES[icRef]) {
|
||||
console.log(
|
||||
`[ISO15693] IC ref 0x${icRef.toString(16)} matched: ${NXP_ISO15693_IC_REFERENCES[icRef]}`,
|
||||
);
|
||||
return NXP_ISO15693_IC_REFERENCES[icRef];
|
||||
}
|
||||
|
||||
// Handle unknown IC references by range inference
|
||||
// Standard SLIX range: 0x01-0x09
|
||||
if (icRef >= 0x01 && icRef <= 0x09) {
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)} in SLIX range, treating as SLIX`,
|
||||
);
|
||||
return ChipType.SLIX;
|
||||
}
|
||||
|
||||
// SLIX-S/SLIX-L range: 0x0A-0x13
|
||||
if (icRef >= 0x0a && icRef <= 0x13) {
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)} in SLIX-S/L range, treating as SLIX_S`,
|
||||
);
|
||||
return ChipType.SLIX_S;
|
||||
}
|
||||
|
||||
// SLIX2/NTAG5 link range: 0x14-0x2F
|
||||
if (icRef >= 0x14 && icRef <= 0x2f) {
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)} in SLIX2/NTAG5 range, treating as SLIX2`,
|
||||
);
|
||||
return ChipType.SLIX2;
|
||||
}
|
||||
|
||||
// NTAG 5 boost range: 0x40-0x4F
|
||||
if (icRef >= 0x40 && icRef <= 0x4f) {
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)} in NTAG5 boost range`,
|
||||
);
|
||||
return ChipType.NTAG5_BOOST;
|
||||
}
|
||||
|
||||
// NTAG 5 switch range: 0x50-0x5F
|
||||
if (icRef >= 0x50 && icRef <= 0x5f) {
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)} in NTAG5 switch range`,
|
||||
);
|
||||
return ChipType.NTAG5_SWITCH;
|
||||
}
|
||||
|
||||
// ICODE DNA range: 0x90-0xBF (extended range based on real chips)
|
||||
// GET_SYSTEM_INFO returns IC ref in 0x90-0x9F range
|
||||
// UID parsing returns IC ref in 0xA0-0xAF range
|
||||
if (icRef >= 0x90 && icRef <= 0xbf) {
|
||||
console.log(
|
||||
`[ISO15693] IC ref 0x${icRef.toString(16)} in ICODE DNA range`,
|
||||
);
|
||||
return ChipType.ICODE_DNA;
|
||||
}
|
||||
|
||||
// High IC references (0x80-0x8F) - unknown, don't guess
|
||||
if (icRef >= 0x80 && icRef < 0x90) {
|
||||
console.log(
|
||||
`[ISO15693] High IC ref 0x${icRef.toString(16)} - unknown variant`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ISO15693] Unknown IC ref 0x${icRef.toString(16)}, no match`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of ISO 15693 detection
|
||||
*/
|
||||
export interface Iso15693DetectionResult {
|
||||
success: boolean;
|
||||
chipType?: ChipType;
|
||||
uid?: string;
|
||||
icManufacturer?: number;
|
||||
icReference?: number;
|
||||
blockSize?: number;
|
||||
blockCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify SLIX variant from memory size when IC reference is not available
|
||||
* Per NXP datasheets, different SLIX variants have different memory sizes:
|
||||
* - SLIX (SL2S2002): 896 bits = 112 bytes = 28 blocks × 4 bytes
|
||||
* - SLIX-S (SL2S2102): 1280 bits = 160 bytes = 40 blocks × 4 bytes
|
||||
* - SLIX-L (SL2S2702): 512 bits = 64 bytes = 16 blocks × 4 bytes
|
||||
* - SLIX2 (SL2S2602): 2528 bits = 316 bytes = 79 blocks × 4 bytes
|
||||
*/
|
||||
function identifySlixFromMemory(
|
||||
blockCount?: number,
|
||||
blockSize?: number,
|
||||
): ChipType | null {
|
||||
if (!blockCount || !blockSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalBytes = blockCount * blockSize;
|
||||
|
||||
// SLIX-L: ~64 bytes (16 blocks)
|
||||
if (totalBytes <= 80 || blockCount <= 20) {
|
||||
return ChipType.SLIX_L;
|
||||
}
|
||||
|
||||
// SLIX: ~112 bytes (28 blocks)
|
||||
if (totalBytes <= 128 || blockCount <= 32) {
|
||||
return ChipType.SLIX;
|
||||
}
|
||||
|
||||
// SLIX-S: ~160 bytes (40 blocks)
|
||||
if (totalBytes <= 200 || blockCount <= 50) {
|
||||
return ChipType.SLIX_S;
|
||||
}
|
||||
|
||||
// SLIX2: ~316 bytes (79 blocks)
|
||||
if (totalBytes <= 400 || blockCount <= 100) {
|
||||
return ChipType.SLIX2;
|
||||
}
|
||||
|
||||
// Larger memory - could be NTAG5 or other
|
||||
if (totalBytes >= 400) {
|
||||
return ChipType.NTAG5_BOOST; // Larger NXP ISO 15693 chips
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect ISO 15693 tag type
|
||||
*
|
||||
* Per NXP AN11042, identification methods:
|
||||
* 1. GET_SYSTEM_INFO - returns IC reference (if bit 3 of info flags set)
|
||||
* 2. Memory size - different SLIX variants have different capacities
|
||||
* 3. UID manufacturer code - confirms NXP origin
|
||||
*
|
||||
* Note: IC reference is OPTIONAL in GET_SYSTEM_INFO - older SLIX chips
|
||||
* may not return it. In that case, use memory size to distinguish variants.
|
||||
*/
|
||||
export async function detectIso15693(): Promise<Iso15693DetectionResult> {
|
||||
try {
|
||||
console.log('[ISO15693] Starting detection...');
|
||||
|
||||
// Get tag info for UID
|
||||
const tag = await NfcManager.getTag();
|
||||
let uid: string | undefined;
|
||||
let icManufacturer: number | undefined;
|
||||
let uidIcReference: number | undefined;
|
||||
|
||||
if (tag?.id) {
|
||||
const uidBytes =
|
||||
typeof tag.id === 'string'
|
||||
? tag.id
|
||||
.replace(/[:\s-]/g, '')
|
||||
.match(/.{1,2}/g)
|
||||
?.map(h => parseInt(h, 16)) || []
|
||||
: Array.from(tag.id as unknown as number[]);
|
||||
|
||||
uid = uidBytes
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(':');
|
||||
|
||||
console.log('[ISO15693] UID:', uid);
|
||||
console.log('[ISO15693] UID bytes:', uidBytes.map(b => `0x${b.toString(16)}`).join(', '));
|
||||
|
||||
// Parse manufacturer from UID
|
||||
const parsed = parseIso15693Uid(uidBytes);
|
||||
icManufacturer = parsed.icManufacturer;
|
||||
uidIcReference = parsed.icReference;
|
||||
|
||||
console.log('[ISO15693] From UID - Manufacturer:', icManufacturer !== undefined ? `0x${icManufacturer.toString(16)}` : 'undefined');
|
||||
console.log('[ISO15693] From UID - IC Reference:', uidIcReference !== undefined ? `0x${uidIcReference.toString(16)}` : 'undefined');
|
||||
}
|
||||
|
||||
// Try GET_SYSTEM_INFO command (NXP recommended method)
|
||||
try {
|
||||
console.log('[ISO15693] Trying GET_SYSTEM_INFO...');
|
||||
const sysInfo = await getIso15693SystemInfo();
|
||||
console.log('[ISO15693] GET_SYSTEM_INFO result:', sysInfo);
|
||||
|
||||
// If we got IC reference from GET_SYSTEM_INFO, use it (most reliable)
|
||||
if (sysInfo.icReference !== undefined && sysInfo.icReference !== 0) {
|
||||
const icReference = sysInfo.icReference;
|
||||
console.log('[ISO15693] Got IC reference from sysInfo:', `0x${icReference.toString(16)}`);
|
||||
|
||||
if (icManufacturer === NXP_IC_MFG_CODE || icManufacturer === undefined) {
|
||||
let chipType = getIcodeTypeFromIcReference(icReference) || ChipType.ISO15693_UNKNOWN;
|
||||
|
||||
// Memory-based override for NXP chips when block count is available
|
||||
// IC references are NOT reliable for distinguishing SLIX vs ICODE DNA
|
||||
// because both chip families can report IC refs in overlapping ranges.
|
||||
//
|
||||
// Memory sizes per NXP datasheets are the reliable differentiator:
|
||||
// - SLIX (SL2S2002): 32 blocks (128 bytes)
|
||||
// - SLIX-S (SL2S2102): 40 blocks (160 bytes)
|
||||
// - SLIX-L (SL2S2702): 16 blocks (64 bytes)
|
||||
// - SLIX2 (SL2S2602): 79-80 blocks (316-320 bytes)
|
||||
// - ICODE DNA: 48-64 blocks (192-256 bytes)
|
||||
//
|
||||
// Use memory size as the authoritative source for NXP chips.
|
||||
if (
|
||||
icManufacturer === NXP_IC_MFG_CODE &&
|
||||
sysInfo.blockCount !== undefined
|
||||
) {
|
||||
const blocks = sysInfo.blockCount;
|
||||
|
||||
// SLIX-L: 16 blocks
|
||||
if (blocks <= 20) {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → SLIX-L`,
|
||||
);
|
||||
chipType = ChipType.SLIX_L;
|
||||
}
|
||||
// SLIX: 32 blocks
|
||||
else if (blocks <= 36) {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → SLIX`,
|
||||
);
|
||||
chipType = ChipType.SLIX;
|
||||
}
|
||||
// SLIX-S: 40 blocks
|
||||
else if (blocks <= 44) {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → SLIX-S`,
|
||||
);
|
||||
chipType = ChipType.SLIX_S;
|
||||
}
|
||||
// ICODE DNA: 48-64 blocks
|
||||
else if (blocks <= 68) {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → ICODE DNA`,
|
||||
);
|
||||
chipType = ChipType.ICODE_DNA;
|
||||
}
|
||||
// SLIX2: 79-80 blocks
|
||||
else if (blocks <= 85) {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → SLIX2`,
|
||||
);
|
||||
chipType = ChipType.SLIX2;
|
||||
}
|
||||
// Larger memory - NTAG5 variants
|
||||
else {
|
||||
console.log(
|
||||
`[ISO15693] NXP chip with ${blocks} blocks → NTAG5 (large memory)`,
|
||||
);
|
||||
chipType = ChipType.NTAG5_BOOST;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
uid,
|
||||
icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE,
|
||||
icReference,
|
||||
blockSize: sysInfo.blockSize,
|
||||
blockCount: sysInfo.blockCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference,
|
||||
blockSize: sysInfo.blockSize,
|
||||
blockCount: sysInfo.blockCount,
|
||||
};
|
||||
}
|
||||
|
||||
// IC reference not in GET_SYSTEM_INFO response (common for older SLIX)
|
||||
// Use memory size to identify the chip variant
|
||||
if (icManufacturer === NXP_IC_MFG_CODE || icManufacturer === undefined) {
|
||||
// For NXP chips, try to identify SLIX variant from memory size
|
||||
const chipFromMemory = identifySlixFromMemory(
|
||||
sysInfo.blockCount,
|
||||
sysInfo.blockSize,
|
||||
);
|
||||
|
||||
if (chipFromMemory) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: chipFromMemory,
|
||||
uid,
|
||||
icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE,
|
||||
icReference: uidIcReference,
|
||||
blockSize: sysInfo.blockSize,
|
||||
blockCount: sysInfo.blockCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Got manufacturer NXP but couldn't determine exact variant
|
||||
// Don't assume SLIX - return unknown with NXP manufacturer info
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE,
|
||||
blockSize: sysInfo.blockSize,
|
||||
blockCount: sysInfo.blockCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-NXP chip with system info
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer,
|
||||
blockSize: sysInfo.blockSize,
|
||||
blockCount: sysInfo.blockCount,
|
||||
};
|
||||
} catch (sysInfoError) {
|
||||
// GET_SYSTEM_INFO failed
|
||||
console.warn('[ISO15693] GET_SYSTEM_INFO failed:', sysInfoError);
|
||||
}
|
||||
|
||||
// Fall back: Use UID-based detection
|
||||
// If we know it's NXP, identify the ICODE variant
|
||||
console.log('[ISO15693] Falling back to UID-based detection');
|
||||
if (icManufacturer === NXP_IC_MFG_CODE) {
|
||||
console.log('[ISO15693] NXP manufacturer detected');
|
||||
// Check if we got IC reference from UID parsing
|
||||
if (uidIcReference !== undefined) {
|
||||
const chipType = getIcodeTypeFromIcReference(uidIcReference);
|
||||
if (chipType) {
|
||||
console.log('[ISO15693] UID-based detection result:', chipType);
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference: uidIcReference,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// NXP but couldn't determine exact variant - return unknown with NXP info
|
||||
console.log('[ISO15693] NXP but could not determine specific chip type');
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference: uidIcReference,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to platform-specific detection using UID
|
||||
if (Platform.OS === 'android') {
|
||||
return await detectIso15693Android();
|
||||
}
|
||||
|
||||
return await detectIso15693IOS();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `ISO 15693 detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ISO 15693 UID to extract manufacturer and IC reference
|
||||
* The UID is 8 bytes and may be in MSB or LSB first order.
|
||||
*
|
||||
* MSB first (standard): E0:MFG:ICRef:Serial[5]
|
||||
* LSB first (common in NFC reads): Serial[5]:ICRef:MFG:E0
|
||||
*/
|
||||
function parseIso15693Uid(uidBytes: number[]): {
|
||||
icManufacturer?: number;
|
||||
icReference?: number;
|
||||
} {
|
||||
if (uidBytes.length < 8) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Check for MSB first order (E0 at position 0)
|
||||
if (uidBytes[0] === 0xe0) {
|
||||
return {
|
||||
icManufacturer: uidBytes[1],
|
||||
icReference: uidBytes[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for LSB first order (E0 at position 7)
|
||||
if (uidBytes[7] === 0xe0) {
|
||||
return {
|
||||
icManufacturer: uidBytes[6],
|
||||
icReference: uidBytes[5],
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find E0 anywhere and infer order
|
||||
const e0Index = uidBytes.indexOf(0xe0);
|
||||
if (e0Index === -1) {
|
||||
// No E0 found - unusual, try positions anyway
|
||||
// Some readers strip E0 or return partial UIDs
|
||||
// Try treating first two bytes as mfg and icref
|
||||
return {
|
||||
icManufacturer: uidBytes[0],
|
||||
icReference: uidBytes[1],
|
||||
};
|
||||
}
|
||||
|
||||
// E0 found at unexpected position - infer based on where it is
|
||||
if (e0Index < 4) {
|
||||
// E0 near start, likely MSB first
|
||||
return {
|
||||
icManufacturer: uidBytes[e0Index + 1],
|
||||
icReference: uidBytes[e0Index + 2],
|
||||
};
|
||||
} else {
|
||||
// E0 near end, likely LSB first
|
||||
return {
|
||||
icManufacturer: uidBytes[e0Index - 1],
|
||||
icReference: uidBytes[e0Index - 2],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android-specific ISO 15693 detection
|
||||
*/
|
||||
async function detectIso15693Android(): Promise<Iso15693DetectionResult> {
|
||||
try {
|
||||
// Try to get tag info - the NfcV handler provides some info
|
||||
const tag = await NfcManager.getTag();
|
||||
|
||||
if (!tag) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No tag available',
|
||||
};
|
||||
}
|
||||
|
||||
// Extract UID from tag
|
||||
const uid = tag.id
|
||||
? Array.from(tag.id as unknown as number[])
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(':')
|
||||
: undefined;
|
||||
|
||||
// Parse UID to extract manufacturer and IC reference
|
||||
if (uid && tag.id) {
|
||||
const uidBytes = Array.from(tag.id as unknown as number[]);
|
||||
const {icManufacturer, icReference} = parseIso15693Uid(uidBytes);
|
||||
|
||||
if (icManufacturer !== undefined) {
|
||||
// For NXP chips, identify specific variant
|
||||
if (icManufacturer === NXP_IC_MFG_CODE && icReference !== undefined) {
|
||||
const chipType = getIcodeTypeFromIcReference(icReference) || ChipType.ISO15693_UNKNOWN;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-NXP ISO 15693 tag
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback - we know it's ISO 15693 but can't identify further
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Android ISO 15693 detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-specific ISO 15693 detection
|
||||
*/
|
||||
async function detectIso15693IOS(): Promise<Iso15693DetectionResult> {
|
||||
try {
|
||||
// On iOS, we have limited access to ISO 15693 tags
|
||||
// CoreNFC provides basic tag info but detailed commands may be restricted
|
||||
const tag = await NfcManager.getTag();
|
||||
|
||||
if (!tag) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No tag available',
|
||||
};
|
||||
}
|
||||
|
||||
// Extract UID - iOS may return as string or array
|
||||
let uidBytes: number[] = [];
|
||||
let uid: string | undefined;
|
||||
|
||||
if (tag.id) {
|
||||
if (typeof tag.id === 'string') {
|
||||
// Parse hex string to bytes
|
||||
const uidClean = tag.id.replace(/[:\s-]/g, '');
|
||||
uid = uidClean
|
||||
.match(/.{1,2}/g)
|
||||
?.map(s => s.toUpperCase())
|
||||
.join(':');
|
||||
for (let i = 0; i < uidClean.length; i += 2) {
|
||||
uidBytes.push(parseInt(uidClean.substring(i, i + 2), 16));
|
||||
}
|
||||
} else {
|
||||
uidBytes = Array.from(tag.id as unknown as number[]);
|
||||
uid = uidBytes
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(':');
|
||||
}
|
||||
}
|
||||
|
||||
// Parse UID to extract manufacturer and IC reference
|
||||
if (uidBytes.length >= 8) {
|
||||
const {icManufacturer, icReference} = parseIso15693Uid(uidBytes);
|
||||
|
||||
if (icManufacturer !== undefined) {
|
||||
if (icManufacturer === NXP_IC_MFG_CODE && icReference !== undefined) {
|
||||
const chipType = getIcodeTypeFromIcReference(icReference) || ChipType.ISO15693_UNKNOWN;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
icManufacturer,
|
||||
icReference,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ISO15693_UNKNOWN,
|
||||
uid,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `iOS ISO 15693 detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag is ISO 15693 based on tech types
|
||||
*/
|
||||
export function isIso15693(techTypes: string[]): boolean {
|
||||
return techTypes.some(
|
||||
t => t.includes('NfcV') || t.includes('ISO15693') || t.includes('Iso15693'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable name for IC manufacturer
|
||||
*/
|
||||
export function getIcManufacturerName(code: number): string {
|
||||
const manufacturers: Record<number, string> = {
|
||||
0x01: 'Motorola',
|
||||
0x02: 'STMicroelectronics',
|
||||
0x03: 'Hitachi',
|
||||
0x04: 'NXP Semiconductors',
|
||||
0x05: 'Infineon',
|
||||
0x06: 'Cylink',
|
||||
0x07: 'Texas Instruments',
|
||||
0x08: 'Fujitsu',
|
||||
0x09: 'Matsushita',
|
||||
0x0a: 'NEC',
|
||||
0x0b: 'Oki Electric',
|
||||
0x0c: 'Toshiba',
|
||||
0x0d: 'Mitsubishi',
|
||||
0x0e: 'Samsung',
|
||||
0x0f: 'Hyundai',
|
||||
0x10: 'LG Semiconductors',
|
||||
};
|
||||
|
||||
return manufacturers[code] || `Unknown (0x${code.toString(16)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of Spark implant detection
|
||||
*/
|
||||
export interface SparkDetectionResult {
|
||||
found: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Spark implant by reading NDEF from ISO 15693 tag
|
||||
* Spark implants have a URI record pointing to vivokey.co/$code
|
||||
*
|
||||
* @param chipType - The detected chip type (used to determine Spark 1 vs 2)
|
||||
*/
|
||||
export async function detectSparkImplant(
|
||||
chipType: ChipType,
|
||||
): Promise<SparkDetectionResult> {
|
||||
try {
|
||||
console.log('[ISO15693] Checking for Spark implant NDEF...');
|
||||
|
||||
// Read first few blocks to find NDEF data
|
||||
// ISO 15693 NDEF layout: Block 0 = CC (Capability Container), Blocks 1+ = NDEF message
|
||||
const ndefBytes: number[] = [];
|
||||
|
||||
// Read blocks 0-7 (typically enough for a short NDEF URI)
|
||||
for (let block = 0; block < 8; block++) {
|
||||
try {
|
||||
const response = await transceiveNfcV(iso15693ReadSingleBlock(block));
|
||||
// Response format: [flags] [data...]
|
||||
// Skip first byte (flags) if present
|
||||
if (response.length > 1 && (response[0] & 0x01) === 0) {
|
||||
// No error, data follows
|
||||
ndefBytes.push(...response.slice(1));
|
||||
} else if (response.length >= 4) {
|
||||
// Some readers don't include flags
|
||||
ndefBytes.push(...response);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[ISO15693] Block ${block} read failed:`, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ndefBytes.length === 0) {
|
||||
console.log('[ISO15693] No NDEF data read');
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[ISO15693] Read NDEF bytes:',
|
||||
ndefBytes.map(b => b.toString(16).padStart(2, '0')).join(' '),
|
||||
);
|
||||
|
||||
// Convert to ASCII and look for vivokey.co pattern
|
||||
const asciiStr = ndefBytes
|
||||
.filter(b => b >= 0x20 && b <= 0x7e)
|
||||
.map(b => String.fromCharCode(b))
|
||||
.join('');
|
||||
|
||||
console.log('[ISO15693] ASCII content:', asciiStr);
|
||||
|
||||
// Check for vivokey.co URL pattern
|
||||
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9]+)/i);
|
||||
if (vivokeyMatch) {
|
||||
const code = vivokeyMatch[1];
|
||||
const url = `https://vivokey.co/${code}`;
|
||||
console.log('[ISO15693] Found Spark URL:', url);
|
||||
|
||||
// Determine Spark version based on chip type
|
||||
// SLIX/ICODE DNA = Spark 1 (ISO 15693)
|
||||
// NTAG 424 DNA = Spark 2 (ISO 14443-4, handled in desfire.ts)
|
||||
let sparkName: string;
|
||||
if (
|
||||
chipType === ChipType.SLIX ||
|
||||
chipType === ChipType.SLIX_S ||
|
||||
chipType === ChipType.SLIX_L ||
|
||||
chipType === ChipType.SLIX2 ||
|
||||
chipType === ChipType.ICODE_DNA
|
||||
) {
|
||||
sparkName = 'Spark 1';
|
||||
} else {
|
||||
// Any other ISO 15693 chip with vivokey.co URL is likely a Spark variant
|
||||
sparkName = 'Spark 1';
|
||||
}
|
||||
|
||||
return {
|
||||
found: true,
|
||||
name: sparkName,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[ISO15693] No vivokey.co URL found');
|
||||
return {found: false};
|
||||
} catch (error) {
|
||||
console.warn('[ISO15693] Spark detection failed:', error);
|
||||
return {found: false};
|
||||
}
|
||||
}
|
||||
489
src/services/detection/javacard.ts
Normal file
489
src/services/detection/javacard.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* JavaCard/JCOP Detector
|
||||
* Identifies JavaCard chips using CPLC (Card Production Life Cycle) data
|
||||
* and AID probing
|
||||
*/
|
||||
|
||||
import {ChipType} from '../../types/detection';
|
||||
import {
|
||||
GET_CPLC,
|
||||
selectAid,
|
||||
KNOWN_AIDS,
|
||||
sendIsoDepCommand,
|
||||
parseApduResponse,
|
||||
} from '../nfc/commands';
|
||||
|
||||
/**
|
||||
* CPLC (Card Production Life Cycle) data structure
|
||||
*/
|
||||
export interface CPLCData {
|
||||
icFabricator: number;
|
||||
icType: number;
|
||||
osId: number;
|
||||
osBuildDate: number;
|
||||
icFabricationDate: number;
|
||||
icSerialNumber: number;
|
||||
icBatchIdentifier: number;
|
||||
icModulePackager: number;
|
||||
installerIdentifier: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known IC Fabricator codes
|
||||
*/
|
||||
const IC_FABRICATORS: Record<number, string> = {
|
||||
0x4790: 'NXP Semiconductors',
|
||||
0x4180: 'Atmel',
|
||||
0x4090: 'Infineon',
|
||||
0x3060: 'Renesas',
|
||||
0x4250: 'Samsung',
|
||||
0x3360: 'STMicroelectronics',
|
||||
};
|
||||
|
||||
/**
|
||||
* Known JCOP versions based on OS ID patterns
|
||||
*/
|
||||
const JCOP_OS_PATTERNS: Array<{pattern: number; mask: number; name: string}> = [
|
||||
{pattern: 0x4791, mask: 0xffff, name: 'JCOP4 J3R180'},
|
||||
{pattern: 0x4700, mask: 0xff00, name: 'JCOP4'},
|
||||
{pattern: 0x4680, mask: 0xff80, name: 'JCOP3'},
|
||||
{pattern: 0x4600, mask: 0xff00, name: 'JCOP2.x'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of JavaCard detection
|
||||
*/
|
||||
export interface JavaCardDetectionResult {
|
||||
success: boolean;
|
||||
chipType?: ChipType;
|
||||
cplc?: CPLCData;
|
||||
fabricatorName?: string;
|
||||
osName?: string;
|
||||
installedApplets?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CPLC response into structured data
|
||||
*/
|
||||
function parseCPLC(data: number[]): CPLCData | null {
|
||||
// CPLC is 42 bytes (sometimes with tag 9F7F prefix)
|
||||
let cplcData = data;
|
||||
|
||||
// Remove tag if present
|
||||
if (data[0] === 0x9f && data[1] === 0x7f) {
|
||||
cplcData = data.slice(3); // Skip 9F 7F length
|
||||
}
|
||||
|
||||
if (cplcData.length < 42) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icFabricator: (cplcData[0] << 8) | cplcData[1],
|
||||
icType: (cplcData[2] << 8) | cplcData[3],
|
||||
osId: (cplcData[4] << 8) | cplcData[5],
|
||||
osBuildDate: (cplcData[6] << 8) | cplcData[7],
|
||||
icFabricationDate: (cplcData[8] << 8) | cplcData[9],
|
||||
icSerialNumber:
|
||||
(cplcData[10] << 24) |
|
||||
(cplcData[11] << 16) |
|
||||
(cplcData[12] << 8) |
|
||||
cplcData[13],
|
||||
icBatchIdentifier: (cplcData[14] << 8) | cplcData[15],
|
||||
icModulePackager: (cplcData[16] << 8) | cplcData[17],
|
||||
installerIdentifier: (cplcData[18] << 8) | cplcData[19],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify JCOP version from OS ID
|
||||
*/
|
||||
function identifyJcopVersion(osId: number): string | null {
|
||||
for (const pattern of JCOP_OS_PATTERNS) {
|
||||
if ((osId & pattern.mask) === pattern.pattern) {
|
||||
return pattern.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect JavaCard/JCOP chip using CPLC
|
||||
*/
|
||||
export async function detectJavaCard(): Promise<JavaCardDetectionResult> {
|
||||
try {
|
||||
// First, try to select the Card Manager (ISD)
|
||||
// Card Manager selection might fail, but we can still try CPLC
|
||||
// Some cards allow CPLC without selecting an applet
|
||||
let cardManagerSelected = false;
|
||||
try {
|
||||
const cmResponse = await sendIsoDepCommand(
|
||||
selectAid(KNOWN_AIDS.cardManager),
|
||||
);
|
||||
const cmParsed = parseApduResponse(cmResponse);
|
||||
cardManagerSelected = cmParsed.isSuccess;
|
||||
} catch {
|
||||
// Ignore - some cards don't support Card Manager selection
|
||||
}
|
||||
|
||||
// Get CPLC data
|
||||
let cplc: CPLCData | null = null;
|
||||
let fabricatorName: string | undefined;
|
||||
let osName: string | undefined;
|
||||
|
||||
if (cardManagerSelected) {
|
||||
try {
|
||||
const cplcResponse = await sendIsoDepCommand(GET_CPLC);
|
||||
const cplcParsed = parseApduResponse(cplcResponse);
|
||||
|
||||
if (cplcParsed.isSuccess && cplcParsed.data.length >= 20) {
|
||||
cplc = parseCPLC(cplcParsed.data);
|
||||
if (cplc) {
|
||||
fabricatorName =
|
||||
IC_FABRICATORS[cplc.icFabricator] ||
|
||||
`Unknown (0x${cplc.icFabricator.toString(16)})`;
|
||||
osName = identifyJcopVersion(cplc.osId) || undefined;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CPLC failed - will try AID probing
|
||||
}
|
||||
}
|
||||
|
||||
// Probe for installed applets (this also helps identify DT implants)
|
||||
const installedApplets = await probeApplets();
|
||||
|
||||
// Determine chip type
|
||||
let chipType: ChipType = ChipType.JAVACARD_UNKNOWN;
|
||||
|
||||
// If we have CPLC and it's NXP JCOP4
|
||||
if (cplc && cplc.icFabricator === 0x4790 && osName?.includes('JCOP4')) {
|
||||
chipType = ChipType.JCOP4;
|
||||
}
|
||||
// If we found JavaCard Memory, it's definitely a JCOP4 (Apex/flexSecure)
|
||||
else if (installedApplets.includes('JavaCard Memory')) {
|
||||
chipType = ChipType.JCOP4;
|
||||
if (!osName) {
|
||||
osName = 'JCOP4 (VivoKey)';
|
||||
}
|
||||
if (!fabricatorName) {
|
||||
fabricatorName = 'NXP Semiconductors';
|
||||
}
|
||||
}
|
||||
// If Fidesmo is present, likely JCOP4
|
||||
else if (installedApplets.includes('Fidesmo') && !cplc) {
|
||||
chipType = ChipType.JCOP4;
|
||||
if (!osName) {
|
||||
osName = 'JCOP4 (Fidesmo)';
|
||||
}
|
||||
}
|
||||
|
||||
// If no identification was successful at all
|
||||
if (
|
||||
chipType === ChipType.JAVACARD_UNKNOWN &&
|
||||
!cplc &&
|
||||
installedApplets.length === 0
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Could not identify JavaCard - CPLC unavailable and no known applets found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
cplc: cplc || undefined,
|
||||
fabricatorName,
|
||||
osName: osName || (cplc ? `Unknown OS (0x${cplc.osId.toString(16)})` : 'Unknown'),
|
||||
installedApplets,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `JavaCard detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for common applets
|
||||
*/
|
||||
async function probeApplets(): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
|
||||
// Try JavaCard Memory Manager (indicates Apex or flexSecure)
|
||||
try {
|
||||
const response = await sendIsoDepCommand(
|
||||
selectAid(KNOWN_AIDS.javacardMemory),
|
||||
);
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('JavaCard Memory');
|
||||
}
|
||||
} catch {
|
||||
// Applet not present
|
||||
}
|
||||
|
||||
// Try OpenPGP applet
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.openPgp));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('OpenPGP');
|
||||
}
|
||||
} catch {
|
||||
// Applet not present
|
||||
}
|
||||
|
||||
// Try FIDO/U2F applet
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.fido));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('FIDO/U2F');
|
||||
}
|
||||
} catch {
|
||||
// Applet not present
|
||||
}
|
||||
|
||||
// Try Fidesmo service discovery
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.fidesmo));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Fidesmo');
|
||||
}
|
||||
} catch {
|
||||
// Applet not present
|
||||
}
|
||||
|
||||
// Try PPSE (Proximity Payment System Environment) - indicates contactless payment card
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ppse));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Payment (PPSE)');
|
||||
// If PPSE is present, try to identify specific payment network
|
||||
await probePaymentNetworks(found);
|
||||
}
|
||||
} catch {
|
||||
// Not a payment card
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe for specific payment network applets
|
||||
* Only called if PPSE is present (i.e., this is a payment card)
|
||||
*/
|
||||
async function probePaymentNetworks(found: string[]): Promise<void> {
|
||||
// Try Visa
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.visaCredit));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Visa');
|
||||
return; // Found payment network, no need to check others
|
||||
}
|
||||
} catch {
|
||||
// Not present
|
||||
}
|
||||
|
||||
// Try Mastercard
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.mastercard));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Mastercard');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not present
|
||||
}
|
||||
|
||||
// Try Amex
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.amex));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('American Express');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not present
|
||||
}
|
||||
|
||||
// Try Discover
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.discover));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Discover');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not present
|
||||
}
|
||||
|
||||
// Try Maestro
|
||||
try {
|
||||
const response = await sendIsoDepCommand(selectAid(KNOWN_AIDS.maestro));
|
||||
const parsed = parseApduResponse(response);
|
||||
if (parsed.isSuccess) {
|
||||
found.push('Maestro');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not present
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the JavaCard Memory Manager applet is present
|
||||
* This indicates an Apex or flexSecure implant
|
||||
*/
|
||||
export async function hasJavacardMemory(): Promise<boolean> {
|
||||
try {
|
||||
const response = await sendIsoDepCommand(
|
||||
selectAid(KNOWN_AIDS.javacardMemory),
|
||||
);
|
||||
const parsed = parseApduResponse(response);
|
||||
return parsed.isSuccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag might be a JavaCard based on ATS/historical bytes
|
||||
*/
|
||||
export function mightBeJavaCard(
|
||||
historicalBytes?: string,
|
||||
ats?: string,
|
||||
): boolean {
|
||||
if (!historicalBytes && !ats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checkStr = (historicalBytes || ats || '').toUpperCase();
|
||||
|
||||
// Look for JCOP signatures in historical bytes
|
||||
// "4A434F50" = "JCOP" in ASCII
|
||||
if (checkStr.includes('4A:43:4F:50') || checkStr.includes('4A434F50')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// NXP SmartMX patterns
|
||||
if (checkStr.includes('80:31') || checkStr.includes('80:71')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for typical JavaCard ATS patterns
|
||||
// T0=78 indicates lots of historical bytes (common in JavaCards)
|
||||
if (ats && ats.startsWith('78')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect JavaCard from ATS/historical bytes when CPLC fails
|
||||
* This is useful for iOS where commands may not work reliably
|
||||
*/
|
||||
export function detectJavaCardFromAts(
|
||||
historicalBytes?: string,
|
||||
ats?: string,
|
||||
): JavaCardDetectionResult {
|
||||
if (!historicalBytes && !ats) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No ATS/historical bytes available',
|
||||
};
|
||||
}
|
||||
|
||||
const checkStr = (historicalBytes || ats || '').toUpperCase();
|
||||
const cleanStr = checkStr.replace(/[:\s-]/g, '');
|
||||
|
||||
// Look for JCOP signatures in historical bytes
|
||||
// "4A434F50" = "JCOP" in ASCII
|
||||
if (cleanStr.includes('4A434F50')) {
|
||||
// Try to determine JCOP version from surrounding bytes
|
||||
const jcopIndex = cleanStr.indexOf('4A434F50');
|
||||
const afterJcop = cleanStr.substring(jcopIndex + 8);
|
||||
|
||||
// JCOP4 typically has version info after "JCOP"
|
||||
if (afterJcop.startsWith('34') || afterJcop.includes('4A33')) {
|
||||
// '34' = '4' in ASCII, or J3 pattern
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.JCOP4,
|
||||
osName: 'JCOP4 (from ATS)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.JAVACARD_UNKNOWN,
|
||||
osName: 'JCOP (version unknown)',
|
||||
};
|
||||
}
|
||||
|
||||
// NXP SmartMX patterns (common in JCOP cards)
|
||||
if (cleanStr.includes('8031') || cleanStr.includes('8071')) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.JAVACARD_UNKNOWN,
|
||||
osName: 'NXP SmartMX (likely JCOP)',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for JavaCard capability indicators in historical bytes
|
||||
// Category indicator 0x80 followed by card capabilities
|
||||
const histBytes = cleanStr.match(/.{1,2}/g)?.map(h => parseInt(h, 16)) || [];
|
||||
|
||||
if (histBytes.length >= 3) {
|
||||
// Check for category indicator 0x80 (status indicator)
|
||||
if (histBytes[0] === 0x80) {
|
||||
// Check compact-TLV data objects
|
||||
// 0x31 = card capabilities, 0x71 = card service data
|
||||
if (histBytes[1] === 0x31 || histBytes[1] === 0x71) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.JAVACARD_UNKNOWN,
|
||||
osName: 'JavaCard (from ATS capabilities)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for initial selection indicator 0x00 (typically JavaCards)
|
||||
// followed by application identifier presence
|
||||
if (histBytes[0] === 0x00 && histBytes.length >= 5) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.JAVACARD_UNKNOWN,
|
||||
osName: 'Possible JavaCard (from ATS)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Could not identify JavaCard from ATS',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CPLC data for display
|
||||
*/
|
||||
export function formatCPLC(cplc: CPLCData): string {
|
||||
const fabricator =
|
||||
IC_FABRICATORS[cplc.icFabricator] || `0x${cplc.icFabricator.toString(16)}`;
|
||||
return `Fabricator: ${fabricator}, OS: 0x${cplc.osId.toString(16)}`;
|
||||
}
|
||||
387
src/services/detection/mifare.ts
Normal file
387
src/services/detection/mifare.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* MIFARE Detector
|
||||
* Identifies MIFARE Classic 1K/4K/Mini based on SAK values
|
||||
* Also provides stubs for DESFire and Plus detection (Phase 4)
|
||||
*/
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {ChipType, CHIP_MEMORY_SIZES} from '../../types/detection';
|
||||
|
||||
/**
|
||||
* SAK (Select Acknowledge) values for MIFARE chips
|
||||
*
|
||||
* SAK is returned during ISO 14443-3A anticollision and indicates card capabilities
|
||||
*/
|
||||
const MIFARE_SAK_VALUES = {
|
||||
// MIFARE Classic 1K variants
|
||||
CLASSIC_1K: 0x08,
|
||||
CLASSIC_1K_SMARTMX: 0x28, // Classic 1K emulation on SmartMX
|
||||
CLASSIC_1K_INFINEON: 0x88, // Infineon variant
|
||||
|
||||
// MIFARE Classic 4K variants
|
||||
CLASSIC_4K: 0x18,
|
||||
CLASSIC_4K_SMARTMX: 0x38, // Classic 4K emulation on SmartMX
|
||||
CLASSIC_4K_INFINEON: 0x98, // Infineon variant
|
||||
|
||||
// MIFARE Classic 2K (rare)
|
||||
CLASSIC_2K: 0x19,
|
||||
|
||||
// MIFARE Classic Mini
|
||||
CLASSIC_MINI: 0x09,
|
||||
|
||||
// MIFARE Classic 1K with UID changeable (magic cards often)
|
||||
CLASSIC_1K_UID_CHANGEABLE: 0x01,
|
||||
|
||||
// Cards with ISO 14443-4 support (bit 5 set)
|
||||
// These might be DESFire, Plus, or SmartMX
|
||||
ISO_DEP_CAPABLE: 0x20,
|
||||
} as const;
|
||||
|
||||
// All SAK values that indicate MIFARE Classic
|
||||
const ALL_CLASSIC_1K_SAKS: number[] = [
|
||||
MIFARE_SAK_VALUES.CLASSIC_1K,
|
||||
MIFARE_SAK_VALUES.CLASSIC_1K_SMARTMX,
|
||||
MIFARE_SAK_VALUES.CLASSIC_1K_INFINEON,
|
||||
MIFARE_SAK_VALUES.CLASSIC_1K_UID_CHANGEABLE,
|
||||
];
|
||||
|
||||
const ALL_CLASSIC_4K_SAKS: number[] = [
|
||||
MIFARE_SAK_VALUES.CLASSIC_4K,
|
||||
MIFARE_SAK_VALUES.CLASSIC_4K_SMARTMX,
|
||||
MIFARE_SAK_VALUES.CLASSIC_4K_INFINEON,
|
||||
MIFARE_SAK_VALUES.CLASSIC_2K, // 2K treated as 4K variant
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of MIFARE Classic detection
|
||||
*/
|
||||
export interface MifareClassicDetectionResult {
|
||||
success: boolean;
|
||||
chipType?: ChipType;
|
||||
memorySize?: number;
|
||||
sectorCount?: number;
|
||||
blockCount?: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIFARE Classic variant from SAK value
|
||||
*/
|
||||
export function detectMifareClassic(sak: number): MifareClassicDetectionResult {
|
||||
// Check for MIFARE Classic 1K variants
|
||||
if (ALL_CLASSIC_1K_SAKS.includes(sak)) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.MIFARE_CLASSIC_1K,
|
||||
memorySize: CHIP_MEMORY_SIZES[ChipType.MIFARE_CLASSIC_1K],
|
||||
sectorCount: 16,
|
||||
blockCount: 64,
|
||||
note:
|
||||
Platform.OS === 'ios'
|
||||
? 'Sector operations require Android'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for MIFARE Classic 4K variants
|
||||
if (ALL_CLASSIC_4K_SAKS.includes(sak)) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.MIFARE_CLASSIC_4K,
|
||||
memorySize: CHIP_MEMORY_SIZES[ChipType.MIFARE_CLASSIC_4K],
|
||||
sectorCount: 40, // 32 small sectors + 8 large sectors
|
||||
blockCount: 256,
|
||||
note:
|
||||
Platform.OS === 'ios'
|
||||
? 'Sector operations require Android'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for MIFARE Classic Mini (SAK 0x09)
|
||||
if (sak === MIFARE_SAK_VALUES.CLASSIC_MINI) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.MIFARE_CLASSIC_MINI,
|
||||
memorySize: CHIP_MEMORY_SIZES[ChipType.MIFARE_CLASSIC_MINI],
|
||||
sectorCount: 5,
|
||||
blockCount: 20,
|
||||
note:
|
||||
Platform.OS === 'ios'
|
||||
? 'Sector operations require Android'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SAK indicates a MIFARE Classic chip
|
||||
*/
|
||||
export function isMifareClassicSak(sak: number): boolean {
|
||||
return (
|
||||
ALL_CLASSIC_1K_SAKS.includes(sak) ||
|
||||
ALL_CLASSIC_4K_SAKS.includes(sak) ||
|
||||
sak === MIFARE_SAK_VALUES.CLASSIC_MINI
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SAK indicates ISO 14443-4 (ISO-DEP) capability
|
||||
* This means the chip might be DESFire, Plus, or SmartMX
|
||||
*/
|
||||
export function hasIsoDepCapability(sak: number): boolean {
|
||||
// Bit 5 (0x20) indicates ISO 14443-4 compliance
|
||||
return (sak & 0x20) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of SAK value
|
||||
*/
|
||||
export function describeSak(sak: number): string {
|
||||
if (sak === MIFARE_SAK_VALUES.CLASSIC_1K) {
|
||||
return 'MIFARE Classic 1K';
|
||||
}
|
||||
if (sak === MIFARE_SAK_VALUES.CLASSIC_4K) {
|
||||
return 'MIFARE Classic 4K';
|
||||
}
|
||||
if (sak === MIFARE_SAK_VALUES.CLASSIC_2K) {
|
||||
return 'MIFARE Classic 2K';
|
||||
}
|
||||
if (sak === MIFARE_SAK_VALUES.CLASSIC_MINI) {
|
||||
return 'MIFARE Classic Mini';
|
||||
}
|
||||
if (sak === 0x00) {
|
||||
return 'Type 2 Tag (NTAG/Ultralight)';
|
||||
}
|
||||
if (hasIsoDepCapability(sak)) {
|
||||
return 'ISO 14443-4 capable (DESFire/Plus/SmartMX)';
|
||||
}
|
||||
return `Unknown (SAK: 0x${sak.toString(16).padStart(2, '0')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS MIFARE Classic limitation info
|
||||
*/
|
||||
export const IOS_MIFARE_CLASSIC_NOTE =
|
||||
'iOS can detect MIFARE Classic but cannot perform sector-level operations. ' +
|
||||
'For cloning or data extraction, an Android device is required.';
|
||||
|
||||
// ============================================================================
|
||||
// SAK Swap Detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SAK values that indicate potential SAK swap capability
|
||||
*
|
||||
* SAK swap refers to chips that can operate in multiple modes:
|
||||
* - MIFARE Plus in SL1 emulates Classic but can switch to SL3
|
||||
* - Some magic/clone cards have mutable SAK values
|
||||
* - DESFire cards with MIFARE Classic emulation
|
||||
*/
|
||||
const SAK_SWAP_INDICATORS = {
|
||||
// MIFARE Plus SL1 (emulating Classic 1K but can upgrade)
|
||||
PLUS_SL1_2K: 0x08, // Same as Classic 1K but actually Plus
|
||||
PLUS_SL1_4K: 0x18, // Same as Classic 4K but actually Plus
|
||||
|
||||
// MIFARE Plus SL2/SL3 (ISO-DEP mode)
|
||||
PLUS_SL2_2K: 0x10,
|
||||
PLUS_SL2_4K: 0x11,
|
||||
PLUS_SL3_2K: 0x20,
|
||||
PLUS_SL3_4K: 0x20,
|
||||
|
||||
// DESFire with MIFARE Application
|
||||
DESFIRE_WITH_CLASSIC: 0x28, // DESFire + Classic emulation
|
||||
|
||||
// Known magic card indicators (Gen2/CUID often have unusual ATQA)
|
||||
MAGIC_INDICATOR: 0x00,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ATQA patterns that might indicate special cards
|
||||
*/
|
||||
const SUSPICIOUS_ATQA_PATTERNS = {
|
||||
// Gen1a magic cards often have ATQA 0x0400
|
||||
GEN1A_MAGIC: '04:00',
|
||||
// Gen2/CUID cards
|
||||
GEN2_MAGIC: '08:04',
|
||||
// Standard Classic 1K
|
||||
CLASSIC_1K: '00:04',
|
||||
// Standard Classic 4K
|
||||
CLASSIC_4K: '00:02',
|
||||
};
|
||||
|
||||
/**
|
||||
* SAK swap detection result
|
||||
*/
|
||||
export interface SakSwapDetection {
|
||||
/** Whether SAK swap capability was detected */
|
||||
hasSakSwap: boolean;
|
||||
|
||||
/** Type of SAK swap if detected */
|
||||
swapType?:
|
||||
| 'mifare_plus_sl1'
|
||||
| 'desfire_with_classic'
|
||||
| 'magic_card'
|
||||
| 'unknown';
|
||||
|
||||
/** Confidence in the detection */
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Additional notes */
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a tag might have SAK swap capability
|
||||
*
|
||||
* This checks for indicators that suggest the tag can operate in
|
||||
* multiple modes or has been modified from factory defaults.
|
||||
*/
|
||||
export function detectSakSwap(
|
||||
sak: number,
|
||||
atqa?: string,
|
||||
historicalBytes?: string,
|
||||
): SakSwapDetection {
|
||||
const notes: string[] = [];
|
||||
|
||||
// Check for MIFARE Plus in SL1 mode
|
||||
// Plus in SL1 looks identical to Classic, but historical bytes may differ
|
||||
if (
|
||||
(sak === SAK_SWAP_INDICATORS.PLUS_SL1_2K ||
|
||||
sak === SAK_SWAP_INDICATORS.PLUS_SL1_4K) &&
|
||||
historicalBytes
|
||||
) {
|
||||
// MIFARE Plus typically has specific historical bytes patterns
|
||||
if (
|
||||
historicalBytes.includes('C1') ||
|
||||
historicalBytes.includes('80:02')
|
||||
) {
|
||||
notes.push('Historical bytes suggest MIFARE Plus in SL1 mode');
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'mifare_plus_sl1',
|
||||
confidence: 'medium',
|
||||
description:
|
||||
'MIFARE Plus in Security Level 1 (emulating Classic). Can be switched to SL2/SL3 with cryptographic authentication.',
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for DESFire with MIFARE Classic application
|
||||
if (sak === SAK_SWAP_INDICATORS.DESFIRE_WITH_CLASSIC) {
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'desfire_with_classic',
|
||||
confidence: 'high',
|
||||
description:
|
||||
'DESFire with MIFARE Classic emulation. Tag operates as both DESFire and Classic.',
|
||||
notes: ['Full DESFire functionality available via ISO-DEP'],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for Magic card indicators via ATQA
|
||||
if (atqa) {
|
||||
const cleanAtqa = atqa.toUpperCase();
|
||||
|
||||
// Gen1a magic cards have unusual ATQA patterns
|
||||
if (
|
||||
cleanAtqa === SUSPICIOUS_ATQA_PATTERNS.GEN1A_MAGIC &&
|
||||
(sak === 0x08 || sak === 0x18)
|
||||
) {
|
||||
notes.push('ATQA pattern suggests Gen1a magic card');
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'magic_card',
|
||||
confidence: 'medium',
|
||||
description:
|
||||
'Possible Gen1a magic card (UID-writable). SAK and UID can be modified with special commands.',
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for mismatched ATQA/SAK (common in clones)
|
||||
const isClassic1kSak = sak === 0x08;
|
||||
const isClassic4kSak = sak === 0x18;
|
||||
const isClassic1kAtqa = cleanAtqa === SUSPICIOUS_ATQA_PATTERNS.CLASSIC_1K;
|
||||
const isClassic4kAtqa = cleanAtqa === SUSPICIOUS_ATQA_PATTERNS.CLASSIC_4K;
|
||||
|
||||
if (
|
||||
(isClassic1kSak && isClassic4kAtqa) ||
|
||||
(isClassic4kSak && isClassic1kAtqa)
|
||||
) {
|
||||
notes.push('SAK/ATQA mismatch suggests modified or clone card');
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'magic_card',
|
||||
confidence: 'low',
|
||||
description:
|
||||
'SAK and ATQA values are inconsistent. May be a magic/clone card with modified parameters.',
|
||||
notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Plus SL2/SL3 modes
|
||||
if (
|
||||
sak === SAK_SWAP_INDICATORS.PLUS_SL2_2K ||
|
||||
sak === SAK_SWAP_INDICATORS.PLUS_SL2_4K
|
||||
) {
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'mifare_plus_sl1',
|
||||
confidence: 'high',
|
||||
description:
|
||||
'MIFARE Plus in Security Level 2. Supports both Classic commands and AES authentication.',
|
||||
notes: ['Can fall back to SL1 (Classic) mode in some configurations'],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
sak === SAK_SWAP_INDICATORS.PLUS_SL3_2K ||
|
||||
sak === SAK_SWAP_INDICATORS.PLUS_SL3_4K
|
||||
) {
|
||||
// SL3 may look like generic ISO-DEP
|
||||
if (historicalBytes?.includes('C1')) {
|
||||
return {
|
||||
hasSakSwap: true,
|
||||
swapType: 'mifare_plus_sl1',
|
||||
confidence: 'medium',
|
||||
description:
|
||||
'MIFARE Plus in Security Level 3 (AES-only mode). May have originated from SL1 configuration.',
|
||||
notes: ['Cannot fall back to Classic mode once in SL3'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No SAK swap detected
|
||||
return {
|
||||
hasSakSwap: false,
|
||||
confidence: 'high',
|
||||
description: 'Standard tag with no SAK swap capability detected.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag might be a magic/clone card based on behavior
|
||||
*/
|
||||
export function mightBeMagicCard(sak: number, atqa?: string): boolean {
|
||||
// Gen1a magic cards often have ATQA 0x0400
|
||||
if (atqa === SUSPICIOUS_ATQA_PATTERNS.GEN1A_MAGIC) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// SAK 0x00 with NfcA tech might be magic NTAG
|
||||
if (sak === 0x00 && atqa === '00:44') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
435
src/services/detection/ntag.ts
Normal file
435
src/services/detection/ntag.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* NTAG/Ultralight Detector
|
||||
* Identifies NTAG213/215/216, NTAG I2C, and MIFARE Ultralight variants
|
||||
* using GET_VERSION command
|
||||
*/
|
||||
|
||||
import {ChipType, NtagVersionInfo} from '../../types/detection';
|
||||
import {NTAG_GET_VERSION, sendType2Command, ntagRead} from '../nfc/commands';
|
||||
|
||||
/**
|
||||
* GET_VERSION response structure (same for NTAG and Ultralight):
|
||||
* Byte 0: Fixed header (0x00)
|
||||
* Byte 1: Vendor ID (0x04 = NXP)
|
||||
* Byte 2: Product type
|
||||
* Byte 3: Product subtype
|
||||
* Byte 4: Major product version
|
||||
* Byte 5: Minor product version
|
||||
* Byte 6: Storage size (encoded)
|
||||
* Byte 7: Protocol type (0x03 = ISO 14443-3)
|
||||
*/
|
||||
|
||||
/** Product type values */
|
||||
const PRODUCT_TYPES = {
|
||||
ULTRALIGHT: 0x03, // MIFARE Ultralight family
|
||||
NTAG: 0x04, // NTAG 21x family
|
||||
NTAG_I2C: 0x05, // NTAG I2C family
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Storage size encoding for MIFARE Ultralight family (product type 0x03)
|
||||
*/
|
||||
const ULTRALIGHT_STORAGE_SIZES: Record<number, {type: ChipType; size: number}> =
|
||||
{
|
||||
// MIFARE Ultralight (MF0ICU1): 48 bytes
|
||||
0x06: {type: ChipType.ULTRALIGHT, size: 48},
|
||||
// MIFARE Ultralight Nano: 48 bytes
|
||||
0x0a: {type: ChipType.ULTRALIGHT_NANO, size: 48},
|
||||
// MIFARE Ultralight C (MF0ICU2): 144 bytes
|
||||
0x0b: {type: ChipType.ULTRALIGHT_C, size: 144},
|
||||
// MIFARE Ultralight EV1 (MF0UL11): 48 bytes (20 pages)
|
||||
0x0e: {type: ChipType.ULTRALIGHT_EV1, size: 48},
|
||||
// MIFARE Ultralight EV1 (MF0UL21): 128 bytes (41 pages)
|
||||
0x0f: {type: ChipType.ULTRALIGHT_EV1, size: 128},
|
||||
// MIFARE Ultralight AES: 540 bytes
|
||||
0x15: {type: ChipType.ULTRALIGHT_AES, size: 540},
|
||||
};
|
||||
|
||||
/**
|
||||
* Storage size encoding for standard NTAG chips (product type 0x04)
|
||||
*/
|
||||
const NTAG_STORAGE_SIZES: Record<number, {type: ChipType; size: number}> = {
|
||||
// NTAG210: 48 bytes user memory (rare)
|
||||
0x06: {type: ChipType.NTAG213, size: 48}, // Treat as NTAG213
|
||||
// NTAG212: 128 bytes user memory (rare)
|
||||
0x0a: {type: ChipType.NTAG213, size: 128}, // Treat as NTAG213
|
||||
// NTAG213: 144 bytes user memory
|
||||
0x0f: {type: ChipType.NTAG213, size: 144},
|
||||
// NTAG215: 504 bytes user memory
|
||||
0x11: {type: ChipType.NTAG215, size: 504},
|
||||
// NTAG216: 888 bytes user memory
|
||||
0x13: {type: ChipType.NTAG216, size: 888},
|
||||
// NTAG I2C 2K reporting as product type 0x04 (some chips do this)
|
||||
// Storage size 0x15 = ~1912 bytes, same as NTAG I2C 2K
|
||||
0x15: {type: ChipType.NTAG_I2C_2K, size: 1912},
|
||||
};
|
||||
|
||||
/**
|
||||
* Storage size encoding for NTAG I2C chips (product type 0x05)
|
||||
* Per NXP NT3H1101/NT3H1201/NT3H2111/NT3H2211 datasheets
|
||||
*
|
||||
* IMPORTANT: Only use exact values from NXP datasheets.
|
||||
* Storage size byte encoding: 2^(storageSize/2) = total bytes
|
||||
* - 0x13: 2^(19/2) ≈ 1024 bytes total = NTAG I2C 1K
|
||||
* - 0x15: 2^(21/2) ≈ 2048 bytes total = NTAG I2C 2K
|
||||
*
|
||||
* The Plus vs non-Plus variant is determined by subtype byte, NOT storage size.
|
||||
*/
|
||||
const NTAG_I2C_STORAGE_SIZES: Record<number, {type: ChipType; size: number}> = {
|
||||
// NTAG I2C 1K (NT3H1101, NT3H2111): 888 bytes user memory
|
||||
0x13: {type: ChipType.NTAG_I2C_1K, size: 888},
|
||||
|
||||
// NTAG I2C 2K (NT3H1201, NT3H2211): 1912 bytes user memory
|
||||
0x15: {type: ChipType.NTAG_I2C_2K, size: 1912},
|
||||
};
|
||||
|
||||
/**
|
||||
* NTAG I2C Plus variants (detected by subtype)
|
||||
* Per NXP NT3H2111/NT3H2211 datasheet:
|
||||
* - Subtype 0x01: NTAG I2C (non-Plus)
|
||||
* - Subtype 0x02: NTAG I2C Plus
|
||||
*/
|
||||
const NTAG_I2C_PLUS_SUBTYPES = [0x02];
|
||||
|
||||
/**
|
||||
* Result of NTAG detection
|
||||
*/
|
||||
export interface NtagDetectionResult {
|
||||
success: boolean;
|
||||
chipType?: ChipType;
|
||||
versionInfo?: NtagVersionInfo;
|
||||
memorySize?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect NTAG chip variant using GET_VERSION command
|
||||
*/
|
||||
export async function detectNtag(): Promise<NtagDetectionResult> {
|
||||
try {
|
||||
console.log('[NTAG] Sending GET_VERSION command:', NTAG_GET_VERSION);
|
||||
|
||||
// Send GET_VERSION command (0x60)
|
||||
const response = await sendType2Command(NTAG_GET_VERSION);
|
||||
console.log('[NTAG] GET_VERSION response:', response);
|
||||
|
||||
if (response.length < 8) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid GET_VERSION response length: ${response.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse version info
|
||||
const versionInfo: NtagVersionInfo = {
|
||||
vendorId: response[1],
|
||||
productType: response[2],
|
||||
productSubtype: response[3],
|
||||
majorVersion: response[4],
|
||||
minorVersion: response[5],
|
||||
storageSize: response[6],
|
||||
protocolType: response[7],
|
||||
};
|
||||
|
||||
console.log('[NTAG] Parsed version info:', {
|
||||
vendorId: `0x${versionInfo.vendorId.toString(16)}`,
|
||||
productType: `0x${versionInfo.productType.toString(16)}`,
|
||||
productSubtype: `0x${versionInfo.productSubtype.toString(16)}`,
|
||||
storageSize: `0x${versionInfo.storageSize.toString(16)}`,
|
||||
});
|
||||
|
||||
// Check if this is an NXP chip
|
||||
if (versionInfo.vendorId !== 0x04) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG_UNKNOWN,
|
||||
versionInfo,
|
||||
error: `Non-NXP vendor ID: 0x${versionInfo.vendorId.toString(16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle MIFARE Ultralight family (product type 0x03)
|
||||
if (versionInfo.productType === PRODUCT_TYPES.ULTRALIGHT) {
|
||||
const storageInfo = ULTRALIGHT_STORAGE_SIZES[versionInfo.storageSize];
|
||||
|
||||
if (storageInfo) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: storageInfo.type,
|
||||
versionInfo,
|
||||
memorySize: storageInfo.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown Ultralight variant
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.ULTRALIGHT,
|
||||
versionInfo,
|
||||
error: `Unknown Ultralight storage size: 0x${versionInfo.storageSize.toString(16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle NTAG I2C (product type 0x05)
|
||||
// Per NXP NT3H2111/NT3H2211 datasheet:
|
||||
// - Storage size 0x13 = 1K variant (NT3H1101, NT3H2111)
|
||||
// - Storage size 0x15 = 2K variant (NT3H1201, NT3H2211)
|
||||
// - Subtype 0x01 = non-Plus, 0x02 = Plus
|
||||
if (versionInfo.productType === PRODUCT_TYPES.NTAG_I2C) {
|
||||
const storageInfo = NTAG_I2C_STORAGE_SIZES[versionInfo.storageSize];
|
||||
|
||||
if (!storageInfo) {
|
||||
// Unknown storage size - report it for diagnosis rather than guessing
|
||||
console.warn(
|
||||
`[NTAG] Unknown NTAG I2C storage size: 0x${versionInfo.storageSize.toString(16)}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG_UNKNOWN,
|
||||
versionInfo,
|
||||
error: `NTAG I2C with unknown storage size: 0x${versionInfo.storageSize.toString(16)}. Expected 0x13 (1K) or 0x15 (2K).`,
|
||||
};
|
||||
}
|
||||
|
||||
let chipType = storageInfo.type;
|
||||
const memorySize = storageInfo.size;
|
||||
|
||||
// Check for Plus variant based on subtype (0x02 = Plus)
|
||||
if (NTAG_I2C_PLUS_SUBTYPES.includes(versionInfo.productSubtype)) {
|
||||
chipType =
|
||||
chipType === ChipType.NTAG_I2C_1K || chipType === ChipType.NTAG_I2C_PLUS_1K
|
||||
? ChipType.NTAG_I2C_PLUS_1K
|
||||
: ChipType.NTAG_I2C_PLUS_2K;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chipType,
|
||||
versionInfo,
|
||||
memorySize,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle standard NTAG (product type 0x04)
|
||||
if (versionInfo.productType === PRODUCT_TYPES.NTAG) {
|
||||
const storageInfo = NTAG_STORAGE_SIZES[versionInfo.storageSize];
|
||||
|
||||
if (storageInfo) {
|
||||
return {
|
||||
success: true,
|
||||
chipType: storageInfo.type,
|
||||
versionInfo,
|
||||
memorySize: storageInfo.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown storage size - return as unknown NTAG
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG_UNKNOWN,
|
||||
versionInfo,
|
||||
error: `Unknown NTAG storage size: 0x${versionInfo.storageSize.toString(16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown product type
|
||||
return {
|
||||
success: true,
|
||||
chipType: ChipType.NTAG_UNKNOWN,
|
||||
versionInfo,
|
||||
error: `Unknown product type: 0x${versionInfo.productType.toString(16)}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// GET_VERSION command not supported - might not be an NTAG
|
||||
if (
|
||||
errorMessage.toLowerCase().includes('transceive') ||
|
||||
errorMessage.toLowerCase().includes('tag was lost')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'GET_VERSION command failed - may not be an NTAG',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `NTAG detection failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if raw tag data suggests this might be an NTAG
|
||||
* (preliminary check before running GET_VERSION)
|
||||
*/
|
||||
export function mightBeNtag(sak?: number, techTypes?: string[]): boolean {
|
||||
// NTAG chips typically have SAK 0x00 (no ISO 14443-4 support)
|
||||
if (sak !== undefined && sak !== 0x00) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should have NfcA technology
|
||||
if (techTypes && !techTypes.some(t => t.includes('NfcA'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should NOT have IsoDep (NTAG is Type 2, not Type 4)
|
||||
if (techTypes && techTypes.some(t => t.includes('IsoDep'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format NTAG/Ultralight version info for display
|
||||
*/
|
||||
export function formatNtagVersionInfo(info: NtagVersionInfo): string {
|
||||
const productTypeNames: Record<number, string> = {
|
||||
0x03: 'Ultralight',
|
||||
0x04: 'NTAG',
|
||||
0x05: 'NTAG I2C',
|
||||
};
|
||||
const typeName =
|
||||
productTypeNames[info.productType] || `0x${info.productType.toString(16)}`;
|
||||
|
||||
return [
|
||||
`Vendor: ${info.vendorId === 0x04 ? 'NXP' : `0x${info.vendorId.toString(16)}`}`,
|
||||
`Type: ${typeName}`,
|
||||
`Version: ${info.majorVersion}.${info.minorVersion}`,
|
||||
`Storage: 0x${info.storageSize.toString(16)}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Known Dangerous Things implant names that may be written to Type 2 tag memory
|
||||
* Looking for 4+ character partial matches (case-insensitive)
|
||||
* Note: Only includes Type 2 (NTAG/Ultralight) based implants
|
||||
*/
|
||||
const KNOWN_IMPLANT_NAMES = [
|
||||
// X-Series NTAG implants
|
||||
'xNT',
|
||||
'xSIID',
|
||||
'NExT',
|
||||
// Bioresin NTAG implants
|
||||
'dNExT',
|
||||
// Flex NTAG implants
|
||||
'flexNT',
|
||||
// Generic identifiers
|
||||
'VivoKey',
|
||||
'Dangerous',
|
||||
'DNGRTHNG',
|
||||
'DNGR',
|
||||
];
|
||||
|
||||
/**
|
||||
* Memory layout for NTAG chips
|
||||
* Last user page = total pages - config pages - 1
|
||||
*/
|
||||
const NTAG_MEMORY_LAYOUT: Partial<Record<ChipType, {totalPages: number; lastUserPage: number}>> = {
|
||||
[ChipType.NTAG213]: {totalPages: 45, lastUserPage: 39},
|
||||
[ChipType.NTAG215]: {totalPages: 135, lastUserPage: 129},
|
||||
[ChipType.NTAG216]: {totalPages: 231, lastUserPage: 225},
|
||||
[ChipType.NTAG_I2C_1K]: {totalPages: 231, lastUserPage: 225},
|
||||
[ChipType.NTAG_I2C_2K]: {totalPages: 485, lastUserPage: 479},
|
||||
[ChipType.NTAG_I2C_PLUS_1K]: {totalPages: 231, lastUserPage: 225},
|
||||
[ChipType.NTAG_I2C_PLUS_2K]: {totalPages: 485, lastUserPage: 479},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert bytes to ASCII string, filtering non-printable characters
|
||||
*/
|
||||
function bytesToAscii(bytes: number[]): string {
|
||||
return bytes
|
||||
.filter(b => b >= 0x20 && b <= 0x7e) // Printable ASCII only
|
||||
.map(b => String.fromCharCode(b))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of implant name detection
|
||||
*/
|
||||
export interface ImplantNameResult {
|
||||
found: boolean;
|
||||
name?: string;
|
||||
rawBytes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check memory pages for implant name (4+ character partial match)
|
||||
* Reads the last 16 bytes (4 pages) of user memory where implant names are typically stored
|
||||
*/
|
||||
export async function detectImplantNameInMemory(
|
||||
chipType: ChipType,
|
||||
): Promise<ImplantNameResult> {
|
||||
const layout = NTAG_MEMORY_LAYOUT[chipType];
|
||||
if (!layout) {
|
||||
console.log(`[NTAG] No memory layout for chip type: ${chipType}`);
|
||||
return {found: false};
|
||||
}
|
||||
|
||||
try {
|
||||
// Read last 4 pages (16 bytes) - where implant names are typically stored
|
||||
// For NTAG216, this is pages 222-225 (lastUserPage - 3 to lastUserPage)
|
||||
const startPage = layout.lastUserPage - 3;
|
||||
|
||||
console.log(
|
||||
`[NTAG] Reading pages ${startPage}-${layout.lastUserPage} (0x${startPage.toString(16)}-0x${layout.lastUserPage.toString(16)}) for implant name`,
|
||||
);
|
||||
|
||||
// READ command returns 4 pages (16 bytes) starting at the given page
|
||||
const response = await sendType2Command(ntagRead(startPage));
|
||||
|
||||
console.log(
|
||||
`[NTAG] Read ${response.length} bytes:`,
|
||||
response.map(b => b.toString(16).padStart(2, '0')).join(' '),
|
||||
);
|
||||
|
||||
const asciiStr = bytesToAscii(response);
|
||||
console.log(`[NTAG] ASCII: "${asciiStr}"`);
|
||||
|
||||
// Look for 4+ character matches (case-insensitive)
|
||||
const upperAscii = asciiStr.toUpperCase();
|
||||
for (const name of KNOWN_IMPLANT_NAMES) {
|
||||
if (name.length >= 4 && upperAscii.includes(name.toUpperCase())) {
|
||||
console.log(`[NTAG] Found implant name: ${name}`);
|
||||
return {
|
||||
found: true,
|
||||
name,
|
||||
rawBytes: response,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in last 4 pages, try the 4 pages before that
|
||||
const earlierStartPage = Math.max(4, startPage - 4);
|
||||
if (earlierStartPage < startPage) {
|
||||
console.log(
|
||||
`[NTAG] Trying earlier pages ${earlierStartPage}-${earlierStartPage + 3}`,
|
||||
);
|
||||
const earlierResponse = await sendType2Command(ntagRead(earlierStartPage));
|
||||
const earlierAscii = bytesToAscii(earlierResponse);
|
||||
const upperEarlierAscii = earlierAscii.toUpperCase();
|
||||
|
||||
for (const name of KNOWN_IMPLANT_NAMES) {
|
||||
if (name.length >= 4 && upperEarlierAscii.includes(name.toUpperCase())) {
|
||||
console.log(`[NTAG] Found implant name in earlier pages: ${name}`);
|
||||
return {
|
||||
found: true,
|
||||
name,
|
||||
rawBytes: earlierResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[NTAG] No implant name found in memory');
|
||||
return {found: false, rawBytes: response};
|
||||
} catch (error) {
|
||||
console.warn('[NTAG] Failed to read memory for implant name:', error);
|
||||
return {found: false};
|
||||
}
|
||||
}
|
||||
15
src/services/matching/index.ts
Normal file
15
src/services/matching/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Product Matching Service
|
||||
*/
|
||||
|
||||
export {
|
||||
matchChipToProducts,
|
||||
getMatchSummary,
|
||||
getRecommendedAction,
|
||||
formatProductFeatures,
|
||||
getCloneTargetsForChip,
|
||||
canCloneToProduct,
|
||||
getDesfireEvLevel,
|
||||
getDesfireEvMismatchWarning,
|
||||
getMifareClassicCapacityWarning,
|
||||
} from './matcher';
|
||||
232
src/services/matching/matcher.ts
Normal file
232
src/services/matching/matcher.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Product Matching Service
|
||||
*
|
||||
* Matches detected NFC chips to compatible Dangerous Things products
|
||||
*/
|
||||
|
||||
import { ChipType, getChipFamily, CHIP_CLONEABILITY, ChipFamily, Transponder } from '../../types/detection';
|
||||
import { MatchResult, Product, DesfireEvLevel } from '../../types/products';
|
||||
import {
|
||||
PRODUCTS,
|
||||
getChipProductMap,
|
||||
CONVERSION_SERVICE_URL,
|
||||
} from '../../data/products';
|
||||
|
||||
/**
|
||||
* Match a detected chip to compatible products
|
||||
*/
|
||||
export function matchChipToProducts(chip: Transponder): MatchResult {
|
||||
const chipType = chip.type as ChipType
|
||||
const chipProductMap = getChipProductMap();
|
||||
const cloneability = CHIP_CLONEABILITY[chipType];
|
||||
const chipFamily = getChipFamily(chipType);
|
||||
|
||||
// Get products that directly support this chip
|
||||
const directMatches = chipProductMap.get(chipType) || [];
|
||||
|
||||
// Separate exact matches from clone targets
|
||||
const exactMatches: Product[] = [];
|
||||
const cloneTargets: Product[] = [];
|
||||
|
||||
for (const product of directMatches) {
|
||||
if (product.exactMatch) {
|
||||
exactMatches.push(product);
|
||||
}
|
||||
if (product.canReceiveClone && cloneability?.cloneable) {
|
||||
if ((product.name.startsWith("xMagic") || product.name.startsWith("xM1") || product.name.startsWith("flexM1")) && chip.rawData.uid.replaceAll(":", "").length / 2 !== 4) {
|
||||
// This skips things without a 4-byte UID
|
||||
} else {
|
||||
cloneTargets.push(product);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find products in the same chip family
|
||||
const familyMatches = findFamilyMatches(chipType, chipFamily, directMatches);
|
||||
|
||||
// Determine if conversion is recommended
|
||||
// Conversion is recommended when:
|
||||
// 1. No exact matches AND no clone targets
|
||||
// 2. Chip is not cloneable (crypto protection)
|
||||
const hasMatches = exactMatches.length > 0 || cloneTargets.length > 0;
|
||||
const conversionRecommended = !hasMatches || !cloneability?.cloneable;
|
||||
|
||||
return {
|
||||
exactMatches,
|
||||
cloneTargets,
|
||||
familyMatches,
|
||||
isCloneable: cloneability?.cloneable ?? false,
|
||||
cloneabilityNote: cloneability?.note,
|
||||
conversionRecommended,
|
||||
conversionUrl: CONVERSION_SERVICE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find products in the same chip family that might be of interest
|
||||
*/
|
||||
function findFamilyMatches(
|
||||
chipType: ChipType,
|
||||
chipFamily: ChipFamily,
|
||||
excludeProducts: Product[],
|
||||
): Product[] {
|
||||
const excludeIds = new Set(excludeProducts.map(p => p.id));
|
||||
const familyMatches: Product[] = [];
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
// Skip already matched products
|
||||
if (excludeIds.has(product.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any of the product's compatible chips are in the same family
|
||||
const productChipFamilies = product.compatibleChips.map(getChipFamily);
|
||||
if (productChipFamilies.includes(chipFamily)) {
|
||||
familyMatches.push(product);
|
||||
}
|
||||
}
|
||||
|
||||
return familyMatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary message for the match result
|
||||
*/
|
||||
export function getMatchSummary(result: MatchResult, chipName: string): string {
|
||||
if (result.exactMatches.length > 0) {
|
||||
const names = result.exactMatches.map(p => p.name).join(', ');
|
||||
return `Your ${chipName} is compatible with: ${names}`;
|
||||
}
|
||||
|
||||
if (result.cloneTargets.length > 0) {
|
||||
const names = result.cloneTargets.map(p => p.name).join(', ');
|
||||
return `Your ${chipName} data can be cloned to: ${names}`;
|
||||
}
|
||||
|
||||
if (!result.isCloneable) {
|
||||
return `${chipName} uses cryptographic protection and cannot be cloned. Consider our conversion service for compatible options.`;
|
||||
}
|
||||
|
||||
return `No direct product match found for ${chipName}. Check our conversion service for options.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended action based on match result
|
||||
*/
|
||||
export function getRecommendedAction(
|
||||
result: MatchResult,
|
||||
): 'purchase' | 'clone' | 'conversion' | 'contact' {
|
||||
if (result.exactMatches.length > 0) {
|
||||
return 'purchase';
|
||||
}
|
||||
|
||||
if (result.cloneTargets.length > 0 && result.isCloneable) {
|
||||
return 'clone';
|
||||
}
|
||||
|
||||
if (result.conversionRecommended) {
|
||||
return 'conversion';
|
||||
}
|
||||
|
||||
return 'contact';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format product features for display
|
||||
*/
|
||||
export function formatProductFeatures(product: Product): string[] {
|
||||
return product.features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products that can receive cloned data from a chip type
|
||||
*/
|
||||
export function getCloneTargetsForChip(chipType: ChipType): Product[] {
|
||||
const cloneability = CHIP_CLONEABILITY[chipType];
|
||||
if (!cloneability?.cloneable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chipProductMap = getChipProductMap();
|
||||
const directMatches = chipProductMap.get(chipType) || [];
|
||||
|
||||
return directMatches.filter(p => p.canReceiveClone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a chip can be cloned to any product
|
||||
*/
|
||||
export function canCloneToProduct(chipType: ChipType): boolean {
|
||||
return getCloneTargetsForChip(chipType).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DESFire EV level from chip type
|
||||
*/
|
||||
export function getDesfireEvLevel(chipType: ChipType): DesfireEvLevel | null {
|
||||
switch (chipType) {
|
||||
case ChipType.DESFIRE_EV1:
|
||||
return 1;
|
||||
case ChipType.DESFIRE_EV2:
|
||||
return 2;
|
||||
case ChipType.DESFIRE_EV3:
|
||||
return 3;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of products that only support MIFARE Classic 1K (not 4K)
|
||||
*/
|
||||
const MIFARE_1K_ONLY_PRODUCTS = new Set(['xmagic', 'xm1', 'flexm1-v2']);
|
||||
|
||||
/**
|
||||
* Check if a scanned 4K card is being matched to a 1K-only implant
|
||||
* Returns warning message if capacity mismatch, null if no issue
|
||||
*/
|
||||
export function getMifareClassicCapacityWarning(
|
||||
chipType: ChipType,
|
||||
product: Product,
|
||||
): string | null {
|
||||
// Only applies to MIFARE Classic 4K cards
|
||||
if (chipType !== ChipType.MIFARE_CLASSIC_4K) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only warn for 1K-only implants
|
||||
if (!MIFARE_1K_ONLY_PRODUCTS.has(product.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'This implant has 1K memory only - might not have capacity to clone your 4K card.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a DESFire EV mismatch between chip and product
|
||||
* Returns warning message if mismatch, null if no issue
|
||||
*/
|
||||
export function getDesfireEvMismatchWarning(
|
||||
chipType: ChipType,
|
||||
product: Product,
|
||||
): string | null {
|
||||
const chipEvLevel = getDesfireEvLevel(chipType);
|
||||
const productEvLevel = product.desfireEvLevel;
|
||||
|
||||
// Not a DESFire chip or product doesn't have EV level
|
||||
if (chipEvLevel === null || productEvLevel === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Perfect match
|
||||
if (chipEvLevel === productEvLevel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mismatch - warn user
|
||||
if (chipEvLevel < productEvLevel) {
|
||||
return `Your card uses DESFire EV${chipEvLevel}, but this implant uses EV${productEvLevel}. Some newer features may not be compatible with your existing system.`;
|
||||
} else {
|
||||
return `Your card uses DESFire EV${chipEvLevel}, but this implant uses EV${productEvLevel}. This should work, but you won't have access to EV${chipEvLevel} features.`;
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,38 @@ import type {
|
||||
ScanError,
|
||||
ScanErrorType,
|
||||
NfcTechType,
|
||||
NdefRecord,
|
||||
MifareClassicInfo,
|
||||
} from '../../types/nfc';
|
||||
|
||||
/**
|
||||
* Convert byte array to hex string
|
||||
* Handles number[], Uint8Array, string, or undefined
|
||||
*/
|
||||
function bytesToHex(bytes: number[] | undefined): string {
|
||||
if (!bytes || bytes.length === 0) {
|
||||
function bytesToHex(bytes: number[] | Uint8Array | string | undefined): string {
|
||||
if (!bytes) {
|
||||
return '';
|
||||
}
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
|
||||
|
||||
// If already a string, assume it's hex and format it
|
||||
if (typeof bytes === 'string') {
|
||||
// Remove any existing separators and format consistently
|
||||
const hex = bytes.replace(/[:\s-]/g, '').toUpperCase();
|
||||
if (hex.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// Add colons between byte pairs
|
||||
return hex.match(/.{1,2}/g)?.join(':') || hex;
|
||||
}
|
||||
|
||||
// Convert array-like to actual array if needed (handles Uint8Array)
|
||||
const byteArray = Array.from(bytes);
|
||||
|
||||
if (byteArray.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return byteArray.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +54,29 @@ function parseSak(tag: TagEvent): number | undefined {
|
||||
if (nfcA?.sak !== undefined) {
|
||||
return nfcA.sak;
|
||||
}
|
||||
|
||||
// iOS: CoreNFC doesn't directly expose SAK
|
||||
// But we can infer ISO-DEP capability (SAK bit 5) from iso7816 presence
|
||||
if (Platform.OS === 'ios') {
|
||||
const iso7816 = (tag as any).iso7816;
|
||||
const mifare = (tag as any).mifare;
|
||||
|
||||
// If iso7816 interface is available, the tag has ISO-DEP capability
|
||||
// This corresponds to SAK bit 5 being set (value 0x20)
|
||||
if (iso7816) {
|
||||
// Check if also has MIFARE capability (could be DESFire or Plus)
|
||||
if (mifare) {
|
||||
return 0x20; // ISO-DEP capable (like DESFire)
|
||||
}
|
||||
return 0x20; // Pure ISO-DEP (like NTAG 424 DNA)
|
||||
}
|
||||
|
||||
// If only MIFARE/NFC-A without iso7816, likely NTAG or Ultralight (SAK 0x00)
|
||||
if (mifare) {
|
||||
return 0x00;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -72,19 +117,136 @@ function parseAts(tag: TagEvent): {ats?: string; historicalBytes?: string} {
|
||||
return {ats, historicalBytes};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MIFARE Classic info from TagEvent (Android only)
|
||||
*
|
||||
* Uses NDEF maxSize to determine card capacity:
|
||||
* - 4K: maxSize ~3356 bytes (NDEF capacity)
|
||||
* - 1K: maxSize ~716 bytes (NDEF capacity)
|
||||
* - Mini: maxSize ~80 bytes (NDEF capacity)
|
||||
*/
|
||||
function parseMifareClassic(tag: TagEvent): MifareClassicInfo | undefined {
|
||||
const techTypes = tag.techTypes || [];
|
||||
const isMifareClassic = techTypes.some(t => t.includes('MifareClassic'));
|
||||
|
||||
if (!isMifareClassic) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use NDEF maxSize to determine card type
|
||||
// MIFARE Classic 4K has ~3356 bytes NDEF capacity
|
||||
// MIFARE Classic 1K has ~716 bytes NDEF capacity
|
||||
const maxSize = (tag as any).maxSize;
|
||||
|
||||
if (typeof maxSize === 'number' && maxSize > 0) {
|
||||
let size: number;
|
||||
let sectorCount: number;
|
||||
let blockCount: number;
|
||||
|
||||
if (maxSize >= 2000) {
|
||||
// 4K card (NDEF maxSize ~3356)
|
||||
size = 4096;
|
||||
sectorCount = 40;
|
||||
blockCount = 256;
|
||||
} else if (maxSize >= 500) {
|
||||
// 1K card (NDEF maxSize ~716)
|
||||
size = 1024;
|
||||
sectorCount = 16;
|
||||
blockCount = 64;
|
||||
} else {
|
||||
// Mini card (NDEF maxSize ~80)
|
||||
size = 320;
|
||||
sectorCount = 5;
|
||||
blockCount = 20;
|
||||
}
|
||||
|
||||
console.log('[NFCManager] MIFARE Classic from maxSize:', {maxSize, size, sectorCount, blockCount});
|
||||
return {size, sectorCount, blockCount};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse NDEF records from TagEvent
|
||||
*/
|
||||
function parseNdefRecords(tag: TagEvent): NdefRecord[] | undefined {
|
||||
const ndefMessage = (tag as any).ndefMessage;
|
||||
if (!ndefMessage || !Array.isArray(ndefMessage) || ndefMessage.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const records: NdefRecord[] = [];
|
||||
for (const record of ndefMessage) {
|
||||
if (!record) continue;
|
||||
|
||||
// react-native-nfc-manager returns NDEF records with these properties
|
||||
const tnf = record.tnf ?? 0;
|
||||
const type = record.type
|
||||
? typeof record.type === 'string'
|
||||
? record.type
|
||||
: String.fromCharCode(...(Array.isArray(record.type) ? record.type : []))
|
||||
: '';
|
||||
const id = record.id
|
||||
? typeof record.id === 'string'
|
||||
? record.id
|
||||
: String.fromCharCode(...(Array.isArray(record.id) ? record.id : []))
|
||||
: undefined;
|
||||
const payload = Array.isArray(record.payload)
|
||||
? record.payload
|
||||
: typeof record.payload === 'string'
|
||||
? record.payload.split('').map((c: string) => c.charCodeAt(0))
|
||||
: [];
|
||||
|
||||
records.push({tnf, type, id, payload});
|
||||
}
|
||||
|
||||
if (records.length > 0) {
|
||||
console.log('[NFCManager] Parsed NDEF records:', records.length);
|
||||
}
|
||||
|
||||
return records.length > 0 ? records : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TagEvent to RawTagData
|
||||
*/
|
||||
function tagEventToRawData(tag: TagEvent): RawTagData {
|
||||
const techTypes = (tag.techTypes || []) as NfcTechType[];
|
||||
const uid = tag.id ? bytesToHex(tag.id as unknown as number[]) : '';
|
||||
let techTypes = (tag.techTypes || []) as NfcTechType[];
|
||||
const uid = tag.id ? bytesToHex(tag.id as string | number[] | Uint8Array) : '';
|
||||
const sak = parseSak(tag);
|
||||
const atqa = parseAtqa(tag);
|
||||
const {ats, historicalBytes} = parseAts(tag);
|
||||
const ndefRecords = parseNdefRecords(tag);
|
||||
const mifareClassic = parseMifareClassic(tag);
|
||||
|
||||
const isoDep = (tag as any).isoDep;
|
||||
const iso7816 = (tag as any).iso7816;
|
||||
const maxTransceiveLength = isoDep?.maxTransceiveLength;
|
||||
|
||||
// On iOS, detect ISO-DEP capability from iso7816 property or tag type
|
||||
// This ensures NTAG 424 DNA and DESFire are properly identified
|
||||
if (Platform.OS === 'ios') {
|
||||
// If iso7816 property exists, this is an ISO-DEP capable tag
|
||||
if (iso7816 && !techTypes.some(t => t.includes('IsoDep'))) {
|
||||
techTypes = [...techTypes, 'android.nfc.tech.IsoDep' as NfcTechType];
|
||||
}
|
||||
// Also check tag type for iOS
|
||||
const tagType = (tag as any).type;
|
||||
if (tagType && typeof tagType === 'string') {
|
||||
if (tagType.includes('iso7816') || tagType.includes('IsoDep')) {
|
||||
if (!techTypes.some(t => t.includes('IsoDep'))) {
|
||||
techTypes = [...techTypes, 'android.nfc.tech.IsoDep' as NfcTechType];
|
||||
}
|
||||
}
|
||||
if (tagType.includes('iso15693') || tagType.includes('NfcV')) {
|
||||
if (!techTypes.some(t => t.includes('NfcV'))) {
|
||||
techTypes = [...techTypes, 'android.nfc.tech.NfcV' as NfcTechType];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uid,
|
||||
techTypes,
|
||||
@@ -93,6 +255,8 @@ function tagEventToRawData(tag: TagEvent): RawTagData {
|
||||
ats,
|
||||
historicalBytes,
|
||||
maxTransceiveLength,
|
||||
ndefRecords,
|
||||
mifareClassic,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,8 +355,13 @@ class NFCManagerService {
|
||||
/**
|
||||
* Request NFC technology and scan for a tag
|
||||
* Returns raw tag data on success
|
||||
*
|
||||
* @param keepAlive - If true, don't release the NFC technology after scanning.
|
||||
* Caller must call cancelScan() when done.
|
||||
*/
|
||||
async scanTag(): Promise<{tag?: RawTagData; error?: ScanError}> {
|
||||
async scanTag(
|
||||
keepAlive = false,
|
||||
): Promise<{tag?: RawTagData; error?: ScanError}> {
|
||||
try {
|
||||
// Ensure initialized
|
||||
if (!this.initialized) {
|
||||
@@ -220,17 +389,21 @@ class NFCManagerService {
|
||||
|
||||
// Request technology based on platform
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS: Use MifareIOS for broadest compatibility with ISO 14443 tags
|
||||
// iOS: Use MifareIOS which works for NFC-A tags including ISO-DEP
|
||||
// The iso7816HandlerIOS is used separately for ISO-DEP commands
|
||||
await NfcManager.requestTechnology(NfcTech.MifareIOS, {
|
||||
alertMessage: 'Hold your NFC tag near the top of your iPhone',
|
||||
});
|
||||
} else {
|
||||
// Android: Request multiple technologies for best detection
|
||||
// IMPORTANT: IsoDep MUST be first so ISO-DEP capable tags (DESFire, NTAG 424 DNA)
|
||||
// connect via ISO-DEP rather than NfcA. When NfcA connects first, isoDepHandler
|
||||
// won't work because the wrong technology is active.
|
||||
await NfcManager.requestTechnology([
|
||||
NfcTech.NfcA,
|
||||
NfcTech.NfcB,
|
||||
NfcTech.NfcV,
|
||||
NfcTech.IsoDep,
|
||||
NfcTech.NfcA,
|
||||
NfcTech.NfcV,
|
||||
NfcTech.NfcB,
|
||||
NfcTech.MifareClassic,
|
||||
]);
|
||||
}
|
||||
@@ -238,16 +411,54 @@ class NFCManagerService {
|
||||
// Get the tag
|
||||
const tag = await NfcManager.getTag();
|
||||
if (!tag) {
|
||||
if (!keepAlive) {
|
||||
await this.cancelScan();
|
||||
}
|
||||
return {
|
||||
error: createScanError('UNKNOWN', 'No tag data received'),
|
||||
};
|
||||
}
|
||||
|
||||
return {tag: tagEventToRawData(tag)};
|
||||
// Convert to RawTagData
|
||||
const rawData = tagEventToRawData(tag);
|
||||
|
||||
return {tag: rawData};
|
||||
} catch (error) {
|
||||
return {error: categorizeError(error)};
|
||||
} finally {
|
||||
// Always clean up
|
||||
// Only clean up if not keeping alive
|
||||
if (!keepAlive) {
|
||||
await this.cancelScan();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan tag and run detection callback while NFC session is active
|
||||
* This ensures commands can be sent during detection
|
||||
*/
|
||||
async scanWithDetection<T>(
|
||||
detectFn: (tag: RawTagData) => Promise<T>,
|
||||
): Promise<{tag?: RawTagData; detection?: T; error?: ScanError}> {
|
||||
try {
|
||||
// Scan but keep the session alive
|
||||
const {tag, error} = await this.scanTag(true);
|
||||
|
||||
if (error || !tag) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
// Run detection while session is still active
|
||||
try {
|
||||
const detection = await detectFn(tag);
|
||||
return {tag, detection};
|
||||
} catch (detectError) {
|
||||
// Detection failed but we still have the tag data
|
||||
console.warn('[NFCManager] Detection failed:', detectError);
|
||||
return {tag};
|
||||
}
|
||||
} finally {
|
||||
// Always clean up after detection
|
||||
await this.cancelScan();
|
||||
}
|
||||
}
|
||||
|
||||
394
src/services/nfc/commands.ts
Normal file
394
src/services/nfc/commands.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* NFC APDU Commands
|
||||
* Command builders and utilities for NFC communication
|
||||
*/
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import NfcManager, {NfcTech} from 'react-native-nfc-manager';
|
||||
|
||||
export {NfcTech};
|
||||
|
||||
/**
|
||||
* APDU response with status word
|
||||
*/
|
||||
export interface ApduResponse {
|
||||
data: number[];
|
||||
sw1: number;
|
||||
sw2: number;
|
||||
isSuccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse APDU response extracting data and status words
|
||||
*/
|
||||
export function parseApduResponse(response: number[]): ApduResponse {
|
||||
if (response.length < 2) {
|
||||
return {data: [], sw1: 0, sw2: 0, isSuccess: false};
|
||||
}
|
||||
|
||||
const sw1 = response[response.length - 2];
|
||||
const sw2 = response[response.length - 1];
|
||||
const data = response.slice(0, -2);
|
||||
|
||||
// 0x9000 = Success, 0x91XX = DESFire success with more data
|
||||
const isSuccess = sw1 === 0x90 || sw1 === 0x91;
|
||||
|
||||
return {data, sw1, sw2, isSuccess};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array
|
||||
*/
|
||||
export function hexToBytes(hex: string): number[] {
|
||||
const cleanHex = hex.replace(/[:\s-]/g, '');
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < cleanHex.length; i += 2) {
|
||||
bytes.push(parseInt(cleanHex.substring(i, i + 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert byte array to hex string
|
||||
*/
|
||||
export function bytesToHex(bytes: number[]): string {
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NTAG Commands (NFC Type 2 Tags)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* NTAG GET_VERSION command
|
||||
* Returns 8 bytes: header, vendor ID, product type, product subtype,
|
||||
* major version, minor version, storage size, protocol type
|
||||
*/
|
||||
export const NTAG_GET_VERSION = [0x60];
|
||||
|
||||
/**
|
||||
* NTAG READ command - reads 4 pages (16 bytes) starting at given page
|
||||
*/
|
||||
export function ntagRead(pageAddress: number): number[] {
|
||||
return [0x30, pageAddress];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ISO 14443-4 / ISO-DEP Commands
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* DESFire GET_VERSION command (ISO-wrapped)
|
||||
* Response byte 3 indicates version: 0x00=EV0, 0x01=EV1, 0x10=EV2, 0x30=EV3
|
||||
*/
|
||||
export const DESFIRE_GET_VERSION = [0x90, 0x60, 0x00, 0x00, 0x00];
|
||||
|
||||
/**
|
||||
* DESFire GET_VERSION additional frames (for full version info)
|
||||
*/
|
||||
export const DESFIRE_GET_VERSION_CONTINUE = [0x90, 0xaf, 0x00, 0x00, 0x00];
|
||||
|
||||
/**
|
||||
* SELECT command for AID
|
||||
*/
|
||||
export function selectAid(aid: number[]): number[] {
|
||||
return [0x00, 0xa4, 0x04, 0x00, aid.length, ...aid, 0x00];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET DATA command for CPLC (Card Production Life Cycle)
|
||||
* Used for JavaCard/JCOP identification
|
||||
*/
|
||||
export const GET_CPLC = [0x80, 0xca, 0x9f, 0x7f, 0x00];
|
||||
|
||||
// ============================================================================
|
||||
// ISO 15693 (NFC-V) Commands - Per NXP AN11042
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* ISO 15693 GET_SYSTEM_INFO command (0x2B)
|
||||
* Returns: DSFID, UID, block size, block count, IC reference
|
||||
* This is the NXP-recommended way to identify SLIX/NTAG5 chips
|
||||
*
|
||||
* Request format: [Flags] [Command] [UID if addressed]
|
||||
* Flags 0x02 = unaddressed mode (use inventory to select)
|
||||
* Flags 0x22 = addressed mode (include UID)
|
||||
*/
|
||||
export const ISO15693_GET_SYSTEM_INFO = [0x02, 0x2b];
|
||||
|
||||
/**
|
||||
* ISO 15693 GET_SYSTEM_INFO with specific UID (addressed mode)
|
||||
*/
|
||||
export function iso15693GetSystemInfoAddressed(uid: number[]): number[] {
|
||||
// Flags 0x22: Addressed mode, high data rate
|
||||
return [0x22, 0x2b, ...uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 15693 READ_SINGLE_BLOCK command
|
||||
* Used to read configuration pages for additional identification
|
||||
*/
|
||||
export function iso15693ReadSingleBlock(blockNumber: number): number[] {
|
||||
return [0x02, 0x20, blockNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 15693 READ_MULTIPLE_BLOCKS command (0x23)
|
||||
* Reads multiple consecutive blocks
|
||||
*/
|
||||
export function iso15693ReadMultipleBlocks(
|
||||
startBlock: number,
|
||||
numBlocks: number,
|
||||
): number[] {
|
||||
// Flags 0x02: unaddressed mode
|
||||
// Command 0x23: READ_MULTIPLE_BLOCKS
|
||||
// numBlocks is 0-indexed (0 = 1 block, so subtract 1)
|
||||
return [0x02, 0x23, startBlock, numBlocks - 1];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Known AIDs
|
||||
// ============================================================================
|
||||
|
||||
export const KNOWN_AIDS = {
|
||||
/** Global Platform Card Manager */
|
||||
cardManager: [0xa0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00],
|
||||
/** OpenPGP applet */
|
||||
openPgp: [0xd2, 0x76, 0x00, 0x01, 0x24, 0x01],
|
||||
/** FIDO/U2F applet */
|
||||
fido: [0xa0, 0x00, 0x00, 0x06, 0x47, 0x2f, 0x00, 0x01],
|
||||
/** FIDO2/WebAuthn applet (CTAP2) */
|
||||
fido2: [0xa0, 0x00, 0x00, 0x06, 0x47, 0x2f, 0x00, 0x01],
|
||||
/** Fidesmo service discovery applet */
|
||||
fidesmo: [0xa0, 0x00, 0x00, 0x06, 0x17, 0x00],
|
||||
/** NFC Forum Type 4 Tag NDEF applet */
|
||||
ndefTag: [0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01],
|
||||
/** VivoKey OTP applet */
|
||||
vivokeyOtp: [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01],
|
||||
/** PIV (Personal Identity Verification) */
|
||||
piv: [0xa0, 0x00, 0x00, 0x03, 0x08],
|
||||
/** OATH (OTP) applet */
|
||||
oath: [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01],
|
||||
/** JavaCard Memory Manager - present on Apex and flexSecure implants */
|
||||
javacardMemory: [
|
||||
0xa0, 0x00, 0x00, 0x08, 0x46, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x01,
|
||||
],
|
||||
|
||||
// Payment network AIDs (EMV)
|
||||
/** Visa Credit/Debit */
|
||||
visaCredit: [0xa0, 0x00, 0x00, 0x00, 0x03, 0x10, 0x10],
|
||||
/** Visa Electron */
|
||||
visaElectron: [0xa0, 0x00, 0x00, 0x00, 0x03, 0x20, 0x10],
|
||||
/** Mastercard Credit/Debit */
|
||||
mastercard: [0xa0, 0x00, 0x00, 0x00, 0x04, 0x10, 0x10],
|
||||
/** Mastercard Maestro */
|
||||
maestro: [0xa0, 0x00, 0x00, 0x00, 0x04, 0x30, 0x60],
|
||||
/** American Express */
|
||||
amex: [0xa0, 0x00, 0x00, 0x00, 0x25, 0x01, 0x08, 0x01],
|
||||
/** Discover */
|
||||
discover: [0xa0, 0x00, 0x00, 0x01, 0x52, 0x30, 0x10],
|
||||
/** JCB */
|
||||
jcb: [0xa0, 0x00, 0x00, 0x00, 0x65, 0x10, 0x10],
|
||||
/** UnionPay */
|
||||
unionpay: [0xa0, 0x00, 0x00, 0x03, 0x33, 0x01, 0x01, 0x01],
|
||||
/** PPSE (Proximity Payment System Environment) - present on all contactless payment cards */
|
||||
ppse: [0x32, 0x50, 0x41, 0x59, 0x2e, 0x53, 0x59, 0x53, 0x2e, 0x44, 0x44, 0x46, 0x30, 0x31], // "2PAY.SYS.DDF01"
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Command Execution
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send a raw command to NFC-A tag (Type 2 tags like NTAG)
|
||||
*/
|
||||
export async function transceiveNfcA(command: number[]): Promise<number[]> {
|
||||
try {
|
||||
const response = await NfcManager.nfcAHandler.transceive(command);
|
||||
return Array.from(response);
|
||||
} catch (error) {
|
||||
// Use debug level - some failures are expected (e.g., GET_VERSION on original Ultralight)
|
||||
console.debug('[commands] NfcA transceive failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ISO-DEP APDU command
|
||||
*/
|
||||
export async function transceiveIsoDep(command: number[]): Promise<number[]> {
|
||||
try {
|
||||
const response = await NfcManager.isoDepHandler.transceive(command);
|
||||
return Array.from(response);
|
||||
} catch (error) {
|
||||
console.error('[commands] IsoDep transceive failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command via iOS MIFARE handler (covers NFC-A and ISO-DEP on iOS)
|
||||
*/
|
||||
export async function transceiveMifareIOS(
|
||||
command: number[],
|
||||
): Promise<number[]> {
|
||||
try {
|
||||
const response = await NfcManager.sendMifareCommandIOS(command);
|
||||
return Array.from(response);
|
||||
} catch (error) {
|
||||
console.error('[commands] MifareIOS transceive failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command via iOS using isoDepHandler (for ISO-DEP/ISO 14443-4 tags)
|
||||
* This works when the tag supports ISO-DEP
|
||||
*/
|
||||
export async function transceiveIsoDepIOS(command: number[]): Promise<number[]> {
|
||||
try {
|
||||
// Try isoDepHandler first - works for ISO 14443-4 tags
|
||||
const response = await NfcManager.isoDepHandler.transceive(command);
|
||||
return Array.from(response);
|
||||
} catch (error) {
|
||||
console.error('[commands] isoDepHandler transceive failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ISO 15693 (NFC-V) command
|
||||
* Uses nfcVHandler on Android, iso15693HandlerIOS on iOS
|
||||
*/
|
||||
export async function transceiveNfcV(command: number[]): Promise<number[]> {
|
||||
try {
|
||||
// Android uses nfcVHandler.transceive for raw commands
|
||||
const response = await NfcManager.nfcVHandler.transceive(command);
|
||||
return Array.from(response);
|
||||
} catch (error) {
|
||||
console.error('[commands] NfcV transceive failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO 15693 system info using platform-specific method
|
||||
* iOS has direct getSystemInfo method, Android uses raw command
|
||||
*/
|
||||
export interface Iso15693SystemInfo {
|
||||
dsfid?: number;
|
||||
afi?: number;
|
||||
blockSize?: number;
|
||||
blockCount?: number;
|
||||
icReference?: number;
|
||||
}
|
||||
|
||||
export async function getIso15693SystemInfo(): Promise<Iso15693SystemInfo> {
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS has direct method with typed response
|
||||
try {
|
||||
const result = await NfcManager.iso15693HandlerIOS.getSystemInfo(0x02);
|
||||
return {
|
||||
dsfid: result.dsfid,
|
||||
afi: result.afi,
|
||||
blockSize: result.blockSize,
|
||||
blockCount: result.blockCount,
|
||||
icReference: result.icReference,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[commands] iOS getSystemInfo failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Android: use raw command and parse response
|
||||
const response = await transceiveNfcV(ISO15693_GET_SYSTEM_INFO);
|
||||
|
||||
// Parse the response (simplified - may need adjustment based on actual response format)
|
||||
if (response.length < 2 || (response[0] & 0x01)) {
|
||||
throw new Error('GET_SYSTEM_INFO failed or returned error');
|
||||
}
|
||||
|
||||
// Response parsing depends on info flags
|
||||
const infoFlags = response[1];
|
||||
let offset = 10; // Skip flags and UID
|
||||
|
||||
const result: Iso15693SystemInfo = {};
|
||||
|
||||
if ((infoFlags & 0x01) && response.length > offset) {
|
||||
result.dsfid = response[offset++];
|
||||
}
|
||||
if ((infoFlags & 0x02) && response.length > offset) {
|
||||
result.afi = response[offset++];
|
||||
}
|
||||
if ((infoFlags & 0x04) && response.length >= offset + 2) {
|
||||
result.blockCount = response[offset] + 1;
|
||||
result.blockSize = (response[offset + 1] & 0x1f) + 1;
|
||||
offset += 2;
|
||||
}
|
||||
if ((infoFlags & 0x08) && response.length > offset) {
|
||||
result.icReference = response[offset];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-aware command sending for Type 2 tags (NTAG)
|
||||
*/
|
||||
export async function sendType2Command(command: number[]): Promise<number[]> {
|
||||
if (Platform.OS === 'ios') {
|
||||
return transceiveMifareIOS(command);
|
||||
}
|
||||
return transceiveNfcA(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-aware command sending for ISO-DEP tags (DESFire, JavaCard)
|
||||
*/
|
||||
export async function sendIsoDepCommand(command: number[]): Promise<number[]> {
|
||||
console.log(`[commands] sendIsoDepCommand on ${Platform.OS}:`, command);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
// Try isoDepHandler first (works for ISO 14443-4 tags)
|
||||
try {
|
||||
console.log('[commands] Trying isoDepHandler...');
|
||||
const response = await transceiveIsoDepIOS(command);
|
||||
console.log('[commands] isoDepHandler success:', response);
|
||||
return response;
|
||||
} catch (isoDepError) {
|
||||
console.log('[commands] isoDepHandler failed:', isoDepError);
|
||||
// Fall back to MifareIOS if isoDepHandler fails
|
||||
console.log('[commands] Trying MifareIOS fallback...');
|
||||
const response = await transceiveMifareIOS(command);
|
||||
console.log('[commands] MifareIOS success:', response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[commands] Using Android isoDepHandler...');
|
||||
const response = await transceiveIsoDep(command);
|
||||
console.log('[commands] Android isoDepHandler success:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request specific NFC technology
|
||||
*/
|
||||
export async function requestTechnology(
|
||||
tech: NfcTech | NfcTech[],
|
||||
options?: {alertMessage?: string},
|
||||
): Promise<void> {
|
||||
await NfcManager.requestTechnology(tech, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel technology request and cleanup
|
||||
*/
|
||||
export async function cancelTechnologyRequest(): Promise<void> {
|
||||
try {
|
||||
await NfcManager.cancelTechnologyRequest();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
471
src/types/detection.ts
Normal file
471
src/types/detection.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Detection Types
|
||||
* Types for chip identification and transponder detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported chip types
|
||||
*/
|
||||
export enum ChipType {
|
||||
// NTAG 21x family (ISO 14443-3A, Type 2)
|
||||
NTAG213 = 'NTAG213',
|
||||
NTAG215 = 'NTAG215',
|
||||
NTAG216 = 'NTAG216',
|
||||
NTAG_I2C_1K = 'NTAG_I2C_1K',
|
||||
NTAG_I2C_2K = 'NTAG_I2C_2K',
|
||||
NTAG_I2C_PLUS_1K = 'NTAG_I2C_PLUS_1K',
|
||||
NTAG_I2C_PLUS_2K = 'NTAG_I2C_PLUS_2K',
|
||||
|
||||
// NTAG 5 family (ISO 15693, NFC-V)
|
||||
NTAG5_LINK = 'NTAG5_LINK',
|
||||
NTAG5_BOOST = 'NTAG5_BOOST',
|
||||
NTAG5_SWITCH = 'NTAG5_SWITCH',
|
||||
|
||||
// NTAG DNA family (ISO 14443-4, Type 4)
|
||||
NTAG413_DNA = 'NTAG413_DNA',
|
||||
NTAG424_DNA = 'NTAG424_DNA',
|
||||
NTAG424_DNA_TT = 'NTAG424_DNA_TT', // TagTamper variant
|
||||
|
||||
NTAG_UNKNOWN = 'NTAG_UNKNOWN',
|
||||
|
||||
// MIFARE Classic family
|
||||
MIFARE_CLASSIC_1K = 'MIFARE_CLASSIC_1K',
|
||||
MIFARE_CLASSIC_4K = 'MIFARE_CLASSIC_4K',
|
||||
MIFARE_CLASSIC_MINI = 'MIFARE_CLASSIC_MINI',
|
||||
|
||||
// MIFARE DESFire family
|
||||
DESFIRE_EV1 = 'DESFIRE_EV1',
|
||||
DESFIRE_EV2 = 'DESFIRE_EV2',
|
||||
DESFIRE_EV3 = 'DESFIRE_EV3',
|
||||
DESFIRE_LIGHT = 'DESFIRE_LIGHT',
|
||||
DESFIRE_UNKNOWN = 'DESFIRE_UNKNOWN',
|
||||
|
||||
// MIFARE Plus
|
||||
MIFARE_PLUS_S = 'MIFARE_PLUS_S',
|
||||
MIFARE_PLUS_X = 'MIFARE_PLUS_X',
|
||||
MIFARE_PLUS_SE = 'MIFARE_PLUS_SE',
|
||||
MIFARE_PLUS_EV1 = 'MIFARE_PLUS_EV1',
|
||||
MIFARE_PLUS = 'MIFARE_PLUS', // Generic
|
||||
|
||||
// MIFARE Ultralight family
|
||||
ULTRALIGHT = 'ULTRALIGHT',
|
||||
ULTRALIGHT_C = 'ULTRALIGHT_C',
|
||||
ULTRALIGHT_EV1 = 'ULTRALIGHT_EV1',
|
||||
ULTRALIGHT_NANO = 'ULTRALIGHT_NANO',
|
||||
ULTRALIGHT_AES = 'ULTRALIGHT_AES',
|
||||
|
||||
// ISO 15693 (NFC-V) - ICODE family
|
||||
SLIX = 'SLIX',
|
||||
SLIX2 = 'SLIX2',
|
||||
SLIX_S = 'SLIX_S',
|
||||
SLIX_L = 'SLIX_L',
|
||||
ICODE_DNA = 'ICODE_DNA',
|
||||
ISO15693_UNKNOWN = 'ISO15693_UNKNOWN',
|
||||
|
||||
// JavaCard
|
||||
JCOP4 = 'JCOP4',
|
||||
JAVACARD_UNKNOWN = 'JAVACARD_UNKNOWN',
|
||||
|
||||
// Generic/Unknown
|
||||
ISO14443A_UNKNOWN = 'ISO14443A_UNKNOWN',
|
||||
ISO14443B_UNKNOWN = 'ISO14443B_UNKNOWN',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip family categories
|
||||
*/
|
||||
export enum ChipFamily {
|
||||
NTAG = 'NTAG',
|
||||
MIFARE_CLASSIC = 'MIFARE_CLASSIC',
|
||||
MIFARE_DESFIRE = 'MIFARE_DESFIRE',
|
||||
MIFARE_PLUS = 'MIFARE_PLUS',
|
||||
ISO15693 = 'ISO15693',
|
||||
JAVACARD = 'JAVACARD',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chip family for a chip type
|
||||
*/
|
||||
export function getChipFamily(type: ChipType): ChipFamily {
|
||||
if (type.startsWith('NTAG')) {
|
||||
return ChipFamily.NTAG;
|
||||
}
|
||||
if (type.startsWith('ULTRALIGHT')) {
|
||||
return ChipFamily.NTAG; // Ultralight is in the NTAG/Type 2 family
|
||||
}
|
||||
if (type.startsWith('MIFARE_CLASSIC')) {
|
||||
return ChipFamily.MIFARE_CLASSIC;
|
||||
}
|
||||
if (type.startsWith('DESFIRE')) {
|
||||
return ChipFamily.MIFARE_DESFIRE;
|
||||
}
|
||||
if (type.startsWith('MIFARE_PLUS')) {
|
||||
return ChipFamily.MIFARE_PLUS;
|
||||
}
|
||||
if (type.startsWith('SLIX') || type.startsWith('ICODE') || type.startsWith('ISO15693')) {
|
||||
return ChipFamily.ISO15693;
|
||||
}
|
||||
if (type.startsWith('JCOP') || type.startsWith('JAVACARD')) {
|
||||
return ChipFamily.JAVACARD;
|
||||
}
|
||||
return ChipFamily.UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* NTAG version information from GET_VERSION response
|
||||
*/
|
||||
export interface NtagVersionInfo {
|
||||
vendorId: number;
|
||||
productType: number;
|
||||
productSubtype: number;
|
||||
majorVersion: number;
|
||||
minorVersion: number;
|
||||
storageSize: number;
|
||||
protocolType: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DESFire version information
|
||||
*/
|
||||
export interface DesfireVersionInfo {
|
||||
hardwareMajor: number;
|
||||
hardwareMinor: number;
|
||||
hardwareStorageSize: number;
|
||||
softwareMajor: number;
|
||||
softwareMinor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SAK swap detection result (imported from mifare detector)
|
||||
*/
|
||||
export interface SakSwapInfo {
|
||||
hasSakSwap: boolean;
|
||||
swapType?:
|
||||
| 'mifare_plus_sl1'
|
||||
| 'desfire_with_classic'
|
||||
| 'magic_card'
|
||||
| 'unknown';
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detected transponder information
|
||||
*/
|
||||
export interface Transponder {
|
||||
/** Identified chip type */
|
||||
type: ChipType;
|
||||
|
||||
/** Chip family category */
|
||||
family: ChipFamily;
|
||||
|
||||
/** Human-readable chip name */
|
||||
chipName: string;
|
||||
|
||||
/** Memory size in bytes (if known) */
|
||||
memorySize?: number;
|
||||
|
||||
/** Whether the chip data can be cloned to an implant */
|
||||
isCloneable: boolean;
|
||||
|
||||
/** Reason why chip is not cloneable (if applicable) */
|
||||
cloneabilityNote?: string;
|
||||
|
||||
/** Raw detection data */
|
||||
rawData: {
|
||||
uid: string;
|
||||
sak?: number;
|
||||
atqa?: string;
|
||||
ats?: string;
|
||||
historicalBytes?: string;
|
||||
techTypes: string[];
|
||||
};
|
||||
|
||||
/** Chip-specific version info */
|
||||
versionInfo?: NtagVersionInfo | DesfireVersionInfo;
|
||||
|
||||
/** SAK swap detection results */
|
||||
sakSwapInfo?: SakSwapInfo;
|
||||
|
||||
/** Implant name found in memory (for Type 2 tags) */
|
||||
implantName?: string;
|
||||
|
||||
/** Detection confidence level */
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
|
||||
/** Platform on which detection was performed */
|
||||
detectedOn: 'ios' | 'android';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection result
|
||||
*/
|
||||
export interface DetectionResult {
|
||||
success: boolean;
|
||||
transponder?: Transponder;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable names for chip types
|
||||
*/
|
||||
export const CHIP_NAMES: Record<ChipType, string> = {
|
||||
// NTAG 21x family
|
||||
[ChipType.NTAG213]: 'NTAG213',
|
||||
[ChipType.NTAG215]: 'NTAG215',
|
||||
[ChipType.NTAG216]: 'NTAG216',
|
||||
[ChipType.NTAG_I2C_1K]: 'NTAG I2C 1K',
|
||||
[ChipType.NTAG_I2C_2K]: 'NTAG I2C 2K',
|
||||
[ChipType.NTAG_I2C_PLUS_1K]: 'NTAG I2C Plus 1K',
|
||||
[ChipType.NTAG_I2C_PLUS_2K]: 'NTAG I2C Plus 2K',
|
||||
|
||||
// NTAG 5 family
|
||||
[ChipType.NTAG5_LINK]: 'NTAG 5 link',
|
||||
[ChipType.NTAG5_BOOST]: 'NTAG 5 boost',
|
||||
[ChipType.NTAG5_SWITCH]: 'NTAG 5 switch',
|
||||
|
||||
// NTAG DNA family
|
||||
[ChipType.NTAG413_DNA]: 'NTAG 413 DNA',
|
||||
[ChipType.NTAG424_DNA]: 'NTAG 424 DNA',
|
||||
[ChipType.NTAG424_DNA_TT]: 'NTAG 424 DNA TagTamper',
|
||||
|
||||
[ChipType.NTAG_UNKNOWN]: 'NTAG (Unknown variant)',
|
||||
|
||||
// MIFARE Classic
|
||||
[ChipType.MIFARE_CLASSIC_1K]: 'MIFARE Classic 1K',
|
||||
[ChipType.MIFARE_CLASSIC_4K]: 'MIFARE Classic 4K',
|
||||
[ChipType.MIFARE_CLASSIC_MINI]: 'MIFARE Classic Mini',
|
||||
|
||||
// MIFARE DESFire
|
||||
[ChipType.DESFIRE_EV1]: 'MIFARE DESFire EV1',
|
||||
[ChipType.DESFIRE_EV2]: 'MIFARE DESFire EV2',
|
||||
[ChipType.DESFIRE_EV3]: 'MIFARE DESFire EV3',
|
||||
[ChipType.DESFIRE_LIGHT]: 'MIFARE DESFire Light',
|
||||
[ChipType.DESFIRE_UNKNOWN]: 'MIFARE DESFire (Unknown version)',
|
||||
|
||||
// MIFARE Plus
|
||||
[ChipType.MIFARE_PLUS_S]: 'MIFARE Plus S',
|
||||
[ChipType.MIFARE_PLUS_X]: 'MIFARE Plus X',
|
||||
[ChipType.MIFARE_PLUS_SE]: 'MIFARE Plus SE',
|
||||
[ChipType.MIFARE_PLUS_EV1]: 'MIFARE Plus EV1',
|
||||
[ChipType.MIFARE_PLUS]: 'MIFARE Plus',
|
||||
|
||||
// MIFARE Ultralight
|
||||
[ChipType.ULTRALIGHT]: 'MIFARE Ultralight',
|
||||
[ChipType.ULTRALIGHT_C]: 'MIFARE Ultralight C',
|
||||
[ChipType.ULTRALIGHT_EV1]: 'MIFARE Ultralight EV1',
|
||||
[ChipType.ULTRALIGHT_NANO]: 'MIFARE Ultralight Nano',
|
||||
[ChipType.ULTRALIGHT_AES]: 'MIFARE Ultralight AES',
|
||||
|
||||
// ICODE family
|
||||
[ChipType.SLIX]: 'ICODE SLIX',
|
||||
[ChipType.SLIX2]: 'ICODE SLIX2',
|
||||
[ChipType.SLIX_S]: 'ICODE SLIX-S',
|
||||
[ChipType.SLIX_L]: 'ICODE SLIX-L',
|
||||
[ChipType.ICODE_DNA]: 'ICODE DNA',
|
||||
[ChipType.ISO15693_UNKNOWN]: 'ISO 15693 Tag',
|
||||
|
||||
// JavaCard
|
||||
[ChipType.JCOP4]: 'JCOP4 (J3R180)',
|
||||
[ChipType.JAVACARD_UNKNOWN]: 'JavaCard',
|
||||
|
||||
// Generic/Unknown
|
||||
[ChipType.ISO14443A_UNKNOWN]: 'ISO 14443-A Tag',
|
||||
[ChipType.ISO14443B_UNKNOWN]: 'ISO 14443-B Tag',
|
||||
[ChipType.UNKNOWN]: 'Unknown NFC Tag',
|
||||
};
|
||||
|
||||
/**
|
||||
* Memory sizes for known chip types (in bytes)
|
||||
*/
|
||||
export const CHIP_MEMORY_SIZES: Partial<Record<ChipType, number>> = {
|
||||
// NTAG 21x
|
||||
[ChipType.NTAG213]: 144,
|
||||
[ChipType.NTAG215]: 504,
|
||||
[ChipType.NTAG216]: 888,
|
||||
[ChipType.NTAG_I2C_1K]: 888,
|
||||
[ChipType.NTAG_I2C_2K]: 1912,
|
||||
[ChipType.NTAG_I2C_PLUS_1K]: 888,
|
||||
[ChipType.NTAG_I2C_PLUS_2K]: 1912,
|
||||
|
||||
// NTAG 5 family
|
||||
[ChipType.NTAG5_LINK]: 496, // 496 bytes user memory
|
||||
[ChipType.NTAG5_BOOST]: 2000, // 2000 bytes user memory
|
||||
[ChipType.NTAG5_SWITCH]: 256, // 256 bytes user memory
|
||||
|
||||
// NTAG DNA family
|
||||
[ChipType.NTAG413_DNA]: 160, // 160 bytes user memory
|
||||
[ChipType.NTAG424_DNA]: 416, // 416 bytes user memory (NDEF)
|
||||
[ChipType.NTAG424_DNA_TT]: 416,
|
||||
|
||||
// MIFARE Classic
|
||||
[ChipType.MIFARE_CLASSIC_1K]: 1024,
|
||||
[ChipType.MIFARE_CLASSIC_4K]: 4096,
|
||||
[ChipType.MIFARE_CLASSIC_MINI]: 320,
|
||||
|
||||
// MIFARE Ultralight
|
||||
[ChipType.ULTRALIGHT]: 48, // 48 bytes user memory
|
||||
[ChipType.ULTRALIGHT_C]: 144, // 144 bytes user memory
|
||||
[ChipType.ULTRALIGHT_EV1]: 128, // 128 bytes (EV1 80 page variant)
|
||||
[ChipType.ULTRALIGHT_NANO]: 48,
|
||||
[ChipType.ULTRALIGHT_AES]: 540, // 540 bytes user memory
|
||||
};
|
||||
|
||||
/**
|
||||
* Cloneability information for chip types
|
||||
*/
|
||||
export const CHIP_CLONEABILITY: Record<
|
||||
ChipType,
|
||||
{cloneable: boolean; note?: string}
|
||||
> = {
|
||||
// NTAG 21x - all cloneable
|
||||
[ChipType.NTAG213]: {cloneable: true},
|
||||
[ChipType.NTAG215]: {cloneable: true},
|
||||
[ChipType.NTAG216]: {cloneable: true},
|
||||
[ChipType.NTAG_I2C_1K]: {cloneable: true, note: 'I2C interface not cloneable'},
|
||||
[ChipType.NTAG_I2C_2K]: {cloneable: true, note: 'I2C interface not cloneable'},
|
||||
[ChipType.NTAG_I2C_PLUS_1K]: {cloneable: true, note: 'I2C interface not cloneable'},
|
||||
[ChipType.NTAG_I2C_PLUS_2K]: {cloneable: true, note: 'I2C interface not cloneable'},
|
||||
|
||||
// NTAG 5 family - NOT cloneable (originality signature, password protection)
|
||||
[ChipType.NTAG5_LINK]: {
|
||||
cloneable: false,
|
||||
note: 'Originality signature prevents cloning',
|
||||
},
|
||||
[ChipType.NTAG5_BOOST]: {
|
||||
cloneable: false,
|
||||
note: 'Originality signature prevents cloning',
|
||||
},
|
||||
[ChipType.NTAG5_SWITCH]: {
|
||||
cloneable: false,
|
||||
note: 'Originality signature prevents cloning',
|
||||
},
|
||||
|
||||
// NTAG DNA family - NOT cloneable (AES-128 crypto, originality signature)
|
||||
[ChipType.NTAG413_DNA]: {
|
||||
cloneable: false,
|
||||
note: 'AES-128 authentication and SUN messaging prevent cloning',
|
||||
},
|
||||
[ChipType.NTAG424_DNA]: {
|
||||
cloneable: false,
|
||||
note: 'AES-128 authentication and SUN messaging prevent cloning',
|
||||
},
|
||||
[ChipType.NTAG424_DNA_TT]: {
|
||||
cloneable: false,
|
||||
note: 'AES-128 authentication and tamper detection prevent cloning',
|
||||
},
|
||||
|
||||
[ChipType.NTAG_UNKNOWN]: {cloneable: true, note: 'May require verification'},
|
||||
|
||||
// MIFARE Classic - cloneable with keys
|
||||
[ChipType.MIFARE_CLASSIC_1K]: {
|
||||
cloneable: true,
|
||||
note: 'Requires key knowledge; Android only for sector operations',
|
||||
},
|
||||
[ChipType.MIFARE_CLASSIC_4K]: {
|
||||
cloneable: true,
|
||||
note: 'Requires key knowledge; Android only for sector operations',
|
||||
},
|
||||
[ChipType.MIFARE_CLASSIC_MINI]: {
|
||||
cloneable: true,
|
||||
note: 'Requires key knowledge; Android only for sector operations',
|
||||
},
|
||||
|
||||
// MIFARE DESFire - NOT cloneable (strong crypto)
|
||||
[ChipType.DESFIRE_EV1]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.DESFIRE_EV2]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.DESFIRE_EV3]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.DESFIRE_LIGHT]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.DESFIRE_UNKNOWN]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic protection prevents cloning',
|
||||
},
|
||||
|
||||
// MIFARE Plus - NOT cloneable (AES crypto)
|
||||
[ChipType.MIFARE_PLUS_S]: {
|
||||
cloneable: false,
|
||||
note: 'AES cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.MIFARE_PLUS_X]: {
|
||||
cloneable: false,
|
||||
note: 'AES cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.MIFARE_PLUS_SE]: {
|
||||
cloneable: false,
|
||||
note: 'AES cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.MIFARE_PLUS_EV1]: {
|
||||
cloneable: false,
|
||||
note: 'AES cryptographic protection prevents cloning',
|
||||
},
|
||||
[ChipType.MIFARE_PLUS]: {
|
||||
cloneable: false,
|
||||
note: 'AES cryptographic protection prevents cloning',
|
||||
},
|
||||
|
||||
// MIFARE Ultralight - cloneable (basic variants)
|
||||
[ChipType.ULTRALIGHT]: {cloneable: true},
|
||||
[ChipType.ULTRALIGHT_C]: {
|
||||
cloneable: true,
|
||||
note: '3DES protection may require key knowledge',
|
||||
},
|
||||
[ChipType.ULTRALIGHT_EV1]: {cloneable: true},
|
||||
[ChipType.ULTRALIGHT_NANO]: {cloneable: true},
|
||||
[ChipType.ULTRALIGHT_AES]: {
|
||||
cloneable: false,
|
||||
note: 'AES authentication prevents cloning without keys',
|
||||
},
|
||||
|
||||
// ICODE family - cloneable (basic variants), DNA has crypto
|
||||
[ChipType.SLIX]: {cloneable: true},
|
||||
[ChipType.SLIX2]: {cloneable: true},
|
||||
[ChipType.SLIX_S]: {cloneable: true},
|
||||
[ChipType.SLIX_L]: {cloneable: true},
|
||||
[ChipType.ICODE_DNA]: {
|
||||
cloneable: false,
|
||||
note: 'Cryptographic authentication prevents cloning',
|
||||
},
|
||||
[ChipType.ISO15693_UNKNOWN]: {
|
||||
cloneable: true,
|
||||
note: 'May require verification',
|
||||
},
|
||||
|
||||
// JavaCard - NOT cloneable
|
||||
[ChipType.JCOP4]: {
|
||||
cloneable: false,
|
||||
note: 'Secure element prevents cloning',
|
||||
},
|
||||
[ChipType.JAVACARD_UNKNOWN]: {
|
||||
cloneable: false,
|
||||
note: 'Secure element prevents cloning',
|
||||
},
|
||||
|
||||
// Unknown types
|
||||
[ChipType.ISO14443A_UNKNOWN]: {
|
||||
cloneable: false,
|
||||
note: 'Unknown chip - cannot determine cloneability',
|
||||
},
|
||||
[ChipType.ISO14443B_UNKNOWN]: {
|
||||
cloneable: false,
|
||||
note: 'Unknown chip - cannot determine cloneability',
|
||||
},
|
||||
[ChipType.UNKNOWN]: {
|
||||
cloneable: false,
|
||||
note: 'Unknown chip - cannot determine cloneability',
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
|
||||
import type {NfcTechType} from './nfc';
|
||||
import type {Transponder} from './detection';
|
||||
|
||||
export type TagDataParam = {
|
||||
uid: string;
|
||||
@@ -15,6 +16,7 @@ export type RootStackParamList = {
|
||||
Scan: undefined;
|
||||
Result: {
|
||||
tagData?: TagDataParam;
|
||||
transponder?: Transponder;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,32 @@ export type NfcTechType =
|
||||
| 'Iso7816'
|
||||
| 'Iso15693';
|
||||
|
||||
/**
|
||||
* NDEF record from tag
|
||||
*/
|
||||
export interface NdefRecord {
|
||||
/** Type Name Format */
|
||||
tnf: number;
|
||||
/** Record type as string */
|
||||
type: string;
|
||||
/** Record ID */
|
||||
id?: string;
|
||||
/** Payload data as byte array */
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* MIFARE Classic specific info from Android
|
||||
*/
|
||||
export interface MifareClassicInfo {
|
||||
/** Total memory size in bytes */
|
||||
size: number;
|
||||
/** Number of sectors */
|
||||
sectorCount: number;
|
||||
/** Total number of blocks */
|
||||
blockCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw tag data from NFC scan
|
||||
*/
|
||||
@@ -36,6 +62,10 @@ export interface RawTagData {
|
||||
historicalBytes?: string;
|
||||
/** Maximum transceive length */
|
||||
maxTransceiveLength?: number;
|
||||
/** Cached NDEF records (if tag supports NDEF) */
|
||||
ndefRecords?: NdefRecord[];
|
||||
/** MIFARE Classic info (Android only) */
|
||||
mifareClassic?: MifareClassicInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
78
src/types/products.ts
Normal file
78
src/types/products.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Product Types for Dangerous Things Implants
|
||||
*/
|
||||
|
||||
import {ChipType} from './detection';
|
||||
|
||||
/**
|
||||
* Product form factor
|
||||
*/
|
||||
export enum FormFactor {
|
||||
X_SERIES = 'X_SERIES', // 2x12mm injectable capsule (glass or bioresin)
|
||||
FLEX = 'FLEX', // Flexible PCB implant (incision install)
|
||||
BIORESIN = 'BIORESIN', // Larger bioresin capsule (incision install)
|
||||
CARD = 'CARD', // ISO card format
|
||||
}
|
||||
|
||||
/**
|
||||
* Product category
|
||||
*/
|
||||
export enum ProductCategory {
|
||||
NFC = 'NFC', // NFC-only implants
|
||||
DUAL_FREQUENCY = 'DUAL_FREQUENCY', // NFC + 125kHz
|
||||
SECURE = 'SECURE', // Cryptographic/secure elements
|
||||
LED = 'LED', // Implants with LED indicators
|
||||
ACCESS = 'ACCESS', // Access control focused
|
||||
}
|
||||
|
||||
/**
|
||||
* DESFire EV level for version matching
|
||||
*/
|
||||
export type DesfireEvLevel = 1 | 2 | 3;
|
||||
|
||||
/**
|
||||
* A Dangerous Things product
|
||||
*/
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
formFactor: FormFactor;
|
||||
categories: ProductCategory[];
|
||||
compatibleChips: ChipType[];
|
||||
features: string[];
|
||||
url: string;
|
||||
/** Whether this product can have data cloned TO it from the scanned chip */
|
||||
canReceiveClone: boolean;
|
||||
/** Whether this product uses the same chip type as scanned */
|
||||
exactMatch: boolean;
|
||||
/** Notes about compatibility or limitations */
|
||||
notes?: string;
|
||||
/** DESFire EV level (1, 2, or 3) for version mismatch warnings */
|
||||
desfireEvLevel?: DesfireEvLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of product matching
|
||||
*/
|
||||
export interface MatchResult {
|
||||
/** Products that use the exact same chip */
|
||||
exactMatches: Product[];
|
||||
/** Products that can receive cloned data from this chip */
|
||||
cloneTargets: Product[];
|
||||
/** Products in the same chip family */
|
||||
familyMatches: Product[];
|
||||
/** Is this chip cloneable at all? */
|
||||
isCloneable: boolean;
|
||||
/** Note about cloneability */
|
||||
cloneabilityNote?: string;
|
||||
/** Conversion service recommendation */
|
||||
conversionRecommended: boolean;
|
||||
/** Conversion URL */
|
||||
conversionUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip to product mapping for quick lookup
|
||||
*/
|
||||
export type ChipProductMap = Map<ChipType, Product[]>;
|
||||
33
src/utils/logger.ts
Normal file
33
src/utils/logger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Logger utility that only outputs in development mode
|
||||
* All console.log statements in the app should use this logger
|
||||
*/
|
||||
|
||||
const isDev = __DEV__;
|
||||
|
||||
export const logger = {
|
||||
log: (...args: unknown[]) => {
|
||||
if (isDev) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
|
||||
warn: (...args: unknown[]) => {
|
||||
if (isDev) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
|
||||
error: (...args: unknown[]) => {
|
||||
// Always log errors, even in production
|
||||
console.error(...args);
|
||||
},
|
||||
|
||||
debug: (...args: unknown[]) => {
|
||||
if (isDev) {
|
||||
console.log('[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
@@ -2,7 +2,10 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"lib": [
|
||||
"ES2021",
|
||||
"DOM"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
@@ -13,8 +16,21 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["jest"]
|
||||
"types": [
|
||||
"jest"
|
||||
]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"exclude": ["node_modules", "android", "ios"]
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"android",
|
||||
"ios",
|
||||
"../react-native-dt-theme"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user