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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@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",
|
||||
@@ -5311,6 +5312,13 @@
|
||||
"@babel/plugin-syntax-flow": "^7.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-transform-remove-console": {
|
||||
"version": "6.9.4",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz",
|
||||
"integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-preset-current-node-syntax": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
35
src/utils/logger.ts
Normal 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;
|
||||
Reference in New Issue
Block a user