Add scan progress indicator, payment card detection, and production cleanup

Features:
- Add scanning progress indicator showing current detection step
- Detect payment cards (Visa, Mastercard, Amex, etc.) via PPSE/EMV AIDs
- Show "Payment Device Detected" instead of "Implant Detected" for payment cards
- Hide compatible implants section for payment devices, show conversion instead
- Add Spark 1/2 implant detection via vivokey.co NDEF URL
- Hide conversion card when actual implant is detected

Technical:
- Add DetectionProgressCallback for real-time detection status
- Add payment network AIDs to commands.ts
- Configure babel to strip console.log/warn in production builds
- Add logger utility for development-only logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-23 09:29:17 -08:00
parent 7596e4cbfa
commit 63d815c1c2
15 changed files with 1179 additions and 130 deletions

View File

@@ -9,6 +9,16 @@ 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;
@@ -20,6 +30,8 @@ export interface UseScanResult {
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 */
@@ -42,6 +54,7 @@ export function useScan(): UseScanResult {
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);
@@ -83,6 +96,7 @@ export function useScan(): UseScanResult {
setTag(null);
setTransponder(null);
setError(null);
setScanProgress({step: 'Waiting for tag...', current: 1});
try {
// Check NFC status first
@@ -115,8 +129,23 @@ export function useScan(): UseScanResult {
// 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);
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;
});
@@ -175,6 +204,7 @@ export function useScan(): UseScanResult {
setTag(null);
setTransponder(null);
setError(null);
setScanProgress(null);
}, []);
// Open device NFC settings
@@ -193,6 +223,7 @@ export function useScan(): UseScanResult {
transponder,
error,
nfcStatus,
scanProgress,
startScan,
cancelScan,
reset,

View File

@@ -1,13 +1,16 @@
import React, {useMemo} from 'react';
import {StyleSheet, View, ScrollView, Linking} from 'react-native';
import {Button, Text, Surface, Divider, Chip} from 'react-native-paper';
import type {ResultScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
import {matchChipToProducts, getMatchSummary, getDesfireEvMismatchWarning} from '../services/matching';
import {Product} from '../types/products';
import React, { useMemo, useState } from 'react';
import { StyleSheet, View, ScrollView, Linking, TouchableOpacity } from 'react-native';
import { Button, Text, Surface, Divider, Chip } from 'react-native-paper';
import type { ResultScreenProps } from '../types/navigation';
import { DTColors } from '../theme';
import { matchChipToProducts, getMatchSummary, getDesfireEvMismatchWarning } 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;
export function ResultScreen({ route, navigation }: ResultScreenProps) {
const { tagData, transponder } = route.params;
const [showChipInfo, setShowChipInfo] = useState(false);
// Match chip to products
const matchResult = useMemo(() => {
@@ -15,6 +18,17 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
return matchChipToProducts(transponder.type);
}, [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]);
const handleConversionLink = () => {
Linking.openURL(matchResult?.conversionUrl || 'https://dngr.us/conversion');
};
@@ -36,7 +50,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
medium: DTColors.modeEmphasis,
low: DTColors.modeWarning,
};
return {color: colors[transponder.confidence], label: `${transponder.confidence.toUpperCase()} CONFIDENCE`};
return { color: colors[transponder.confidence], label: `${transponder.confidence.toUpperCase()} CONFIDENCE` };
};
return (
@@ -54,16 +68,39 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
{transponder.chipName}
</Text>
{/* 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}>
<Chip
style={[styles.familyChip, {borderColor: DTColors.modeNormal}]}
style={[styles.familyChip, { borderColor: DTColors.modeNormal }]}
textStyle={styles.familyChipText}>
{transponder.family}
</Chip>
{getConfidenceLabel() && (
<Chip
style={[styles.confidenceChip, {borderColor: getConfidenceLabel()!.color}]}
textStyle={[styles.confidenceChipText, {color: getConfidenceLabel()!.color}]}>
style={[styles.confidenceChip, { borderColor: getConfidenceLabel()!.color }]}
textStyle={[styles.confidenceChipText, { color: getConfidenceLabel()!.color }]}>
{getConfidenceLabel()!.label}
</Chip>
)}
@@ -86,7 +123,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
</Text>
<Text
variant="bodyLarge"
style={[styles.detailValue, {color: getCloneabilityColor()}]}>
style={[styles.detailValue, { color: getCloneabilityColor() }]}>
{transponder.isCloneable ? 'YES' : 'NO'}
</Text>
</View>
@@ -99,13 +136,114 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
</Surface>
)}
{/* Product Matches Card */}
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && (
{/* Educational Chip Info Card */}
{transponder && (chipInfo || chipFamilyInfo) && (
<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>
)}
{/* Product Matches Card - hide for payment devices */}
{transponder && matchResult && (matchResult.exactMatches.length > 0 || matchResult.cloneTargets.length > 0) && !transponder.implantName?.includes('Payment Card') && (
<Surface style={styles.productsCard} elevation={1}>
<Text variant="labelLarge" style={styles.productsLabel}>
COMPATIBLE IMPLANTS
</Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeEmphasis}]} />
<Divider style={[styles.divider, { backgroundColor: DTColors.modeEmphasis }]} />
<Text variant="bodyMedium" style={styles.matchSummary}>
{getMatchSummary(matchResult, transponder.chipName)}
@@ -171,7 +309,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.noMatchLabel}>
NO DIRECT MATCH
</Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeOther}]} />
<Divider style={[styles.divider, { backgroundColor: DTColors.modeOther }]} />
<Text variant="bodyMedium" style={styles.noMatchText}>
{getMatchSummary(matchResult, transponder.chipName)}
@@ -198,7 +336,7 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.sakSwapLabel}>
SAK SWAP DETECTED
</Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeEmphasis}]} />
<Divider style={[styles.divider, { backgroundColor: DTColors.modeEmphasis }]} />
<Text variant="bodyLarge" style={styles.sakSwapType}>
{transponder.sakSwapInfo.swapType?.replace(/_/g, ' ').toUpperCase()}
@@ -293,22 +431,25 @@ export function ResultScreen({route, navigation}: ResultScreenProps) {
<Text variant="labelLarge" style={styles.noDetectionLabel}>
CHIP NOT IDENTIFIED
</Text>
<Divider style={[styles.divider, {backgroundColor: DTColors.modeWarning}]} />
<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>
</Surface>
)}
{/* Conversion Service Card - show when recommended */}
{(matchResult?.conversionRecommended || !transponder) && (
{/* 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
? '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."}
{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>
<Button
mode="outlined"
@@ -536,7 +677,7 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: DTColors.modeNormal,
height: 24,
height: 35,
},
formFactorChipText: {
color: DTColors.modeNormal,
@@ -645,4 +786,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,
},
});

View File

@@ -1,10 +1,11 @@
import React, {useCallback, useEffect} from 'react';
import {StyleSheet, View, Platform} from 'react-native';
import {Button, Text, ActivityIndicator} from 'react-native-paper';
import {Button, Text} from 'react-native-paper';
import type {ScanScreenProps} from '../types/navigation';
import {DTColors} from '../theme';
import {useScan} from '../hooks';
import {getScanInstructions} from '../services/nfc';
import {ScanAnimation} from '../components';
export function ScanScreen({navigation}: ScanScreenProps) {
const {
@@ -13,6 +14,7 @@ export function ScanScreen({navigation}: ScanScreenProps) {
transponder,
error,
nfcStatus,
scanProgress,
startScan,
cancelScan,
openSettings,
@@ -56,16 +58,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}>
@@ -85,18 +117,26 @@ 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"
onPress={openSettings}
style={styles.settingsButton}
labelStyle={styles.buttonLabel}>
labelStyle={styles.settingsButtonLabel}>
OPEN SETTINGS
</Button>
)}
@@ -124,34 +164,41 @@ 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>
{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
@@ -181,10 +228,10 @@ export function ScanScreen({navigation}: ScanScreenProps) {
{state === 'processing' && (
<>
<ActivityIndicator
size="large"
<ScanAnimation
isActive={true}
size={180}
color={DTColors.modeEmphasis}
style={styles.processingSpinner}
/>
<Text variant="headlineMedium" style={styles.processingText}>
IDENTIFYING
@@ -219,27 +266,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: {
@@ -248,11 +278,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,
@@ -282,6 +348,10 @@ const styles = StyleSheet.create({
borderWidth: 2,
marginTop: 16,
},
settingsButtonLabel: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
},
readyText: {
color: DTColors.modeEmphasis,
letterSpacing: 2,
@@ -295,12 +365,10 @@ const styles = StyleSheet.create({
color: DTColors.modeNormal,
letterSpacing: 2,
},
processingSpinner: {
marginBottom: 24,
},
processingText: {
color: DTColors.modeEmphasis,
letterSpacing: 4,
marginTop: 32,
},
detectingHint: {
color: DTColors.light,

View File

@@ -10,6 +10,8 @@ import {
DESFIRE_GET_VERSION_CONTINUE,
sendIsoDepCommand,
parseApduResponse,
selectAid,
KNOWN_AIDS,
} from '../nfc/commands';
/**
@@ -405,3 +407,110 @@ export function formatDesfireVersionInfo(info: DesfireVersionInfo): string {
: '';
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
const selectNdefApp = await sendIsoDepCommand(selectAid(KNOWN_AIDS.ndefTag));
const selectAppResponse = parseApduResponse(selectNdefApp);
if (!selectAppResponse.isSuccess) {
console.log('[DESFire] NDEF app selection failed');
return {found: false};
}
// Step 2: Select NDEF file (file ID E104 for standard NDEF)
// SELECT command: 00 A4 00 0C 02 E104
const selectFileCmd = [0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x04];
const selectFileResponse = await sendIsoDepCommand(selectFileCmd);
const selectFileParsed = parseApduResponse(selectFileResponse);
if (!selectFileParsed.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
const readLengthCmd = [0x00, 0xb0, 0x00, 0x00, 0x02];
const readLengthResponse = await sendIsoDepCommand(readLengthCmd);
const lengthParsed = parseApduResponse(readLengthResponse);
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 length:', ndefLength);
if (ndefLength === 0 || ndefLength > 500) {
console.log('[DESFire] Invalid NDEF length');
return {found: false};
}
// Step 4: Read NDEF message (starting after length bytes)
// READ BINARY: 00 B0 00 02 <length>
const readDataCmd = [0x00, 0xb0, 0x00, 0x02, Math.min(ndefLength, 128)];
const readDataResponse = await sendIsoDepCommand(readDataCmd);
const dataParsed = parseApduResponse(readDataResponse);
if (!dataParsed.isSuccess || dataParsed.data.length === 0) {
console.log('[DESFire] NDEF data read failed');
return {found: false};
}
console.log(
'[DESFire] NDEF data:',
dataParsed.data.map(b => b.toString(16).padStart(2, '0')).join(' '),
);
// Convert to ASCII and look for vivokey.co pattern
const asciiStr = dataParsed.data
.filter(b => b >= 0x20 && b <= 0x7e)
.map(b => String.fromCharCode(b))
.join('');
console.log('[DESFire] NDEF ASCII:', 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('[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};
}
}

View File

@@ -14,15 +14,15 @@ import {
CHIP_MEMORY_SIZES,
CHIP_CLONEABILITY,
} from '../../types/detection';
import {detectNtag, mightBeNtag} from './ntag';
import {detectNtag, mightBeNtag, detectImplantNameInMemory} from './ntag';
import {
detectMifareClassic,
isMifareClassicSak,
hasIsoDepCapability,
detectSakSwap,
} from './mifare';
import {detectDesfire, detectDesfireFromAts} from './desfire';
import {detectIso15693, isIso15693} from './iso15693';
import {detectDesfire, detectDesfireFromAts, detectSpark2Implant} from './desfire';
import {detectIso15693, isIso15693, detectSparkImplant} from './iso15693';
import {detectJavaCard, mightBeJavaCard, detectJavaCardFromAts} from './javacard';
/**
@@ -36,6 +36,7 @@ function createTransponder(
versionInfo?: Transponder['versionInfo'];
confidence?: Transponder['confidence'];
sakSwapInfo?: Transponder['sakSwapInfo'];
implantName?: string;
} = {},
): Transponder {
const cloneInfo = CHIP_CLONEABILITY[type];
@@ -67,11 +68,47 @@ function createTransponder(
},
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
*
@@ -81,7 +118,10 @@ function createTransponder(
* 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): Promise<DetectionResult> {
export async function detectChip(
rawData: RawTagData,
onProgress?: DetectionProgressCallback,
): Promise<DetectionResult> {
try {
const {sak, techTypes} = rawData;
@@ -97,6 +137,8 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 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'),
);
@@ -157,6 +199,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
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:', {
@@ -166,6 +209,19 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
});
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, {
@@ -173,6 +229,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
versionInfo: ntagResult.versionInfo,
confidence:
ntagResult.chipType === ChipType.NTAG_UNKNOWN ? 'medium' : 'high',
implantName,
}),
};
}
@@ -197,8 +254,29 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
) {
// 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) {
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:', implantName);
}
} catch (e) {
console.warn('[Detector] Spark 2 detection failed:', e);
}
}
return {
success: true,
transponder: createTransponder(desfireResult.chipType, rawData, {
@@ -208,6 +286,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
desfireResult.chipType === ChipType.DESFIRE_UNKNOWN
? 'medium'
: 'high',
implantName,
}),
};
}
@@ -231,13 +310,17 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 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,
}),
};
}
@@ -259,12 +342,16 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 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,
}),
};
}
@@ -296,6 +383,7 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
// 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
@@ -315,10 +403,24 @@ export async function detectChip(rawData: RawTagData): Promise<DetectionResult>
? '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,
}),
};
}

View File

@@ -4,7 +4,9 @@
*/
export {detectChip, getDetectionSummary, canDoAdvancedDetection} from './detector';
export {detectNtag, mightBeNtag, formatNtagVersionInfo} from './ntag';
export type {DetectionProgressCallback} from './detector';
export {detectNtag, mightBeNtag, formatNtagVersionInfo, detectImplantNameInMemory} from './ntag';
export type {ImplantNameResult} from './ntag';
export {
detectMifareClassic,
isMifareClassicSak,
@@ -15,9 +17,9 @@ export {
IOS_MIFARE_CLASSIC_NOTE,
} from './mifare';
export type {SakSwapDetection} from './mifare';
export {detectDesfire, mightBeDesfire, formatDesfireVersionInfo} from './desfire';
export type {DesfireDetectionResult} from './desfire';
export {detectIso15693, isIso15693, getIcManufacturerName} from './iso15693';
export type {Iso15693DetectionResult} from './iso15693';
export {detectJavaCard, mightBeJavaCard, formatCPLC} from './javacard';
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';

View File

@@ -10,7 +10,7 @@
import {Platform} from 'react-native';
import NfcManager from 'react-native-nfc-manager';
import {ChipType} from '../../types/detection';
import {getIso15693SystemInfo} from '../nfc/commands';
import {getIso15693SystemInfo, transceiveNfcV, iso15693ReadSingleBlock} from '../nfc/commands';
/**
* NXP ICODE IC manufacturer code
@@ -191,12 +191,12 @@ function getIcodeTypeFromIcReference(icRef: number): ChipType | null {
return ChipType.ICODE_DNA;
}
// High IC references (0x80-0x8F) might be older SLIX or special variants
// High IC references (0x80-0x8F) - unknown, don't guess
if (icRef >= 0x80 && icRef < 0x90) {
console.log(
`[ISO15693] High IC ref 0x${icRef.toString(16)}, treating as SLIX (legacy/special)`,
`[ISO15693] High IC ref 0x${icRef.toString(16)} - unknown variant`,
);
return ChipType.SLIX;
return null;
}
console.log(
@@ -431,10 +431,10 @@ export async function detectIso15693(): Promise<Iso15693DetectionResult> {
}
// Got manufacturer NXP but couldn't determine exact variant
// Default to SLIX as it's the most common NXP ISO 15693 chip
// Don't assume SLIX - return unknown with NXP manufacturer info
return {
success: true,
chipType: ChipType.SLIX,
chipType: ChipType.ISO15693_UNKNOWN,
uid,
icManufacturer: icManufacturer ?? NXP_IC_MFG_CODE,
blockSize: sysInfo.blockSize,
@@ -463,25 +463,27 @@ export async function detectIso15693(): Promise<Iso15693DetectionResult> {
console.log('[ISO15693] NXP manufacturer detected');
// Check if we got IC reference from UID parsing
if (uidIcReference !== undefined) {
const chipType = getIcodeTypeFromIcReference(uidIcReference) || ChipType.SLIX;
console.log('[ISO15693] UID-based detection result:', chipType);
return {
success: true,
chipType,
uid,
icManufacturer,
icReference: uidIcReference,
};
const chipType = getIcodeTypeFromIcReference(uidIcReference);
if (chipType) {
console.log('[ISO15693] UID-based detection result:', chipType);
return {
success: true,
chipType,
uid,
icManufacturer,
icReference: uidIcReference,
};
}
}
// NXP but no IC reference - assume SLIX (most common)
console.log('[ISO15693] NXP but no IC reference, defaulting to SLIX');
// 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.SLIX,
chipType: ChipType.ISO15693_UNKNOWN,
uid,
icManufacturer,
icReference: uidIcReference,
};
}
@@ -746,3 +748,103 @@ export function getIcManufacturerName(code: number): string {
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 = Spark 1, NTAG 424 DNA = Spark 2
let sparkName: string;
if (
chipType === ChipType.SLIX ||
chipType === ChipType.SLIX_S ||
chipType === ChipType.SLIX_L ||
chipType === ChipType.SLIX2
) {
sparkName = 'Spark 1';
} else if (chipType === ChipType.NTAG424_DNA) {
sparkName = 'Spark 2';
} else {
sparkName = 'Spark';
}
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};
}
}

View File

@@ -116,59 +116,87 @@ export async function detectJavaCard(): Promise<JavaCardDetectionResult> {
// 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 {
await sendIsoDepCommand(selectAid(KNOWN_AIDS.cardManager));
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
const cplcResponse = await sendIsoDepCommand(GET_CPLC);
const cplcParsed = parseApduResponse(cplcResponse);
let cplc: CPLCData | null = null;
let fabricatorName: string | undefined;
let osName: string | undefined;
if (!cplcParsed.isSuccess || cplcParsed.data.length < 20) {
// CPLC not available - might not be a JavaCard
return {
success: false,
error: 'CPLC data not available - may not be a JavaCard',
};
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
}
}
// Parse CPLC
const cplc = parseCPLC(cplcParsed.data);
if (!cplc) {
return {
success: false,
error: 'Failed to parse CPLC data',
};
}
// Identify fabricator
const fabricatorName =
IC_FABRICATORS[cplc.icFabricator] ||
`Unknown (0x${cplc.icFabricator.toString(16)})`;
// Identify JCOP version
const osName = identifyJcopVersion(cplc.osId);
// Probe for installed applets (this also helps identify DT implants)
const installedApplets = await probeApplets();
// Determine chip type
let chipType: ChipType = ChipType.JAVACARD_UNKNOWN;
// Check if it's NXP JCOP4 (J3R180)
if (cplc.icFabricator === 0x4790 && osName?.includes('JCOP4')) {
// 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)';
}
}
// Probe for installed applets
const installedApplets = await probeApplets();
// 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: cplc || undefined,
fabricatorName,
osName: osName || `Unknown OS (0x${cplc.osId.toString(16)})`,
osName: osName || (cplc ? `Unknown OS (0x${cplc.osId.toString(16)})` : 'Unknown'),
installedApplets,
};
} catch (error) {
@@ -188,6 +216,19 @@ export async function detectJavaCard(): Promise<JavaCardDetectionResult> {
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));
@@ -210,9 +251,115 @@ async function probeApplets(): Promise<string[]> {
// 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
*/

View File

@@ -5,7 +5,7 @@
*/
import {ChipType, NtagVersionInfo} from '../../types/detection';
import {NTAG_GET_VERSION, sendType2Command} from '../nfc/commands';
import {NTAG_GET_VERSION, sendType2Command, ntagRead} from '../nfc/commands';
/**
* GET_VERSION response structure (same for NTAG and Ultralight):
@@ -303,3 +303,133 @@ export function formatNtagVersionInfo(info: NtagVersionInfo): string {
`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};
}
}

View File

@@ -6,6 +6,8 @@
import {Platform} from 'react-native';
import NfcManager, {NfcTech} from 'react-native-nfc-manager';
export {NfcTech};
/**
* APDU response with status word
*/
@@ -130,6 +132,20 @@ 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
// ============================================================================
@@ -141,6 +157,42 @@ export const KNOWN_AIDS = {
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"
};
// ============================================================================

View File

@@ -190,6 +190,9 @@ export interface Transponder {
/** SAK swap detection results */
sakSwapInfo?: SakSwapInfo;
/** Implant name found in memory (for Type 2 tags) */
implantName?: string;
/** Detection confidence level */
confidence: 'high' | 'medium' | 'low';

35
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Logger utility that only outputs in development mode
* All console.log statements in the app should use this logger
*/
/* eslint-disable no-console */
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;