Overview
Muun Wallet supports NFC security cards as a hardware-based second authentication factor. Security cards provide an additional layer of protection by requiring physical possession of the card to authorize sensitive operations.
Security cards are an optional feature. Users can choose to use them for enhanced security or rely solely on device-based authentication.
What is a Security Card?
A security card is a:
Physical NFC card similar to a credit card
Cryptographic hardware device that can sign challenges
Portable second factor that doesn’t require batteries or charging
Backup authentication method alongside device-based keys
Use Cases
High-value transactions : Require physical card tap to authorize
Account recovery : Use card as part of multi-factor recovery
Hardware security : Cryptographic operations isolated from device
Portable 2FA : Works with any NFC-enabled phone
How It Works
NFC Communication
The wallet communicates with security cards using ISO-DEP (ISO 14443-4) protocol:
// From NfcReaderViewModel.kt:88
internal fun onNewNfcSession (nfcSession: NfcSession ) {
analytics. report (AnalyticsEvent. E_SECURITY_CARD_TAP (DETECTED))
viewModelScope. launch {
_viewState. emit (ViewState.Reading)
try {
handleSignChallenge (nfcSession)
analytics. report (AnalyticsEvent. E_SECURITY_CARD_SIGN_SUCCESS ())
_viewCommand. emit (ViewCommand.Success)
} catch (error: Exception ) {
// Handle errors
}
}
}
Source: android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt:96-104
Challenge-Response Authentication
// From NfcReaderViewModel.kt:145
private suspend fun handleSignChallenge (nfcSession: NfcSession ) {
withContext (Dispatchers.IO) {
nfcSession. connect ()
val nfcBridger = nfcBridgerFactory. forSession (nfcSession)
val challengeMessage = "testing NFC in Android"
if (featureSelector. get (MuunFeature.NFC_CARD)) {
val signedMessageHex = libwalletClient. securityCardSignMessage (
nfcBridger,
challengeMessage
)
} else if (featureSelector. get (MuunFeature.NFC_CARD_V2)) {
libwalletClient. securityCardV2SignMessage (nfcBridger)
}
nfcSession. close ()
}
}
Authentication Flow:
User initiates action requiring security card
App generates challenge (random message)
User taps card to phone
Card signs challenge with its private key
App verifies signature using card’s public key
Action authorized if signature is valid
User Experience
Card Reading Interface
Muun provides a sophisticated NFC reading experience:
// From NfcReaderActivity.kt:66
override fun onCreate (savedInstanceState: Bundle ?) {
super . onCreate (savedInstanceState)
initializeUi ()
generateAppEvent ( "s_nfc_reader" )
loadBoundaryData ()
lifecycleScope. launch {
repeatOnLifecycle (Lifecycle.State.STARTED) {
launch {
viewModel.viewCommand. collect { viewCommand ->
when (viewCommand) {
is ViewCommand.Success -> {
generateAppEvent ( "nfc_reading_success" )
vibrateShort ()
disableReaderMode ()
viewModel. securityCard2faSuccess ( this@NfcReaderActivity )
}
is ViewCommand.Error -> {
handleNfcError (viewCommand)
}
}
}
}
}
}
}
Antenna Position Detection
The app helps users locate the NFC antenna:
// From NfcReaderViewModel.kt:246
internal fun getAntennaPosition (): Pair < Float , Float >? {
return metricsProvider.nfcAntennaPosition. firstOrNull ()
}
// From NfcReaderActivity.kt:190
viewModel. getAntennaPosition ()?. let {
container. attachChildAtMm (
childView = nfcSensorView,
originXmm = it.first,
originYmm = it.second,
modifications = { pxPos ->
pxPos * 0.5f // -50% shift for positioning in the middle
},
)
}
UI Features:
Visual guide : Shows where to tap the card on the device
Antenna position : Device-specific NFC antenna location
Haptic feedback : Vibrates when card is detected
Real-time status : “Scanning” vs “Reading” states
Error handling : Clear messages for common issues
Source: android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderActivity.kt:190-199
Feasible Zone Visualization
Muun displays a “feasible zone” showing the optimal card placement area:
// From NfcReaderActivity.kt:218
private fun setBoundaryData (feasibleZone: FeasibleZone ) {
binding.nfcFeasibleAreaView. apply {
setData (feasibleZone)
}
}
This helps users:
Find the NFC antenna quickly
Position the card correctly
Avoid failed read attempts
Security Features
Challenge Keys
Security cards use special challenge keys derived from the recovery code:
// From challenge_keys.go:29
type ChallengePrivateKey struct {
key * btcec . PrivateKey
}
func NewChallengePrivateKey ( input , salt [] byte ) * ChallengePrivateKey {
key := Scrypt256 ( input , salt )
priv , _ := btcec . PrivKeyFromBytes ( key )
return & ChallengePrivateKey { key : priv }
}
Signature Verification
// From challenge_keys.go:66
func ( k * ChallengePrivateKey ) SignSha ( payload [] byte ) ([] byte , error ) {
hash := sha256 . Sum256 ( payload )
sig := ecdsa . Sign ( k . key , hash [:])
return sig . Serialize (), nil
}
The card:
Derives a private key from the recovery code
Signs challenges with ECDSA
Returns compact signature format
Never exposes the private key
Source: libwallet/challenge_keys.go:29-72
MAC Validation
// From NfcReaderViewModel.kt:130
if (errorDetail?.code == ErrorDetailCode.SIGN_MAC_VALIDATION_FAILED.code) {
"Invalid Signature Verification! You're probably tapping with another " +
"(incorrect) security card"
}
The wallet validates:
Signature matches the expected public key
Card is the correct one for this wallet
No tampering or replay attacks
If you tap with the wrong security card, the signature validation will fail and the operation will be rejected.
Sensor Data Collection
Muun collects sensor data to improve NFC tap detection:
// From NfcReaderViewModel.kt:203
internal fun subscribeToAllSensors (context: Context , lifecycleOwner: LifecycleOwner ) {
if ( ! featureSelector. get (MuunFeature.NFC_SENSORS)) {
return
}
val sensorManager = context. getSystemService (Context.SENSOR_SERVICE) as SensorManager
val mergedFlow = merge (
SensorUtils. mergedSensorFlow (sensorManager),
appEvents,
)
sensorJob = lifecycleOwner.lifecycleScope. launch {
mergedFlow. collect { event ->
storeSensorsDataAction. run (event)
}
}
}
Sensors monitored:
Accelerometer : Detect phone movement during tap
Gyroscope : Track phone rotation
Magnetometer : Detect magnetic field from card
Pressure : Detect when user presses card to phone
Gestures : Track touch events
This data helps:
Improve tap success rate
Provide better user guidance
Detect optimal antenna positions per device
Debug NFC reading issues
Source: android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderViewModel.kt:203-221
Error Handling
Common Errors
// From NfcReaderActivity.kt:116
private fun handleNfcError (error: ViewCommand .Error) {
generateAppEvent ( "nfc_reading_error" )
showError (
ErrorViewModel. Builder ()
. loggingName (AnalyticsEvent.ERROR_TYPE.NFC_2FA_FAILED)
. kind (ErrorViewModel.ErrorViewKind.REPORTABLE)
. title ( "Error Reading Security Card" )
. description (error.message)
. canGoBack ( true )
. build ()
)
}
Error Types:
NFC Disabled : User hasn’t enabled NFC in system settings
Wrong Card : MAC validation fails (wrong security card)
Communication Error : Card read timeout or connection issue
No Slots Available : Card storage full (backend issue)
NFC Settings Prompt
// From NfcReaderActivity.kt:153
if ( ! isNfcEnabled ()) {
generateAppEvent ( "nfc_disabled_dialog" )
MuunDialog. Builder ()
. title (R.string.nfc_reader_screen_title)
. message (R.string.nfc_reader_screen_enable_nfc)
. positiveButton (R.string.nfc_reader_screen_go_to_configs) {
startActivity ( Intent (android.provider.Settings.ACTION_NFC_SETTINGS))
}
. negativeButton (R.string.cancel) {
finishActivity ()
}
. build ()
. let (:: showDialog )
}
If NFC is disabled, the app:
Detects this immediately on screen load
Prompts user to enable NFC
Provides direct link to system settings
Allows user to cancel and return
Feature Flag Management
Disabling Security Card
// From NfcReaderActivity.kt:133
private fun handleDisableSecurityCardFlag () {
MuunDialog. Builder ()
. title (R.string.nfc_reader_screen_disable_ff_title)
. message (R.string.nfc_reader_screen_disable_ff_desc)
. positiveButton (R.string.nfc_reader_screen_disable_ff_yes) {
viewModel. disableSecurityCardFF ()
finishActivity ()
}
. negativeButton (R.string.no) {
viewModel. cancelDisableSecurityCardFF ()
}
. build ()
. let (:: showDialog )
}
If backend returns NO_SLOTS_AVAILABLE error:
User is prompted to disable security card feature
This is a backend capacity issue
User can choose to disable or keep trying
Source: android/apolloui/src/main/java/io/muun/apollo/presentation/ui/nfc/NfcReaderActivity.kt:133-146
Implementation Details
Reader Mode
// From NfcReaderActivity.kt:238
private fun enableReaderMode () {
nfcReaderModeExtension. enableReaderMode ()
}
private fun disableReaderMode () {
nfcReaderModeExtension. disableReaderMode ()
}
Reader mode:
Activates when screen is visible (onResume)
Deactivates when screen is hidden (onPause)
Prevents conflicts with other NFC apps
Provides fastest possible card detection
NFC Session Management
// From NfcReaderActivity.kt:234
override fun onNewNfcSession (nfcSession: NfcSession ) {
viewModel. onNewNfcSession (nfcSession)
}
When a card is detected:
System creates NfcSession from the tag
Session is passed to view model
View model handles signing in background coroutine
UI updates based on result
Analytics Tracking
// From NfcReaderViewModel.kt:97
analytics. report (AnalyticsEvent. E_SECURITY_CARD_TAP (SECURITY_CARD_TAP_PARAM.DETECTED))
// On success:
analytics. report (AnalyticsEvent. E_SECURITY_CARD_SIGN_SUCCESS ())
// On error:
analytics. report (
E_ERROR (
ERROR_TYPE.NFC_2FA_FAILED,
error,
"errorMessage" to errorMessage
)
)
Events tracked:
Card tap detected
Sign success/failure
Error types and messages
User actions (disable flag, etc.)
Privacy Considerations
What’s Private
Security card never exposes private key
Signatures are unique per challenge (no replay)
NFC communication is short-range only
Card identity not revealed to third parties
What’s Collected
Sensor data during NFC reading (if feature enabled)
Success/failure metrics
Error types and frequencies
Device antenna positions
Sensor data collection is behind a feature flag (MuunFeature.NFC_SENSORS) and can be disabled.
Best Practices
For Users
Store safely : Keep security card in wallet, not with phone
Backup recovery code : Card can be recreated from recovery code
Test regularly : Ensure card works before emergency
Keep clean : Dirty or damaged cards may not read properly
For Developers
Always handle NFC disabled state gracefully
Provide clear visual guidance for card placement
Implement proper timeout handling
Test on multiple device models (antenna positions vary)
Handle reader mode lifecycle correctly
Security Card Versions
// From NfcReaderViewModel.kt:151
if (featureSelector. get (MuunFeature.NFC_CARD)) {
// Original security card implementation
val signedMessageHex = libwalletClient. securityCardSignMessage (
nfcBridger,
challengeMessage
)
} else if (featureSelector. get (MuunFeature.NFC_CARD_V2)) {
// V2 implementation with improvements
libwalletClient. securityCardV2SignMessage (nfcBridger)
}
Muun supports two security card versions:
V1 (NFC_CARD) : Original implementation
V2 (NFC_CARD_V2) : Enhanced version with better security
Both versions use the same physical cards but different signing protocols.
Emergency Kit How security cards integrate with emergency recovery
Recovery Using security cards in wallet recovery
Multisig How security cards complement multisig architecture