[PATCH] Add usage counter reset with AUTHLIM warning

Adds the ability to reset the brush head's usage counter to zero.

Flow: Read head -> press Right ("Reset") -> confirm warning dialog ->
hold head to Flipper again. The app authenticates with the derived
password via the poller's AuthRequest event and writes the official
reset payload (00 00 02 00) to page 0x24.

The confirm dialog warns about the NTAG213 AUTHLIM feature which
permanently locks the tag after 3 wrong password attempts.
This commit is contained in:
Michael Huber
2026-08-08 15:45:58 +01:00
committed by mbirth
parent 8933b64139
commit 8074cd4ad8
9 changed files with 317 additions and 8 deletions
+3
View File
@@ -2,3 +2,6 @@ ADD_SCENE(sonicare, start, Start)
ADD_SCENE(sonicare, about, About)
ADD_SCENE(sonicare, read, Read)
ADD_SCENE(sonicare, read_complete, ReadComplete)
ADD_SCENE(sonicare, reset_confirm, ResetConfirm)
ADD_SCENE(sonicare, reset, Reset)
ADD_SCENE(sonicare, reset_complete, ResetComplete)
+19 -5
View File
@@ -44,9 +44,18 @@ void sonicare_scene_read_complete_on_enter(void* context) {
furi_string_cat_printf(temp_str, "\e#%s\n", nfc_device_get_name(nfc_device, NfcDeviceNameTypeFull));
// UID
furi_string_cat_printf(temp_str, "UID:");
furi_string_cat_str(temp_str, "UID:");
format_bytes(temp_str, ul_data->iso14443_3a_data->uid, ul_data->iso14443_3a_data->uid_len);
furi_string_cat_printf(temp_str, "\n");
furi_string_cat_str(temp_str, "\n");
// Cache UID + MFG for the reset scene's password derivation.
// MFG (10 bytes, e.g. "221214 12K") = page[0x21].data[2..3] +
// page[0x22].data[0..3] + page[0x23].data[0..3]
// (first 2 bytes of page 0x21 are the max-usage value, not MFG).
memcpy(app->sonicare_uid, ul_data->iso14443_3a_data->uid, 7);
memcpy(app->sonicare_mfg + 0, ul_data->page[0x21].data + 2, 2);
memcpy(app->sonicare_mfg + 2, ul_data->page[0x22].data, 4);
memcpy(app->sonicare_mfg + 6, ul_data->page[0x23].data, 4);
// Manufacturing Code
furi_string_cat_str(temp_str, "MFG: ");
@@ -98,18 +107,23 @@ void sonicare_scene_read_complete_on_enter(void* context) {
furi_string_free(temp_str);
furi_string_free(serial_no);
// TODO: widget_add_button_element(widget, GuiButtonTypeRight, "Change", sonicare_scene_read_complete_widget_callback, app);
widget_add_button_element(
widget,
GuiButtonTypeRight,
"Reset",
sonicare_scene_read_complete_widget_callback,
app);
view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget);
}
bool sonicare_scene_read_complete_on_event(void* context, SceneManagerEvent event) {
UNUSED(context);
Sonicare* app = context;
bool consumed = false;
if (event.type == SceneManagerEventTypeCustom) {
if (event.event == GuiButtonTypeRight) {
// switch to edit screen
scene_manager_next_scene(app->scene_manager, SonicareSceneResetConfirm);
consumed = true;
}
} else if (event.type == SceneManagerEventTypeBack) {
+122
View File
@@ -0,0 +1,122 @@
#include "../uk_mbirth_sonicare.h"
#include "../sonicare_password.h"
#include <uk_mbirth_sonicare_icons.h>
#include <gui/scene_manager.h>
#include <gui/view_dispatcher.h>
#include <nfc/nfc.h>
#include <nfc/nfc_device.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight_poller.h>
#include <notification/notification.h>
#include <notification/notification_messages.h>
#include <dolphin/dolphin.h>
#define SONICARE_PAGE_USAGE 0x24
static NfcCommand sonicare_scene_reset_poller_callback(NfcGenericEvent event, void* context) {
furi_assert(context);
Sonicare* app = context;
if(event.protocol != NfcProtocolMfUltralight) {
return NfcCommandContinue;
}
const MfUltralightPollerEvent* ev = event.event_data;
NfcCommand command = NfcCommandContinue;
if(ev->type == MfUltralightPollerEventTypeRequestMode) {
ev->data->poller_mode = MfUltralightPollerModeRead;
} else if(ev->type == MfUltralightPollerEventTypeAuthRequest) {
// The poller asks us for the password before reading protected pages.
// Provide it (MSB-first, as in the 8-digit hex string).
uint32_t pwd = get_sonicare_password(app->sonicare_uid, app->sonicare_mfg);
FURI_LOG_I("sonicare_scene_reset", "AuthRequest - providing password %08lX", pwd);
ev->data->auth_context.password.data[0] = (pwd >> 24) & 0xFF;
ev->data->auth_context.password.data[1] = (pwd >> 16) & 0xFF;
ev->data->auth_context.password.data[2] = (pwd >> 8) & 0xFF;
ev->data->auth_context.password.data[3] = (pwd >> 0) & 0xFF;
ev->data->auth_context.skip_auth = false;
} else if(ev->type == MfUltralightPollerEventTypeAuthSuccess) {
FURI_LOG_I("sonicare_scene_reset", "Auth success, writing page 0x24");
MfUltralightPoller* poller = (MfUltralightPoller*)event.instance;
// Official reset: write page 0x24 = {00 00 02 00}
MfUltralightPage page = {.data = {0x00, 0x00, 0x02, 0x00}};
MfUltralightError err =
mf_ultralight_poller_write_page(poller, SONICARE_PAGE_USAGE, &page);
if(err == MfUltralightErrorNone) {
FURI_LOG_I("sonicare_scene_reset", "Usage counter reset OK");
app->reset_state = SonicareResetStateSuccess;
} else {
FURI_LOG_E("sonicare_scene_reset", "Write failed: %d", err);
app->reset_state = SonicareResetStateFailedWrite;
}
view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit);
command = NfcCommandStop;
} else if(ev->type == MfUltralightPollerEventTypeAuthFailed) {
FURI_LOG_E("sonicare_scene_reset", "Auth failed (poller state machine)");
app->reset_state = SonicareResetStateFailedAuth;
view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit);
command = NfcCommandStop;
} else if(ev->type == MfUltralightPollerEventTypeReadSuccess) {
// Card has no password protection or auth was skipped; try direct write.
FURI_LOG_I("sonicare_scene_reset", "ReadSuccess without prior auth, trying write");
MfUltralightPoller* poller = (MfUltralightPoller*)event.instance;
MfUltralightPage page = {.data = {0x00, 0x00, 0x02, 0x00}};
MfUltralightError err =
mf_ultralight_poller_write_page(poller, SONICARE_PAGE_USAGE, &page);
app->reset_state =
(err == MfUltralightErrorNone) ? SonicareResetStateSuccess : SonicareResetStateFailedWrite;
view_dispatcher_send_custom_event(app->view_dispatcher, NfcCustomEventWorkerExit);
command = NfcCommandStop;
}
return command;
}
void sonicare_scene_reset_on_enter(void* context) {
Sonicare* app = context;
Popup* popup = app->popup;
app->reset_state = SonicareResetStateInit;
popup_reset(popup);
popup_set_header(popup, "Resetting", 83, 8, AlignCenter, AlignTop);
popup_set_text(popup, "Hold brush stem\nnext to\nFlipper's back", 83, 27, AlignCenter, AlignTop);
popup_set_icon(app->popup, 0, 0, &I_sonicare_read);
view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewPopup);
app->poller = nfc_poller_alloc(app->nfc, NfcProtocolMfUltralight);
nfc_poller_start(app->poller, sonicare_scene_reset_poller_callback, app);
}
bool sonicare_scene_reset_on_event(void* context, SceneManagerEvent event) {
Sonicare* app = context;
bool consumed = false;
if(event.type == SceneManagerEventTypeCustom) {
if(event.event == NfcCustomEventWorkerExit) {
if(app->reset_state == SonicareResetStateSuccess) {
notification_message(app->notifications, &sequence_success);
dolphin_deed(DolphinDeedNfcRead);
} else {
notification_message(app->notifications, &sequence_error);
}
scene_manager_next_scene(app->scene_manager, SonicareSceneResetComplete);
consumed = true;
}
}
return consumed;
}
void sonicare_scene_reset_on_exit(void* context) {
Sonicare* app = context;
nfc_poller_stop(app->poller);
nfc_poller_free(app->poller);
popup_reset(app->popup);
}
+80
View File
@@ -0,0 +1,80 @@
#include "../uk_mbirth_sonicare.h"
#include <gui/canvas.h>
#include <gui/modules/widget.h>
#include <gui/scene_manager.h>
#include <gui/view_dispatcher.h>
#include <uk_mbirth_sonicare_icons.h>
#include <dolphin/dolphin.h>
static void sonicare_scene_reset_complete_widget_callback(
GuiButtonType result,
InputType type,
void* context) {
furi_assert(context);
Sonicare* app = context;
if(type == InputTypeShort) {
view_dispatcher_send_custom_event(app->view_dispatcher, result);
}
}
void sonicare_scene_reset_complete_on_enter(void* context) {
Sonicare* app = context;
Widget* widget = app->widget;
widget_reset(widget);
if(app->reset_state == SonicareResetStateSuccess) {
widget_add_icon_element(widget, 0, 0, &I_sonicare_brush);
widget_add_string_element(
widget, 64, 20, AlignCenter, AlignCenter, FontPrimary, "Counter reset!");
widget_add_string_element(
widget,
64,
40,
AlignCenter,
AlignCenter,
FontSecondary,
"Read again to verify");
} else {
const char* reason =
(app->reset_state == SonicareResetStateFailedAuth) ? "Auth failed" : "Write failed";
widget_add_string_element(
widget, 64, 20, AlignCenter, AlignCenter, FontPrimary, "Reset failed");
widget_add_string_element(
widget, 64, 40, AlignCenter, AlignCenter, FontSecondary, reason);
}
widget_add_button_element(
widget,
GuiButtonTypeLeft,
"Back",
sonicare_scene_reset_complete_widget_callback,
app);
view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget);
}
bool sonicare_scene_reset_complete_on_event(void* context, SceneManagerEvent event) {
Sonicare* app = context;
bool consumed = false;
if(event.type == SceneManagerEventTypeCustom) {
if(event.event == GuiButtonTypeLeft) {
// Go back to start menu so user can re-read the head
scene_manager_search_and_switch_to_previous_scene(
app->scene_manager, SonicareSceneStart);
consumed = true;
}
} else if(event.type == SceneManagerEventTypeBack) {
scene_manager_search_and_switch_to_previous_scene(
app->scene_manager, SonicareSceneStart);
consumed = true;
}
return consumed;
}
void sonicare_scene_reset_complete_on_exit(void* context) {
Sonicare* app = context;
widget_reset(app->widget);
}
+68
View File
@@ -0,0 +1,68 @@
#include "../uk_mbirth_sonicare.h"
#include <gui/canvas.h>
#include <gui/modules/widget.h>
#include <gui/scene_manager.h>
#include <gui/view_dispatcher.h>
#include <uk_mbirth_sonicare_icons.h>
#include <dolphin/dolphin.h>
static void sonicare_scene_reset_confirm_widget_callback(
GuiButtonType result,
InputType type,
void* context) {
furi_assert(context);
Sonicare* app = context;
if(type == InputTypeShort) {
view_dispatcher_send_custom_event(app->view_dispatcher, result);
}
}
void sonicare_scene_reset_confirm_on_enter(void* context) {
Sonicare* app = context;
Widget* widget = app->widget;
widget_reset(widget);
widget_add_string_element(
widget, 64, 2, AlignCenter, AlignTop, FontPrimary, "Reset usage counter?");
widget_add_text_box_element(
widget,
0,
14,
128,
38,
AlignLeft,
AlignTop,
"\e#Warning:\e# The NTAG213 permanently\nlocks after 3 wrong\npassword attempts.",
false);
widget_add_button_element(
widget, GuiButtonTypeLeft, "Cancel", sonicare_scene_reset_confirm_widget_callback, app);
widget_add_button_element(
widget, GuiButtonTypeRight, "Reset", sonicare_scene_reset_confirm_widget_callback, app);
view_dispatcher_switch_to_view(app->view_dispatcher, SonicareViewWidget);
}
bool sonicare_scene_reset_confirm_on_event(void* context, SceneManagerEvent event) {
Sonicare* app = context;
bool consumed = false;
if(event.type == SceneManagerEventTypeCustom) {
if(event.event == GuiButtonTypeRight) {
scene_manager_next_scene(app->scene_manager, SonicareSceneReset);
consumed = true;
} else if(event.event == GuiButtonTypeLeft) {
consumed = scene_manager_previous_scene(app->scene_manager);
}
} else if(event.type == SceneManagerEventTypeBack) {
consumed = scene_manager_previous_scene(app->scene_manager);
}
return consumed;
}
void sonicare_scene_reset_confirm_on_exit(void* context) {
Sonicare* app = context;
widget_reset(app->widget);
}