Fixed UID length bug for product suggestions re: MFC and payment verbiage

This commit is contained in:
michael
2026-01-26 12:42:27 -08:00
parent e597728883
commit b27baf5dec
12 changed files with 349 additions and 148 deletions

View File

@@ -14,7 +14,8 @@
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.dangerousthings.nfcidentifier", "bundleIdentifier": "com.dangerousthings.nfcidentifier",
"infoPlist": { "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": { "android": {

View File

@@ -11,6 +11,12 @@
"preview": { "preview": {
"distribution": "internal" "distribution": "internal"
}, },
"preview-apk": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": { "production": {
"autoIncrement": true "autoIncrement": true
} }

View File

@@ -8,7 +8,7 @@
* Discontinued products removed per https://forum.dangerousthings.com/t/shes-dead-jim/26308 * Discontinued products removed per https://forum.dangerousthings.com/t/shes-dead-jim/26308
*/ */
import {ChipType} from '../types/detection'; import { ChipType } from '../types/detection';
import { import {
Product, Product,
FormFactor, FormFactor,
@@ -72,7 +72,7 @@ export const PRODUCTS: Product[] = [
id: 'xnt', id: 'xnt',
name: 'xNT', name: 'xNT',
description: description:
'NFC Type 2 implant with 888 bytes of user memory. The original and most popular NFC implant.', 'NFC Type 2 implant with 888 bytes of user memory.',
formFactor: FormFactor.X_SERIES, formFactor: FormFactor.X_SERIES,
categories: [ProductCategory.NFC], categories: [ProductCategory.NFC],
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE], compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
@@ -96,9 +96,9 @@ export const PRODUCTS: Product[] = [
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE], compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
features: [ features: [
'NTAG I2C chip', 'NTAG I2C chip',
'888 bytes user memory', '1kB user memory',
'Built-in LED indicator', 'URL/contact sharing',
'Multiple colors available', 'Red, Green, Blue, and White LEDs available',
], ],
url: 'https://dangerousthings.com/product/xsiid/', url: 'https://dangerousthings.com/product/xsiid/',
canReceiveClone: true, canReceiveClone: true,
@@ -107,14 +107,14 @@ export const PRODUCTS: Product[] = [
{ {
id: 'xslx', id: 'xslx',
name: 'xSLX', name: 'xSLX',
description: 'ISO 15693 NFC-V implant using ICODE SLIX chip.', description: 'NFC Type 5 implant using ICODE SLIX chip.',
formFactor: FormFactor.X_SERIES, formFactor: FormFactor.X_SERIES,
categories: [ProductCategory.NFC], categories: [ProductCategory.NFC],
compatibleChips: [...SLIX_COMPATIBLE], compatibleChips: [...SLIX_COMPATIBLE],
features: [ features: [
'ICODE SLIX chip', 'ICODE SLIX chip',
'ISO 15693 (NFC-V)', '320 bbytes user memory',
'Long range capability', 'URL/contact sharing',
], ],
url: 'https://dangerousthings.com/product/xslx/', url: 'https://dangerousthings.com/product/xslx/',
canReceiveClone: true, canReceiveClone: true,
@@ -133,10 +133,11 @@ export const PRODUCTS: Product[] = [
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS], categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS],
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE], compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
features: [ features: [
'Dual-frequency',
'NTAG216 NFC chip', 'NTAG216 NFC chip',
'888 bytes user memory',
'T5577 125kHz chip', 'T5577 125kHz chip',
'Dual-frequency capability', 'LF access control',
'HID/EM compatible',
], ],
url: 'https://dangerousthings.com/product/next/', url: 'https://dangerousthings.com/product/next/',
canReceiveClone: true, canReceiveClone: true,
@@ -147,15 +148,17 @@ export const PRODUCTS: Product[] = [
id: 'next-v2', id: 'next-v2',
name: 'NExT v2', name: 'NExT v2',
description: description:
'Updated dual-frequency implant with NTAG I2C, T5577, and built-in LED indicator.', 'Updated dual-frequency implant with NTAG I2C, T5577, and built-in LED.',
formFactor: FormFactor.X_SERIES, formFactor: FormFactor.X_SERIES,
categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS, ProductCategory.LED], categories: [ProductCategory.DUAL_FREQUENCY, ProductCategory.ACCESS, ProductCategory.LED],
compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE], compatibleChips: [...NTAG21X_COMPATIBLE, ...NTAG_I2C_COMPATIBLE],
features: [ features: [
'NTAG I2C NFC chip', 'NTAG I2C NFC chip',
'1kB user memory',
'URL/contact sharing',
'Green, Blue, and White LEDs available (HF)',
'T5577 125kHz chip', 'T5577 125kHz chip',
'Built-in LED indicator', 'LF access control',
'888 bytes user memory',
], ],
url: 'https://dangerousthings.com/product/next-v2/', url: 'https://dangerousthings.com/product/next-v2/',
canReceiveClone: true, canReceiveClone: true,
@@ -240,38 +243,15 @@ export const PRODUCTS: Product[] = [
categories: [ProductCategory.SECURE], categories: [ProductCategory.SECURE],
compatibleChips: [ChipType.NTAG424_DNA, ChipType.NTAG424_DNA_TT, ChipType.ICODE_DNA], compatibleChips: [ChipType.NTAG424_DNA, ChipType.NTAG424_DNA_TT, ChipType.ICODE_DNA],
features: [ features: [
'Cryptographic authentication', 'Spark Actions',
'VivoKey identity platform', 'Verify API',
'Secure web login',
'Digital signatures',
], ],
url: 'https://dangerousthings.com/product/vivokey-spark-2/', url: 'https://dangerousthings.com/product/vivokey-spark/',
canReceiveClone: false, canReceiveClone: false,
exactMatch: true, exactMatch: true,
notes: 'Uses cryptographic authentication - cannot clone', notes: 'Uses cryptographic authentication - cannot clone',
}, },
// ==========================================================================
// X-Series - LED/Special
// ==========================================================================
{
id: 'xglow',
name: 'xGlow',
description: 'Implant with LED that glows when exposed to NFC field.',
formFactor: FormFactor.X_SERIES,
categories: [ProductCategory.LED],
compatibleChips: [], // No NFC storage
features: [
'LED indicator',
'Multiple colors',
'Field detection',
],
url: 'https://dangerousthings.com/product/xglow/',
canReceiveClone: false,
exactMatch: false,
notes: 'LED only - no NFC data storage',
},
// ========================================================================== // ==========================================================================
// Bioresin Implants - Larger capsules (incision install) // Bioresin Implants - Larger capsules (incision install)
// ========================================================================== // ==========================================================================
@@ -313,25 +293,6 @@ export const PRODUCTS: Product[] = [
exactMatch: false, exactMatch: false,
notes: 'Can clone MIFARE Classic cards (requires Android + keys)', notes: 'Can clone MIFARE Classic cards (requires Android + keys)',
}, },
{
id: 'dt5',
name: 'dT5',
description:
'Bioresin 125kHz implant with T5577 chip for access control systems.',
formFactor: FormFactor.BIORESIN,
categories: [ProductCategory.ACCESS],
compatibleChips: [], // 125kHz only, no NFC
features: [
'T5577 125kHz chip',
'EM41xx emulation',
'HID ProxCard compatible',
'Bioresin encapsulation',
],
url: 'https://dangerousthings.com/product/dt5/',
canReceiveClone: false,
exactMatch: false,
notes: '125kHz only - no NFC capability',
},
// ========================================================================== // ==========================================================================
// Flex Implants - NFC // Flex Implants - NFC
@@ -482,23 +443,6 @@ export const PRODUCTS: Product[] = [
exactMatch: false, exactMatch: false,
notes: 'For HID iCLASS systems only', notes: 'For HID iCLASS systems only',
}, },
{
id: 'teslaflex',
name: 'TeslaFlex',
description: 'Implant designed to work as a Tesla key card.',
formFactor: FormFactor.FLEX,
categories: [ProductCategory.ACCESS],
compatibleChips: [], // Tesla proprietary
features: [
'Tesla key card compatible',
'Flexible form factor',
'Direct Tesla integration',
],
url: 'https://dangerousthings.com/product/teslaflex/',
canReceiveClone: false,
exactMatch: false,
notes: 'Specifically for Tesla vehicles',
},
{ {
id: 'flexug4', id: 'flexug4',
name: 'flexUG4', name: 'flexUG4',

View File

@@ -25,7 +25,7 @@ export interface UseScanResult {
/** Scanned tag data (when state is 'success') */ /** Scanned tag data (when state is 'success') */
tag: RawTagData | null; tag: RawTagData | null;
/** Detected transponder info (when detection succeeds) */ /** Detected transponder info (when detection succeeds) */
transponder: Transponder | null; transponder: Transponder | null | undefined;
/** Scan error (when state is 'error') */ /** Scan error (when state is 'error') */
error: ScanError | null; error: ScanError | null;
/** NFC status (supported and enabled) */ /** NFC status (supported and enabled) */
@@ -45,10 +45,19 @@ export interface UseScanResult {
/** /**
* Hook for managing NFC scanning * 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 { export function useScan(): UseScanResult {
const [state, setState] = useState<ScanState>('idle'); const [state, setState] = useState<ScanState>('idle');
const [tag, setTag] = useState<RawTagData | null>(null); // Use combined state to ensure tag and transponder update atomically
const [transponder, setTransponder] = useState<Transponder | null>(null); const [scanResult, setScanResult] = useState<ScanResult>({
tag: null,
transponder: undefined,
});
const [error, setError] = useState<ScanError | null>(null); const [error, setError] = useState<ScanError | null>(null);
const [nfcStatus, setNfcStatus] = useState<NFCStatus>({ const [nfcStatus, setNfcStatus] = useState<NFCStatus>({
isSupported: false, isSupported: false,
@@ -93,8 +102,7 @@ export function useScan(): UseScanResult {
scanInProgress.current = true; scanInProgress.current = true;
setState('scanning'); setState('scanning');
setTag(null); setScanResult({ tag: null, transponder: undefined });
setTransponder(null);
setError(null); setError(null);
setScanProgress({step: 'Waiting for tag...', current: 1}); setScanProgress({step: 'Waiting for tag...', current: 1});
@@ -162,11 +170,18 @@ export function useScan(): UseScanResult {
setError(result.error); setError(result.error);
} }
} else if (result.tag) { } 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'); setState('success');
setTag(result.tag);
if (result.detection) {
setTransponder(result.detection);
}
} else { } else {
setState('error'); setState('error');
setError({ setError({
@@ -201,8 +216,7 @@ export function useScan(): UseScanResult {
// Reset state to idle // Reset state to idle
const reset = useCallback(() => { const reset = useCallback(() => {
setState('idle'); setState('idle');
setTag(null); setScanResult({ tag: null, transponder: undefined });
setTransponder(null);
setError(null); setError(null);
setScanProgress(null); setScanProgress(null);
}, []); }, []);
@@ -219,8 +233,8 @@ export function useScan(): UseScanResult {
return { return {
state, state,
tag, tag: scanResult.tag,
transponder, transponder: scanResult.transponder,
error, error,
nfcStatus, nfcStatus,
scanProgress, scanProgress,

View File

@@ -15,7 +15,7 @@ export function ResultScreen({ route, navigation }: ResultScreenProps) {
// Match chip to products // Match chip to products
const matchResult = useMemo(() => { const matchResult = useMemo(() => {
if (!transponder) return null; if (!transponder) return null;
return matchChipToProducts(transponder.type); return matchChipToProducts(transponder);
}, [transponder]); }, [transponder]);
// Get educational chip info // Get educational chip info
@@ -449,32 +449,42 @@ export function ResultScreen({ route, navigation }: ResultScreenProps) {
<Text variant="bodyMedium" style={styles.noDetectionText}> <Text variant="bodyMedium" style={styles.noDetectionText}>
Unable to identify this chip type. It may be unsupported or require advanced detection. Unable to identify this chip type. It may be unsupported or require advanced detection.
</Text> </Text>
</Surface> <Text variant="bodyMedium" style={styles.noDetectionText}>
)} Need help? Ask on our forum for assistance.
{/* Conversion Service Card - show for payment devices, or when recommended and not a real implant */}
{(transponder?.implantName?.includes('Payment Card') ||
((matchResult?.conversionRecommended || !transponder) && !transponder?.implantName)) && (
<Surface style={styles.conversionCard} elevation={1}>
<Text variant="bodyMedium" style={styles.conversionText}>
{transponder?.implantName?.includes('Payment Card')
? "Payment cards can't be converted to implants due to security restrictions. Our conversion service can help you find an implant that works with your use case."
: !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> </Text>
<Button <Button
mode="outlined" mode="outlined"
onPress={handleConversionLink} onPress={() => Linking.openURL(buildTrackedUrl('https://dngr.us/forum', 'unknown_chip'))}
style={styles.conversionButton} style={styles.forumButton}
labelStyle={styles.conversionButtonLabel}> labelStyle={styles.forumButtonLabel}>
CONVERSION SERVICE VISIT FORUM
</Button> </Button>
</Surface> </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)) && (
<Surface style={styles.conversionCard} elevation={1}>
<Text variant="bodyMedium" style={styles.conversionText}>
{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"
onPress={handleConversionLink}
style={styles.conversionButton}
labelStyle={styles.conversionButtonLabel}>
CONVERSION SERVICE
</Button>
</Surface>
)}
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.actions}> <View style={styles.actions}>
<Button <Button
@@ -650,6 +660,16 @@ const styles = StyleSheet.create({
noDetectionText: { noDetectionText: {
color: DTColors.light, color: DTColors.light,
opacity: 0.8, opacity: 0.8,
marginBottom: 12,
},
forumButton: {
borderColor: DTColors.modeWarning,
borderWidth: 1,
marginTop: 8,
},
forumButtonLabel: {
color: DTColors.modeWarning,
fontSize: 12,
}, },
// Products Card // Products Card
productsCard: { productsCard: {

View File

@@ -20,9 +20,25 @@ export function ScanScreen({navigation}: ScanScreenProps) {
openSettings, openSettings,
} = useScan(); } = useScan();
// Navigate to results when scan succeeds (detection happens in the hook) // 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(() => { 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', { navigation.replace('Result', {
tagData: { tagData: {
uid: tag.uid, uid: tag.uid,
@@ -37,6 +53,13 @@ export function ScanScreen({navigation}: ScanScreenProps) {
} }
}, [state, tag, transponder, 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 // Auto-start scan when screen loads
useEffect(() => { useEffect(() => {
// Small delay to ensure screen is mounted // Small delay to ensure screen is mounted

View File

@@ -5,6 +5,7 @@
*/ */
import {ChipType, DesfireVersionInfo} from '../../types/detection'; import {ChipType, DesfireVersionInfo} from '../../types/detection';
import type {NdefRecord} from '../../types/nfc';
import { import {
DESFIRE_GET_VERSION, DESFIRE_GET_VERSION,
DESFIRE_GET_VERSION_CONTINUE, DESFIRE_GET_VERSION_CONTINUE,
@@ -14,11 +15,73 @@ import {
KNOWN_AIDS, KNOWN_AIDS,
} from '../nfc/commands'; } 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 * Product type values from GET_VERSION byte 1
*/ */
const PRODUCT_TYPES = { const PRODUCT_TYPES = {
DESFIRE: 0x01, // Standard DESFire DESFIRE: 0x01, // Standard DESFire
NTAG: 0x04, // NTAG family (including NTAG 413 DNA which uses 0x04)
NTAG_I2C: 0x05, // NTAG I2C family (can have ISO-DEP on Plus variants) NTAG_I2C: 0x05, // NTAG I2C family (can have ISO-DEP on Plus variants)
DESFIRE_LIGHT: 0x08, // DESFire Light DESFIRE_LIGHT: 0x08, // DESFire Light
NTAG_424_DNA: 0x21, // NTAG 424 DNA family NTAG_424_DNA: 0x21, // NTAG 424 DNA family
@@ -184,7 +247,7 @@ export async function detectDesfire(): Promise<DesfireDetectionResult> {
let chipType: ChipType; let chipType: ChipType;
if (hwProductType === PRODUCT_TYPES.NTAG_424_DNA) { if (hwProductType === PRODUCT_TYPES.NTAG_424_DNA) {
// NTAG DNA family (413 DNA and 424 DNA share product type 0x21) // NTAG 424 DNA family uses product type 0x21
// Subtype 0x02 = TagTamper variant (424 DNA TT only) // Subtype 0x02 = TagTamper variant (424 DNA TT only)
// Storage size helps differentiate: // Storage size helps differentiate:
// - NTAG 413 DNA: ~160 bytes user memory (storage code <= 0x0E) // - NTAG 413 DNA: ~160 bytes user memory (storage code <= 0x0E)
@@ -198,6 +261,14 @@ export async function detectDesfire(): Promise<DesfireDetectionResult> {
// Larger storage indicates NTAG 424 DNA (~416 bytes) // Larger storage indicates NTAG 424 DNA (~416 bytes)
chipType = ChipType.NTAG424_DNA; chipType = ChipType.NTAG424_DNA;
} }
} else if (hwProductType === PRODUCT_TYPES.NTAG && hwMajor >= 0x30) {
// NTAG 413 DNA returns product type 0x04 (like regular NTAG) but with:
// - hwMajor >= 0x30 (typically 0x30)
// - hwStorageSize 0x11 (different meaning than regular NTAG)
// - Responds to IsoDep (unlike regular NTAG 21x)
// Per NXP NT4H1321 datasheet, NTAG 413 DNA uses this response format
console.log('[DESFire] Detected NTAG 413 DNA (product type 0x04, hwMajor 0x30+)');
chipType = ChipType.NTAG413_DNA;
} else if (hwProductType === PRODUCT_TYPES.NTAG_I2C) { } else if (hwProductType === PRODUCT_TYPES.NTAG_I2C) {
// NTAG I2C family (can have ISO-DEP on Plus variants) // NTAG I2C family (can have ISO-DEP on Plus variants)
// Per NXP NT3H2111/NT3H2211 datasheet: // Per NXP NT3H2111/NT3H2211 datasheet:
@@ -430,31 +501,58 @@ export async function detectSpark2Implant(): Promise<Spark2DetectionResult> {
try { try {
console.log('[DESFire] Checking for Spark 2 implant NDEF...'); console.log('[DESFire] Checking for Spark 2 implant NDEF...');
// Step 1: Select NDEF application // Step 1: Select NDEF application (D2760000850101)
console.log('[DESFire] Selecting NDEF application...');
const selectNdefApp = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ndefTag)); const selectNdefApp = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ndefTag));
const selectAppResponse = parseApduResponse(selectNdefApp); 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) { if (!selectAppResponse.isSuccess) {
console.log('[DESFire] NDEF app selection failed'); console.log('[DESFire] NDEF app selection failed');
return {found: false}; return {found: false};
} }
// Step 2: Select NDEF file (file ID E104 for standard NDEF) // Step 2: Select NDEF file (file ID E104 for standard Type 4 Tag)
// SELECT command: 00 A4 00 0C 02 E104 // 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 selectFileCmd = [0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x04];
const selectFileResponse = await sendIsoDepCommand(selectFileCmd); const selectFileResponse = await sendIsoDepCommand(selectFileCmd);
const selectFileParsed = parseApduResponse(selectFileResponse); 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) { if (!selectFileParsed.isSuccess) {
console.log('[DESFire] NDEF file selection failed'); // Try alternate file ID (some Type 4 tags use E102 or just 02)
return {found: false}; 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) // Step 3: Read NDEF length (first 2 bytes)
// READ BINARY: 00 B0 00 00 02 // READ BINARY: 00 B0 00 00 02
console.log('[DESFire] Reading NDEF length...');
const readLengthCmd = [0x00, 0xb0, 0x00, 0x00, 0x02]; const readLengthCmd = [0x00, 0xb0, 0x00, 0x00, 0x02];
const readLengthResponse = await sendIsoDepCommand(readLengthCmd); const readLengthResponse = await sendIsoDepCommand(readLengthCmd);
const lengthParsed = parseApduResponse(readLengthResponse); 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) { if (!lengthParsed.isSuccess || lengthParsed.data.length < 2) {
console.log('[DESFire] NDEF length read failed'); console.log('[DESFire] NDEF length read failed');
@@ -462,39 +560,58 @@ export async function detectSpark2Implant(): Promise<Spark2DetectionResult> {
} }
const ndefLength = (lengthParsed.data[0] << 8) | lengthParsed.data[1]; const ndefLength = (lengthParsed.data[0] << 8) | lengthParsed.data[1];
console.log('[DESFire] NDEF length:', ndefLength); console.log('[DESFire] NDEF message length:', ndefLength, 'bytes');
if (ndefLength === 0 || ndefLength > 500) { if (ndefLength === 0 || ndefLength > 500) {
console.log('[DESFire] Invalid NDEF length'); console.log('[DESFire] Invalid or empty NDEF length');
return {found: false}; return {found: false};
} }
// Step 4: Read NDEF message (starting after length bytes) // Step 4: Read NDEF message (starting after length bytes)
// READ BINARY: 00 B0 00 02 <length> // For longer NDEF messages, we may need to read in chunks
const readDataCmd = [0x00, 0xb0, 0x00, 0x02, Math.min(ndefLength, 128)]; const allNdefData: number[] = [];
const readDataResponse = await sendIsoDepCommand(readDataCmd); let offset = 2; // Start after the 2-byte length field
const dataParsed = parseApduResponse(readDataResponse); let remaining = ndefLength;
if (!dataParsed.isSuccess || dataParsed.data.length === 0) { while (remaining > 0) {
console.log('[DESFire] NDEF data read failed'); 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}; return {found: false};
} }
console.log( console.log(
'[DESFire] NDEF data:', '[DESFire] NDEF data (' + allNdefData.length + ' bytes):',
dataParsed.data.map(b => b.toString(16).padStart(2, '0')).join(' '), 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 // Convert to ASCII and look for vivokey.co pattern
const asciiStr = dataParsed.data const asciiStr = allNdefData
.filter(b => b >= 0x20 && b <= 0x7e) .filter(b => b >= 0x20 && b <= 0x7e)
.map(b => String.fromCharCode(b)) .map(b => String.fromCharCode(b))
.join(''); .join('');
console.log('[DESFire] NDEF ASCII:', asciiStr); console.log('[DESFire] NDEF ASCII:', asciiStr);
// Check for vivokey.co URL pattern // Check for vivokey.co URL pattern - match any characters after the domain
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9]+)/i); // Pattern allows alphanumeric, hyphens, underscores, and other URL-safe chars
const vivokeyMatch = asciiStr.match(/vivokey\.co\/([A-Za-z0-9_\-]+)/i);
if (vivokeyMatch) { if (vivokeyMatch) {
const code = vivokeyMatch[1]; const code = vivokeyMatch[1];
const url = `https://vivokey.co/${code}`; const url = `https://vivokey.co/${code}`;

View File

@@ -21,7 +21,7 @@ import {
hasIsoDepCapability, hasIsoDepCapability,
detectSakSwap, detectSakSwap,
} from './mifare'; } from './mifare';
import {detectDesfire, detectDesfireFromAts, detectSpark2Implant} from './desfire'; import {detectDesfire, detectDesfireFromAts, detectSpark2Implant, detectSpark2FromNdef} from './desfire';
import {detectIso15693, isIso15693, detectSparkImplant} from './iso15693'; import {detectIso15693, isIso15693, detectSparkImplant} from './iso15693';
import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard'; import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard';
@@ -265,15 +265,24 @@ export async function detectChip(
desfireResult.chipType === ChipType.NTAG413_DNA; desfireResult.chipType === ChipType.NTAG413_DNA;
if (isNtagDna) { if (isNtagDna) {
try { // First, try to detect from cached NDEF records (doesn't require APDU)
onProgress?.('Reading NDEF for Spark 2...'); // This works even after DESFire GET_VERSION puts the tag in native mode
const spark2Result = await detectSpark2Implant(); const cachedNdefResult = detectSpark2FromNdef(rawData.ndefRecords);
if (spark2Result.found && spark2Result.name) { if (cachedNdefResult.found && cachedNdefResult.name) {
implantName = spark2Result.name; implantName = cachedNdefResult.name;
console.log('[Detector] Found Spark 2 implant:', implantName); 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);
} }
} catch (e) {
console.warn('[Detector] Spark 2 detection failed:', e);
} }
} }

View File

@@ -819,19 +819,20 @@ export async function detectSparkImplant(
console.log('[ISO15693] Found Spark URL:', url); console.log('[ISO15693] Found Spark URL:', url);
// Determine Spark version based on chip type // Determine Spark version based on chip type
// SLIX = Spark 1, NTAG 424 DNA = Spark 2 // SLIX/ICODE DNA = Spark 1 (ISO 15693)
// NTAG 424 DNA = Spark 2 (ISO 14443-4, handled in desfire.ts)
let sparkName: string; let sparkName: string;
if ( if (
chipType === ChipType.SLIX || chipType === ChipType.SLIX ||
chipType === ChipType.SLIX_S || chipType === ChipType.SLIX_S ||
chipType === ChipType.SLIX_L || chipType === ChipType.SLIX_L ||
chipType === ChipType.SLIX2 chipType === ChipType.SLIX2 ||
chipType === ChipType.ICODE_DNA
) { ) {
sparkName = 'Spark 1'; sparkName = 'Spark 1';
} else if (chipType === ChipType.NTAG424_DNA) {
sparkName = 'Spark 2';
} else { } else {
sparkName = 'Spark'; // Any other ISO 15693 chip with vivokey.co URL is likely a Spark variant
sparkName = 'Spark 1';
} }
return { return {

View File

@@ -4,8 +4,8 @@
* Matches detected NFC chips to compatible Dangerous Things products * Matches detected NFC chips to compatible Dangerous Things products
*/ */
import {ChipType, getChipFamily, CHIP_CLONEABILITY, ChipFamily} from '../../types/detection'; import { ChipType, getChipFamily, CHIP_CLONEABILITY, ChipFamily, Transponder } from '../../types/detection';
import {MatchResult, Product, DesfireEvLevel} from '../../types/products'; import { MatchResult, Product, DesfireEvLevel } from '../../types/products';
import { import {
PRODUCTS, PRODUCTS,
getChipProductMap, getChipProductMap,
@@ -15,7 +15,8 @@ import {
/** /**
* Match a detected chip to compatible products * Match a detected chip to compatible products
*/ */
export function matchChipToProducts(chipType: ChipType): MatchResult { export function matchChipToProducts(chip: Transponder): MatchResult {
const chipType = chip.type as ChipType
const chipProductMap = getChipProductMap(); const chipProductMap = getChipProductMap();
const cloneability = CHIP_CLONEABILITY[chipType]; const cloneability = CHIP_CLONEABILITY[chipType];
const chipFamily = getChipFamily(chipType); const chipFamily = getChipFamily(chipType);
@@ -32,7 +33,12 @@ export function matchChipToProducts(chipType: ChipType): MatchResult {
exactMatches.push(product); exactMatches.push(product);
} }
if (product.canReceiveClone && cloneability?.cloneable) { if (product.canReceiveClone && cloneability?.cloneable) {
cloneTargets.push(product); 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);
}
} }
} }

View File

@@ -11,6 +11,7 @@ import type {
ScanError, ScanError,
ScanErrorType, ScanErrorType,
NfcTechType, NfcTechType,
NdefRecord,
} from '../../types/nfc'; } from '../../types/nfc';
/** /**
@@ -115,6 +116,47 @@ function parseAts(tag: TagEvent): {ats?: string; historicalBytes?: string} {
return {ats, historicalBytes}; return {ats, historicalBytes};
} }
/**
* 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 * Convert TagEvent to RawTagData
*/ */
@@ -124,6 +166,7 @@ function tagEventToRawData(tag: TagEvent): RawTagData {
const sak = parseSak(tag); const sak = parseSak(tag);
const atqa = parseAtqa(tag); const atqa = parseAtqa(tag);
const {ats, historicalBytes} = parseAts(tag); const {ats, historicalBytes} = parseAts(tag);
const ndefRecords = parseNdefRecords(tag);
const isoDep = (tag as any).isoDep; const isoDep = (tag as any).isoDep;
const iso7816 = (tag as any).iso7816; const iso7816 = (tag as any).iso7816;
@@ -160,6 +203,7 @@ function tagEventToRawData(tag: TagEvent): RawTagData {
ats, ats,
historicalBytes, historicalBytes,
maxTransceiveLength, maxTransceiveLength,
ndefRecords,
}; };
} }

View File

@@ -18,6 +18,20 @@ export type NfcTechType =
| 'Iso7816' | 'Iso7816'
| 'Iso15693'; | '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[];
}
/** /**
* Raw tag data from NFC scan * Raw tag data from NFC scan
*/ */
@@ -36,6 +50,8 @@ export interface RawTagData {
historicalBytes?: string; historicalBytes?: string;
/** Maximum transceive length */ /** Maximum transceive length */
maxTransceiveLength?: number; maxTransceiveLength?: number;
/** Cached NDEF records (if tag supports NDEF) */
ndefRecords?: NdefRecord[];
} }
/** /**