Перейти к содержанию

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():

  1. Найти метку по sha256(token) — проверить: not-found → revoked → expired.
  2. Атомарный UPDATE ... WHERE lastCounter < counter — если затронута 1 строка: новое легитимное касание, обновить lastCounter + lastIdempotencyKey.
  3. Если UPDATE затронул 0 строк — счётчик не вырос. Перечитать строку:
  4. lastCounter == counter И lastIdempotencyKey == idempotencyKeyретрай: касание уже потреблено, вернуть как есть.
  5. Иначе (регресс счётчика или новый 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').