Enable 'hf felica info' to determine card issuer, serial, issuance date based on extracted IDi

This commit is contained in:
kormax
2026-08-26 20:40:05 +03:00
parent 727a7c879f
commit 3451febfc4
2 changed files with 531 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
[
{
"system": "0003",
"prefix": "0102",
"code": "JH",
"name": "Kitaca",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0103",
"code": "JE",
"name": "Suica",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0104",
"code": "JC",
"name": "TOICA",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0105",
"code": "JW",
"name": "ICOCA",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0107",
"code": "JK",
"name": "SUGOCA",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0252",
"code": "PB",
"name": "PASMO",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "0387",
"code": "TP",
"name": "manaca",
"format": "packed",
"serial_group": 5
},
{
"system": "0003",
"prefix": "04AD",
"code": "SU",
"name": "PiTaPa",
"format": "packed",
"serial_group": 5,
"sources": [
"https://github.com/soltia48/suica-viewer/"
]
},
{
"system": "0003",
"prefix": "05D5",
"code": "NR",
"name": "nimoca",
"format": "packed",
"serial_group": 5,
"sources": [
"https://github.com/soltia48/suica-viewer/"
]
},
{
"system": "0003",
"prefix": "05D7",
"code": "FC",
"name": "hayakaken",
"format": "packed",
"serial_group": 5,
"sources": [
"https://github.com/soltia48/suica-viewer/"
]
},
{
"system": "0003",
"format": "packed"
},
{
"system": "8029",
"prefix": "024D",
"code": "SR",
"name": "Saitama",
"format": "packed",
"serial_group": 5
},
{
"system": "8029",
"format": "packed"
},
{
"system": "802B",
"prefix": "0232",
"code": "TK",
"name": "Setamaru",
"format": "packed",
"serial_group": 5
},
{
"system": "802B",
"format": "packed"
},
{
"system": "80E0",
"prefix": "05D3",
"code": "IY",
"name": "icecard",
"format": "packed",
"serial_group": 5
},
{
"system": "80E0",
"prefix": "0FF0",
"code": "DE",
"name": "Desuca",
"format": "packed",
"counter_endian": "le",
"serial_group": 5
},
{
"system": "80E0",
"format": "packed"
},
{
"system": "80EF",
"prefix": "0D66",
"code": "HT",
"name": "Ica",
"format": "epoch",
"serial_group": 4
},
{
"system": "80EF",
"format": "epoch"
},
{
"system": "811D",
"format": "bcd"
},
{
"system": "8194",
"prefix": "2332",
"code": "IW",
"name": "Iwasaki",
"format": "epoch",
"serial_group": 4
},
{
"system": "8194",
"format": "epoch"
},
{
"system": "832C",
"prefix": "0367",
"code": "TC",
"name": "ecomyca",
"format": "epoch",
"serial_group": 4
},
{
"system": "832C",
"prefix": "0D69",
"code": "TL",
"name": "passca",
"format": "epoch",
"serial_group": 4
},
{
"system": "832C",
"format": "epoch"
},
{
"system": "83CC",
"prefix": "0F0C",
"code": "RB",
"name": "hareca",
"format": "packed",
"serial_group": 5
},
{
"system": "83CC",
"format": "packed"
},
{
"system": "84A1",
"prefix": "1822",
"code": "HH",
"name": "hanica",
"format": "packed",
"serial_group": 5
},
{
"system": "84A1",
"format": "packed"
},
{
"system": "8B43",
"prefix": "0FC9",
"code": "FK",
"name": "Noruca",
"format": "epoch",
"serial_group": 4
},
{
"system": "8B43",
"prefix": "0FCA",
"code": "AZ",
"name": "Aizu Noruca",
"format": "epoch",
"serial_group": 4
},
{
"system": "8B43",
"format": "epoch"
},
{
"system": "8B5D",
"prefix": "0F4F",
"code": "NB",
"name": "ryuto",
"format": "epoch",
"serial_group": 4
},
{
"system": "8B5D",
"format": "epoch"
},
{
"system": "FE00",
"format": "epoch",
"date_offset": 2
}
]
+282
View File
@@ -87,6 +87,7 @@
#define FELICA_POLLING_REQUEST_SYSTEM_CODE 0x01U
#define FELICA_SYSTEM_LIST_JSON "felica/felica_system_code_list"
#define FELICA_IC_CODE_LIST_JSON "felica/ic_code_list"
#define FELICA_ISSUERS_JSON "felica/issuers"
#define FELICA_CONTAINER_ISSUE_LIST_JSON "felica/felica_container_issue_information_list"
#define FELICA_AUTH_KEY_FILE_VERSION 1
@@ -523,6 +524,8 @@ static json_t *felica_ic_code_list = NULL;
static bool felica_ic_code_list_loaded = false;
static json_t *felica_container_issue_list = NULL;
static bool felica_container_issue_list_loaded = false;
static json_t *felica_issuers_list = NULL;
static bool felica_issuers_list_loaded = false;
static void set_last_known_card(felica_card_select_t card) {
last_known_card = card;
@@ -837,6 +840,264 @@ static json_t *felica_get_ic_code_list(void) {
return felica_ic_code_list;
}
static json_t *felica_get_issuers_list(void) {
if (felica_issuers_list_loaded) {
return felica_issuers_list;
}
felica_issuers_list_loaded = true;
char *path = NULL;
if (searchFile(&path, RESOURCES_SUBDIR, FELICA_ISSUERS_JSON, ".json", true) != PM3_SUCCESS) {
return NULL;
}
json_error_t error;
json_t *root = json_load_file(path, 0, &error);
if (root == NULL) {
PrintAndLogEx(WARNING, "Failed to parse `%s` line %d: %s", path, error.line, error.text);
free(path);
return NULL;
}
if (json_is_array(root) == false) {
PrintAndLogEx(WARNING, "Invalid `%s` format, expected array root", path);
json_decref(root);
free(path);
return NULL;
}
felica_issuers_list = root;
free(path);
return felica_issuers_list;
}
// First issuers.json entry whose `system` matches and whose `prefix` is absent (a per-system
// date-only fallback) or equals idi[0..1]. Entries are scanned in file order, so specific
// prefixes precede the fallback for the same system.
static const json_t *felica_find_issuer_entry(uint16_t system_code, const uint8_t idi[8]) {
json_t *list = felica_get_issuers_list();
if (list == NULL) {
return NULL;
}
char system_hex[5] = {0};
char prefix_hex[5] = {0};
snprintf(system_hex, sizeof(system_hex), "%04X", system_code);
snprintf(prefix_hex, sizeof(prefix_hex), "%02X%02X", idi[0], idi[1]);
size_t index = 0;
json_t *entry = NULL;
json_array_foreach(list, index, entry) {
if (json_is_object(entry) == false) {
continue;
}
const char *entry_system = felica_get_json_string(entry, "system");
if (entry_system == NULL || strcasecmp(entry_system, system_hex) != 0) {
continue;
}
const char *entry_prefix = felica_get_json_string(entry, "prefix");
if (entry_prefix == NULL || strcasecmp(entry_prefix, prefix_hex) == 0) {
return entry;
}
}
return NULL;
}
static bool felica_date_is_acceptable(int year, int month, int day) {
if (month < 1 || month > 12 || day < 1 || day > 31) {
return false;
}
static const int days_in_month[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int max_day = days_in_month[month - 1];
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0)) {
max_day = 29;
}
if (day > max_day) {
return false;
}
int current_year = 9999;
const time_t now = time(NULL);
if (now != (time_t)-1) {
const struct tm *tm_now = gmtime(&now);
if (tm_now != NULL) {
current_year = tm_now->tm_year + 1900;
}
}
return year >= 2000 && year <= current_year + 1;
}
static bool felica_epoch_offset_date(uint16_t offset_days, int *year, int *month, int *day) {
// Days since 1970-01-01 (2000-01-01 is 10957 days after the Unix epoch). Converted to a civil
// date with a pure integer algorithm (Howard Hinnant's civil_from_days) so there is no time_t
// width/overflow dependency; offset_days is a uint16_t, so all intermediates stay small.
const long z = (long)offset_days + 10957 + 719468; // shift to the internal era epoch (0000-03-01)
const long era = z / 146097;
const long doe = z - era * 146097; // [0, 146096]
const long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
const long y = yoe + era * 400;
const long doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
const long mp = (5 * doy + 2) / 153; // [0, 11]
*day = (int)(doy - (153 * mp + 2) / 5 + 1); // [1, 31]
*month = (int)(mp < 10 ? mp + 3 : mp - 9); // [1, 12]
*year = (int)(y + (*month <= 2));
return true;
}
// Default IDi byte offset for each encoding, overridable per entry via `date_offset`.
static int felica_default_date_offset(const char *encoding) {
if (encoding != NULL && strcmp(encoding, "bcd") == 0) {
return 0;
}
return 4; // packed, epoch
}
// Resolve an entry's date `format` (encoding) and its byte `offset` (default per encoding,
// overridden by the optional `date_offset` field). Returns the encoding, or NULL if absent.
static const char *felica_entry_format(const json_t *entry, int *offset) {
const char *encoding = felica_get_json_string(entry, "format");
if (encoding == NULL) {
return NULL;
}
*offset = felica_default_date_offset(encoding);
json_t *offset_json = json_object_get_ci(entry, "date_offset");
if (json_is_integer(offset_json)) {
*offset = (int)json_integer_value(offset_json);
}
return encoding;
}
// Decode the date at `offset` in the IDi per `encoding`: packed (big-endian Y/M/D bit-field),
// epoch (little-endian days since 2000-01-01), bcd (4-byte BCD century/year/month/day). Returns
// the raw y/m/d (not calendar-validated).
static bool felica_idi_decode(const char *encoding, int offset, const uint8_t idi[8], int *year, int *month, int *day) {
if (encoding == NULL) {
return false;
}
if (strcmp(encoding, "packed") == 0) {
if (offset < 0 || offset + 1 >= 8) {
return false;
}
const uint16_t p = ((uint16_t)idi[offset] << 8) | idi[offset + 1];
*year = 2000 + ((p >> 9) & 0x3F);
*month = (p >> 5) & 0x0F;
*day = p & 0x1F;
return true;
}
if (strcmp(encoding, "epoch") == 0) {
if (offset < 0 || offset + 1 >= 8) {
return false;
}
const uint16_t n = (uint16_t)idi[offset] | ((uint16_t)idi[offset + 1] << 8);
return felica_epoch_offset_date(n, year, month, day);
}
if (strcmp(encoding, "bcd") == 0) {
if (offset < 0 || offset + 3 >= 8) {
return false;
}
int digits[4];
for (int i = 0; i < 4; i++) {
const int hi = idi[offset + i] >> 4;
const int lo = idi[offset + i] & 0x0F;
if (hi > 9 || lo > 9) {
return false;
}
digits[i] = hi * 10 + lo;
}
*year = digits[0] * 100 + digits[1];
*month = digits[2];
*day = digits[3];
return true;
}
return false;
}
// Issuance date for display: line is suppressed unless it decodes to a sane calendar date.
static bool felica_idi_decode_date(const json_t *entry, const uint8_t idi[8], int *year, int *month, int *day) {
int offset = 0;
const char *encoding = felica_entry_format(entry, &offset);
return felica_idi_decode(encoding, offset, idi, year, month, day) &&
felica_date_is_acceptable(*year, *month, *day);
}
// Date embedded in the serial: raw values (an identifier, not calendar-validated). BCD systems
// carry no serial.
static bool felica_idi_serial_date(const json_t *entry, const uint8_t idi[8], int *year, int *month, int *day) {
int offset = 0;
const char *encoding = felica_entry_format(entry, &offset);
if (encoding == NULL || strcmp(encoding, "bcd") == 0) {
return false;
}
return felica_idi_decode(encoding, offset, idi, year, month, day);
}
// Regroup a serial for display: first `first` chars, then space-separated 4-char chunks.
static void felica_group_serial(const char *raw, int first, char *out, size_t out_sz) {
size_t len = strlen(raw);
size_t pos = 0;
size_t o = 0;
size_t head = ((size_t)first < len) ? (size_t)first : len;
for (size_t i = 0; i < head && o + 1 < out_sz; i++) {
out[o++] = raw[pos++];
}
while (pos < len && o + 1 < out_sz) {
out[o++] = ' ';
for (int k = 0; k < 4 && pos < len && o + 1 < out_sz; k++) {
out[o++] = raw[pos++];
}
}
out[o] = '\0';
}
static bool felica_idi_format_serial(const json_t *entry, const uint8_t idi[8], char *out, size_t out_sz) {
// A serial exists only when the row names an issuer `code` (date-only fallback rows omit it)
// and its date format carries one (bcd does not).
const char *encoding = felica_get_json_string(entry, "format");
const char *code = felica_get_json_string(entry, "code");
if (encoding == NULL || code == NULL) {
return false;
}
int year = 0, month = 0, day = 0;
if (felica_idi_serial_date(entry, idi, &year, &month, &day) == false) {
return false;
}
// epoch defaults to LE/width 4, packed to BE/width 5; either is overridable per entry.
bool little_endian = (strcmp(encoding, "epoch") == 0);
const char *endian = felica_get_json_string(entry, "counter_endian");
if (endian != NULL) {
little_endian = (strcasecmp(endian, "le") == 0);
}
int counter_width = (strcmp(encoding, "epoch") == 0) ? 4 : 5;
json_t *width_json = json_object_get_ci(entry, "counter_width");
if (json_is_integer(width_json)) {
const long w = (long)json_integer_value(width_json);
if (w >= 1 && w <= 16) {
counter_width = (int)w;
}
}
const uint16_t counter = little_endian
? (uint16_t)((uint16_t)idi[6] | ((uint16_t)idi[7] << 8))
: (uint16_t)(((uint16_t)idi[6] << 8) | idi[7]);
char raw[64] = {0};
if (snprintf(raw, sizeof(raw), "%s%02X%02X%02d%02d%02d%0*u",
code, idi[2], idi[3], year % 100, month, day,
counter_width, (unsigned int)counter) < 0) {
return false;
}
json_t *group_json = json_object_get_ci(entry, "serial_group");
if (json_is_integer(group_json)) {
felica_group_serial(raw, (int)json_integer_value(group_json), out, out_sz);
} else {
snprintf(out, out_sz, "%s", raw);
}
return true;
}
static json_t *felica_get_container_issue_list(void) {
if (felica_container_issue_list_loaded) {
return felica_container_issue_list;
@@ -1848,6 +2109,27 @@ static void felica_info_process_system(int level, uint8_t flags, felica_discover
sprint_hex_inrow(system->idi, sizeof(system->idi)));
PrintAndLogEx(level, " PMi............ " _GREEN_("%s"),
sprint_hex_inrow(system->pmi, sizeof(system->pmi)));
static const uint8_t zero_idi[8] = {0};
const json_t *issuer_entry = (memcmp(system->idi, zero_idi, sizeof(zero_idi)) != 0)
? felica_find_issuer_entry(system->system_code, system->idi)
: NULL;
if (issuer_entry != NULL) {
int year = 0, month = 0, day = 0;
const bool has_issuance_date = felica_idi_decode_date(issuer_entry, system->idi, &year, &month, &day);
char serial[96] = {0};
const bool has_serial = felica_idi_format_serial(issuer_entry, system->idi, serial, sizeof(serial));
if (has_issuance_date || has_serial) {
PrintAndLogEx(level, " Details:");
if (has_issuance_date) {
PrintAndLogEx(level, " Issuance Date.. " _GREEN_("%04d-%02d-%02d"), year, month, day);
}
if (has_serial) {
PrintAndLogEx(level, " Serial......... " _GREEN_("%s"), serial);
}
}
}
}
} else {
PrintAndLogEx(level, " IDM............ " _RED_("N/A"));