Nfc
NFC-модуль: управление метками, криптография, clone detection, сброс чипа.
NfcTag жизненный цикл¶
sequenceDiagram
Consumer->>NfcTagEndpoint: registerTag(label?)
NfcTagEndpoint->>NfcTagService: generateToken() → 22-char base64url
NfcTagService->>NfcTagService: hashToken(token) → sha256 hex
NfcTagService->>DB: NfcTag(tokenHash, status=active, lastCounter=0, TTL=90д)
NfcTagEndpoint->>Consumer: NfcTagRegistration{tagId, token, expiresAt}
Consumer->>NfcTagEndpoint: confirmTagWritten(tagId, chipUid)
Note over DB: chipUid сохранён, метка готова к оплате
Merchant->>MerchantEndpoint: chargeByNfc(tagId, amount, counter, idempotencyKey)
MerchantEndpoint->>NfcTagService: assertNfcEnabled + resolveAndConsume
NfcTagService->>NfcTagService: монотонный counter → атомарный UPDATE
NfcTagService->>NfcTagService: checkTagLimits(perTap, daily)
MerchantEndpoint->>PspHmacClient: createIntent (merchant HMAC key)
NfcTagEndpoint методы¶
| Метод | Параметры | Действие | Требует NFC enabled |
|---|---|---|---|
registerTag |
label? |
Выпускает метку; возвращает plaintext token (единственный раз) | Да |
rotateTag |
tagId |
Новый token/expiresAt; lastCounter→0; лимиты сохраняются | Да |
confirmTagWritten |
tagId, chipUid |
Фиксирует UID физического чипа | Нет |
setTagLimits |
tagId, perTapLimit?, dailyLimit? |
Задаёт/снимает лимиты (satang); null = снять лимит |
Да |
prepareTagClear |
tagId |
Gate авторизации для owner-driven очистки; возвращает NfcTagClearCredentials |
Нет |
confirmTagCleared |
tagId |
Hard delete метки после физического сброса владельцем | Нет |
revokeTag |
tagId |
Soft-revoke (если есть chipUid) или hard delete (орфан без chipUid) | Нет |
listTags |
— | Список активных меток с spentToday из v_tx_history |
Нет |
prepareTagClear/confirmTagCleared/revokeTag/listTagsдоступны даже при выключенном NFC — аварийные ручки.
Лимиты и ограничения¶
| Параметр | Значение | Источник |
|---|---|---|
| TTL метки | 90 дней | NfcTagEndpoint._ttl = Duration(days: 90) |
| Макс. активных меток на пользователя | 3 | NfcTagEndpoint._quota = 3 |
| perTapLimit | задаётся пользователем (satang); null = без лимита |
setTagLimits |
| dailyLimit | задаётся пользователем (satang); null = без лимита |
setTagLimits |
| Дневной расход | SUM(amount) из v_tx_history WHERE direction=DEBIT |
NfcTagService.dailySpent() |
| Quota check | count + insert не транзакционны (низкий риск, self-inflicted) | registerTag comment |
Зависимость:
dailySpent()требует колонкиnfcTagIdво вьюv_tx_history(контракт C7). До мержа зоны 1 упадёт с «column does not exist».
Clone Detection¶
Механизм основан на монотонном счётчике NTAG (растёт переменным шагом; Android-спайк: +2 за касание).
Алгоритм в resolveAndConsume():
- Найти метку по
sha256(token)— проверить: not-found → revoked → expired. - Атомарный
UPDATE ... WHERE lastCounter < counter— если затронута 1 строка: новое легитимное касание, обновитьlastCounter+lastIdempotencyKey. - Если UPDATE затронул 0 строк — счётчик не вырос. Перечитать строку:
lastCounter == counterИlastIdempotencyKey == idempotencyKey→ ретрай: касание уже потреблено, вернуть как есть.- Иначе (регресс счётчика или новый
idempotencyKeyсо старым counter) → клон.
При обнаружении клона (_flagClone):
- nfc_tag.status = 'revoked', revokedAt = now()
- Best-effort FCM-уведомление владельцу по каналу security
- Бросается CloseloopException(code: 'NFC_CLONE_DETECTED')
Код: NfcTagService.resolveAndConsume() и NfcTagService._flagClone()
Ограничение: если между сбоем и ретраем метку тапнули ещё раз, ретрай старой попытки будет отклонён как клон — принятый остаточный риск.
Ротация (rotateTag)¶
sequenceDiagram
Consumer->>NfcTagEndpoint: rotateTag(tagId)
NfcTagEndpoint->>NfcTagService: assertNfcEnabled
NfcTagEndpoint->>NfcTagService: generateToken() → новый token
NfcTagEndpoint->>DB: UPDATE nfc_tag SET tokenHash=new, lastCounter=0,\n lastIdempotencyKey=null, expiresAt=now+90d, status=active, revokedAt=null
NfcTagEndpoint->>Consumer: NfcTagRegistration{tagId, token, expiresAt}
Consumer->>NfcTagEndpoint: confirmTagWritten(tagId, chipUid)
- Старый token немедленно становится невалидным (tokenHash заменён)
- Per-tag лимиты (
perTapLimit,dailyLimit) сохраняются - Write-protection не используется — чип перезаписывается без PWD_AUTH
NfcCryptoService¶
AES-256-GCM шифрование writePassword перед записью в БД.
| Параметр | Значение |
|---|---|
| Алгоритм | AES-256-GCM (cryptography пакет) |
| Ключ | nfcWritePasswordKey из passwords.yaml (base64, 32 байта) |
| Blob-формат | [1 байт версия=0x01][12 байт nonce][ciphertext][16 байт GCM-тег] → base64 |
| Хранение | nfc_tag.writePasswordEnc |
| Синглтон | NfcCryptoService.initialize() → вызывается в server.dart при старте |
Зачем: writePassword — секрет записи на чип. В открытом виде в БД не хранится; при необходимости (agent reset) расшифровывается на лету через NfcCryptoService.instance.decrypt().
На текущий момент write-protection не используется в системе —
NfcChipResetEndpointиprepareTagClearне возвращают пароль. Поле зарезервировано для будущей write-protection.
NfcChipResetEndpoint¶
Двухшаговый сброс чипа сотрудником. Доступен agent + operator + superadmin.
sequenceDiagram
Agent->>NfcChipResetEndpoint: getChipResetCredentials(chipUid | token)
NfcChipResetEndpoint->>DB: найти nfc_tag по chipUid ИЛИ sha256(token)
NfcChipResetEndpoint->>AuditLog: action='nfc_chip_reset_lookup'
NfcChipResetEndpoint->>Agent: ChipResetCredentials{tagId, status, label}
Agent->>NfcChipResetEndpoint: confirmChipReset(chipUid | token)
NfcChipResetEndpoint->>AuditLog: action='nfc_chip_reset'
NfcChipResetEndpoint->>DB: DELETE nfc_tag
| Метод | Параметры | Действие |
|---|---|---|
getChipResetCredentials |
chipUid? XOR token? |
Возвращает {tagId, status, label}; пишет в audit_log |
confirmChipReset |
chipUid? XOR token? |
Hard delete метки; пишет в audit_log |
Ровно один из chipUid / token должен быть передан; иначе VALIDATION_ERROR. Все операции логируются best-effort (сбой логирования не откатывает операцию).
NfcGlobalEnabled — C8 feature flag¶
| Параметр | Ключ в passwords.yaml | Значение по умолчанию |
|---|---|---|
| Глобальный kill-switch NFC | nfcGlobalEnabled |
true (отсутствие ключа = включён) |
Логика: NfcConfig.initialize() — выключает фичу только явное 'false' (регистр игнорируется). Инициализируется в server.dart при старте пода.
# config/passwords.yaml
nfcGlobalEnabled: 'false' # выключить NFC глобально
# nfcGlobalEnabled: 'true' # или убрать ключ = включено
assertNfcEnabled() проверяет ДВА условия:
1. NfcConfig.globalEnabled == true
2. user_profile.nfcEnabled == true для конкретного пользователя
Ошибка в обоих случаях: CloseloopException(code: 'NFC_DISABLED').