Skip to content

Instantly share code, notes, and snippets.

@tripolskypetr
Created May 10, 2026 13:33
Show Gist options
  • Select an option

  • Save tripolskypetr/41f7d6d74943abffaecaf103b59f55ad to your computer and use it in GitHub Desktop.

Select an option

Save tripolskypetr/41f7d6d74943abffaecaf103b59f55ad to your computer and use it in GitHub Desktop.
Signing SDK

Signature Validation HOWTO

Каждый успешный ответ сервера содержит поле signature — JWT, подписанный приватным ключом сервера по алгоритму RS256 со временем жизни 15 минут.

SDK должен верифицировать signature сразу после получения ответа, до использования данных из data. Это исключает MITM: атакующий не может сфабриковать валидную подпись без приватного ключа сервера.


Что нужно для верификации

  • public.pem — публичный ключ сервера (RS256), получить у команды бэкенда
  • signature — JWT из поля ответа
  • deviceId, bankId — идентификаторы, которые SDK передавал в запросе

Алгоритм верификации (единый для всех эндпоинтов)

1. Верифицировать JWT-подпись через public.pem (RS256)
2. Убедиться что токен не истёк (exp claim)
3. Убедиться что decoded.deviceId === ожидаемый deviceId
4. Убедиться что decoded.bankId === ожидаемый bankId
5. Убедиться что decoded.<ключевое поле> совпадает с data.<ключевое поле>

Шаги 3–5 защищают от подстановки валидного токена другого пользователя или другой операции.


Состав JWT payload по эндпоинтам

POST /api/v1/auth/register/options

Запрос содержит: deviceId, bankId, embeddingChecksum, dto

JWT payload:

{
  "challenge": "<base64url>",
  "rp": { "name": "...", "id": "..." },
  "user": { "id": "<base64url>", "name": "bankId:deviceId", "displayName": "bankId:deviceId" },
  "pubKeyCredParams": [...],
  "timeout": 60000,
  "attestation": "none",
  "authenticatorSelection": { ... },
  "registrationId": "<uuid>",
  "deviceId": "<string>",
  "bankId": "<string>",
  "dto": { ...произвольные поля банковского контекста... },
  "iat": 1234567890,
  "exp": 1234568790
}

Проверить:

  • decoded.deviceId === request.deviceId
  • decoded.bankId === request.bankId
  • decoded.registrationId === data.registrationId
  • decoded.challenge === data.challenge
  • decoded.dto совпадает с dto, переданным в запросе

dto не возвращается в data — он подписан только внутри signature. Это криптографическое подтверждение того, что сервер принял именно этот dto и сохранил его в Redis.

Примечание: поле challenge — это base64url(challengeJwt), где challengeJwt сам является JWT с { deviceId, bankId, registrationId }. Его дополнительная верификация необязательна, если проверены поля верхнего уровня.


POST /api/v1/auth/register/verify

Запрос содержит: deviceId, bankId, registrationId, attestation, originalImageBase64

JWT payload:

{
  "credentialId": "<string>",
  "dto": { ...произвольные поля банковского контекста... },
  "deviceId": "<string>",
  "bankId": "<string>",
  "iat": 1234567890,
  "exp": 1234568790
}

Проверить:

  • decoded.deviceId === request.deviceId
  • decoded.bankId === request.bankId
  • decoded.credentialId === data.credentialId

credentialId — идентификатор WebAuthn-credential, созданного в Secure Enclave. SDK должен сохранить его для последующей аутентификации.


POST /api/v1/auth/verify/options

Запрос содержит: deviceId, bankId, dto

JWT payload:

{
  "verificationId": "<uuid>",
  "challengeJwt": "<jwt>",
  "deviceId": "<string>",
  "bankId": "<string>",
  "dto": { ...произвольные поля банковского контекста... },
  "iat": 1234567890,
  "exp": 1234568790
}

Проверить:

  • decoded.deviceId === request.deviceId
  • decoded.bankId === request.bankId
  • decoded.verificationId === data.verificationId
  • decoded.challengeJwt === data.challengeJwt
  • decoded.dto совпадает с dto, переданным в запросе

dto не возвращается в data — он подписан только внутри signature. Это криптографическое подтверждение того, что сервер принял именно этот dto и сохранил его в Redis.

challengeJwt будет использован как WebAuthn challenge на следующем шаге. Верифицировать его содержимое через тот же public.pem: внутри { deviceId, bankId, verificationId }.


POST /api/v1/auth/verify

Запрос содержит: payload.deviceId, payload.bankId, payload.verificationId, payload.replay, assertion

JWT payload:

{
  ...поля dto (произвольные поля банковского контекста)...,
  "deviceId": "<string>",
  "bankId": "<string>",
  "iat": 1234567890,
  "exp": 1234568790
}

Проверить:

  • decoded.deviceId === request.payload.deviceId
  • decoded.bankId === request.payload.bankId
  • состав dto-полей совпадает с data

Это финальное подтверждение аутентификации. Валидная подпись означает: сервер прошёл все 8 проверок (WebAuthn assertion, replay, freshness, biometry, signCount и др.) и подтверждает, что именно этот deviceId/bankId прошёл верификацию.


POST /api/v1/check/capture

Запрос содержит: deviceId, bankId, originalImageBase64

JWT payload:

{
  "hash": "<sha256-hex>",
  "deviceId": "<string>",
  "bankId": "<string>",
  "iat": 1234567890,
  "exp": 1234568790
}

Проверить:

  • decoded.deviceId === request.deviceId
  • decoded.bankId === request.bankId
  • decoded.hash === data.hash

hash — SHA-256 хэш биометрического embedding изображения. Используется как embeddingChecksum при вызове /register/options.


Пример кода (Node.js / jsonwebtoken)

const fs = require("fs");
const jwt = require("jsonwebtoken");

const PUBLIC_KEY = fs.readFileSync("public.pem");

function verifySignature(signature, expectedDeviceId, expectedBankId) {
  let decoded;
  try {
    decoded = jwt.verify(signature, PUBLIC_KEY, { algorithms: ["RS256"] });
  } catch (err) {
    // TokenExpiredError, JsonWebTokenError, NotBeforeError
    throw new Error(`Signature invalid: ${err.message}`);
  }

  if (decoded.deviceId !== expectedDeviceId) {
    throw new Error(`deviceId mismatch: got ${decoded.deviceId}, expected ${expectedDeviceId}`);
  }
  if (decoded.bankId !== expectedBankId) {
    throw new Error(`bankId mismatch: got ${decoded.bankId}, expected ${expectedBankId}`);
  }

  return decoded;
}

// Пример: верификация ответа /auth/verify
const response = await fetch("/api/v1/auth/verify", { ... });
const body = await response.json();

const decoded = verifySignature(body.signature, myDeviceId, myBankId);
// decoded содержит dto + deviceId + bankId

Пример кода (Swift / использование сторонней библиотеки)

// Рекомендуется библиотека: SwiftJWT (IBM) или JWTDecode (Auth0)
// Пример с JWTDecode (только decode без верификации подписи — недостаточно для безопасности)
// Для полной верификации RS256 используйте SwiftJWT

import SwiftJWT

struct SignatureClaims: Claims {
    let deviceId: String
    let bankId: String
    let iat: Date?
    let exp: Date?
    // добавить специфичные для эндпоинта поля
}

func verifySignature(_ token: String, expectedDeviceId: String, expectedBankId: String) throws -> SignatureClaims {
    let publicKey = ... // Data из public.pem
    let verifier = JWTVerifier.rs256(publicKey: publicKey)
    let jwt = try JWT<SignatureClaims>(jwtString: token, verifier: verifier)
    guard jwt.claims.deviceId == expectedDeviceId else {
        throw VerificationError.deviceIdMismatch
    }
    guard jwt.claims.bankId == expectedBankId else {
        throw VerificationError.bankIdMismatch
    }
    return jwt.claims
}

Пример кода (Kotlin / Android)

// Рекомендуется библиотека: java-jwt (Auth0)
// implementation("com.auth0:java-jwt:4.4.0")

import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

fun verifySignature(token: String, expectedDeviceId: String, expectedBankId: String): Map<String, Any> {
    val publicKeyPem = // строка из public.pem без заголовков -----BEGIN/END PUBLIC KEY-----
    val keyBytes = Base64.getDecoder().decode(publicKeyPem.replace("\\s".toRegex(), ""))
    val publicKey = KeyFactory.getInstance("RSA")
        .generatePublic(X509EncodedKeySpec(keyBytes)) as RSAPublicKey

    val algorithm = Algorithm.RSA256(publicKey, null)
    val verifier = JWT.require(algorithm).build()
    val decoded = verifier.verify(token) // бросит исключение если подпись невалидна или токен истёк

    val deviceId = decoded.getClaim("deviceId").asString()
    val bankId = decoded.getClaim("bankId").asString()

    require(deviceId == expectedDeviceId) { "deviceId mismatch" }
    require(bankId == expectedBankId) { "bankId mismatch" }

    return mapOf("deviceId" to deviceId, "bankId" to bankId)
    // добавить извлечение специфичных для эндпоинта полей
}

Важные замечания

  • Никогда не доверяйте data из ответа без верификации signature — именно signature является криптографическим доказательством того, что сервер выдал эти данные.
  • Порядок проверок: сначала верификация подписи, затем сравнение полей — не наоборот.
  • Токен истекает через 15 минут — не кешируйте signature для повторного использования.
  • public.pem не является секретом, но его целостность критична: хранить в бандле приложения, не загружать динамически с сервера.

Target SDK architecture

TODO

TadSigningSDK exposes a single method sign(dto). The bank application developer writes one line:

let result = await TadSigning.sign(dto: ["amount": 1000, "to": "IBAN..."])

And gets back statusOk or statusError(error:, errorCode:). Everything else lives inside the SDK.


Roadmap: Flutter binding

Later the SDK will get a Flutter wrapper via MethodChannel / FFI — a thin abstraction layer that delegates calls to the native iOS/Android SDK modules. There will be no separate Dart implementation of the SDK. Dart code can be decompiled and runs in a VM, so all cryptography, biometrics, storage and network calls stay on the native side.

Implications for the public iOS SDK behaviour:

  • errorCode is an enum-like string whose names sound equally natural on iOS and Android. WEBAUTHN_USER_NOT_FOUND, INTEGRITY_TAMPERED_OS, JWT_SIGNATURE_INVALID are neutral. KEYCHAIN_FAILURE, SECURE_ENCLAVE_UNAVAILABLE, KEYSTORE_LOCKED, STRONGBOX_UNAVAILABLE, INTEGRITY_JAILBREAK (iOS-only term), INTEGRITY_ROOT (Android-only term) are platform-specific and must not be exposed. Under the hood the iOS SDK is free to do any checks and use any platform APIs; in the public errorCode everything is normalized to the shared name set.
  • All WEBAUTHN errors come from the backend. The SDK does not invent them — it just forwards them as a string and decides on registration wipe by the WEBAUTHN_ prefix.
  • Storage is the native mechanism of each platform behind a common interface. On iOS — Keychain. On Android — EncryptedSharedPreferences. On Flutter nothing new is added: MethodChannel forwards the call to the platform, and the very same Keychain / EncryptedSharedPreferences is used. No flutter_secure_storage or any other "cross-platform" solution. The only requirement is to put Keychain behind a thin SecureStorage interface inside the iOS SDK so that the mirror Android SDK has the same contract.
  • Every public API method must have a mirror Kotlin counterpart, otherwise MethodChannel cannot proxy it. Method names and enum codes are unified.

Any PR that adds a platform-specific name to the public errorCode or breaks iOS/Android API mirroring must be rejected.


Public API

Configuration (called once at app initialization)

TadSigning.configure(
    apiBaseUrl:    URL(string: "https://signing.tadi.uz")!,
    publicKeyPem:  "-----BEGIN PUBLIC KEY-----\n...",
    rpId:          "signing.tadi.uz",
    serviceName:   "tad-signing-demo",
    bankId:        "demo-user-001"    // pinned here, not passed into sign()
)

DTO signing

public enum TadSigning {

    /// SDK configuration. Called once at app start.
    public static func configure(
        apiBaseUrl:   URL,
        publicKeyPem: String,
        rpId:         String,
        serviceName:  String,
        bankId:       String
    )

    /// Registration check. Local: returns true when the secure storage
    /// holds a (deviceId, credentialId) pair for the current bankId.
    /// Performs no network requests.
    public static func isRegistered() -> Bool

    /// Removes the registration from local storage.
    /// deviceId is a random UUID issued by the SDK at registration time;
    /// it is NOT bound to the device and is NOT reused. The next sign()
    /// after logout() performs a fresh registration with a new random deviceId.
    /// Performs no network requests; the server-side credential expires on its own.
    public static func logout()

    /// Performs ONE operation chosen by isRegistered():
    /// - if isRegistered() == false — registration runs. One call to /api/v1/auth/register/*.
    /// - if isRegistered() == true  — verification runs. One call to /api/v1/auth/verify.
    /// Exactly ONE operation per sign() call, never both.
    ///
    /// On errorCode with prefix WEBAUTHN_, JWT_ or INTEGRITY_ the SDK wipes the
    /// local registration and returns statusError. The caller can invoke sign(dto)
    /// again — the next call will start with registration.
    public static func sign(dto: [String: Any]) async -> TadSigningStatus
}

public enum TadSigningStatus {
    case statusOk
    case statusError(error: String, errorCode: String)
}

statusError fields

Field Contents
error Human-readable message for logs / user dialog
errorCode String. One of four classes — see the table below

JWT, deviceId, credentialId, verificationId, registrationIdnever returned to the caller.

errorCode classes

Class / prefix Source What the SDK does
WEBAUTHN_* (e.g. WEBAUTHN_USER_NOT_FOUND, WEBAUTHN_FACE_EXPIRED, WEBAUTHN_REPLAY_USED, WEBAUTHN_EMBEDDING_MISMATCH, WEBAUTHN_SIGN_COUNT, WEBAUTHN_REGISTRATION_ID_MISMATCH, WEBAUTHN_CHALLENGE_EXPIRED, WEBAUTHN_DTO_EXPIRED) Server Wipes registration, returns statusError. No auto-retry. The next sign() will start from registration.
JWT_* (e.g. JWT_SIGNATURE_INVALID, JWT_EXPIRED, JWT_DEVICE_MISMATCH, JWT_BANK_MISMATCH, JWT_FIELD_MISMATCH, JWT_ALG_MISMATCH) Local JWT validation in the SDK against public.pem per HOWTO.md Wipes registration, returns statusError. The signature is compromised — continuing is not allowed.
INTEGRITY_* (e.g. INTEGRITY_TAMPERED_OS, INTEGRITY_DEBUGGER, INTEGRITY_PROXY, INTEGRITY_HOOKING, INTEGRITY_EMULATOR, INTEGRITY_PINNING) Local integrity checks Wipes registration, returns statusError. Internally the SDK is free to implement any checks; only these neutral strings are exposed. INTEGRITY_TAMPERED_OS covers both jailbreak (iOS) and root (Android) — a single name for both platforms.
FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK Server / user / platform statusError without wipe. The registration stays valid; a subsequent sign() will take the short "verification only" path.

No platform-specific names (SECURE_ENCLAVE_*, KEYCHAIN_*, KEYSTORE_*, STRONGBOX_*, JAILBREAK_*, ROOT_*, PROXY_* without an INTEGRITY_ prefix) in the public errorCode. Each name must read equally naturally on iOS and Android — this is required by the Flutter wrapper, which proxies the same values from both platforms.


sign(dto) lifecycle

flowchart TD
  Start([App: TadSigning.sign&#40;dto&#41;])
  Start --> Integrity{Integrity checks<br/>pass?}
  Integrity -- no --> ErrInt([statusError<br/>INTEGRITY_xxx])
  Integrity -- yes --> Lock[Acquire mutex by bankId]
  Lock --> Read{isRegistered?<br/>storage holds<br/>deviceId + credentialId}

  Read -- no --> Reg[Subflow: registration<br/>one call to /api/v1/auth/register/*]
  Read -- yes --> Ver[Subflow: verification<br/>one call to /api/v1/auth/verify]

  Reg -- ok --> Done([statusOk<br/>registration saved])
  Reg -- error --> WipeIfDirty[If a partial record was<br/>created — wipe]
  WipeIfDirty --> ErrReg([statusError<br/>specific code])

  Ver -- ok --> Done
  Ver -- WEBAUTHN_xxx --> Wipe[wipe registration<br/>from storage]
  Ver -- JWT_xxx --> Wipe
  Ver -- INTEGRITY_xxx --> Wipe
  Ver -- other error --> ErrOther([statusError<br/>specific code<br/>registration intact])

  Wipe --> ErrWipe([statusError<br/>specific code<br/>next sign&#40;&#41;<br/>will go through registration])

  Done --> Unlock[Release mutex]
  ErrInt --> Unlock
  ErrReg --> Unlock
  ErrOther --> Unlock
  ErrWipe --> Unlock

  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef sub fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313,font-weight:bold
  classDef rec fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Integrity,Read chk
  class Reg,Ver,Lock,Unlock,WipeIfDirty sub
  class Done ok
  class ErrInt,ErrReg,ErrOther,ErrWipe err
  class Wipe rec
Loading

Key rules:

  • sign() performs one operation per call: either registration or verification. Never both. The decision is made by isRegistered().
  • If isRegistered() == falsesign() performs registration. On success a deviceId + credentialId pair appears in storage and sign() returns statusOk. The DTO is NOT signed in this call — signing requires another sign() call.
  • If isRegistered() == truesign() performs verification with the supplied dto. On success statusOk, the registration is preserved.
  • If verify fails with WEBAUTHN_* / JWT_* / INTEGRITY_* — wipe + statusError. The next sign() will start with registration (because isRegistered() is now false).
  • All other errors (USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK, FACEIDS_*) — statusError without wipe. The registration stays valid.
  • No auto-retry inside a single sign() call.

Typical caller code for signing a document:

if !TadSigning.isRegistered() {
    // first sign() does registration; an empty dto is fine
    let s1 = await TadSigning.sign(dto: [:])
    guard case .statusOk = s1 else { return /* handle error */ }
}
// once registered — sign() with the real DTO performs the signing
let s2 = await TadSigning.sign(dto: realDto)

If a registration is already in place, the first if is skipped and signing happens in a single call.


Registration subflow

flowchart TD
  Start([Subflow: registration])
  Start --> Gen[deviceId = UUID&#40;&#41;<br/>random for EVERY registration,<br/>not bound to the device]
  Gen --> Cam[Camera + liveness, 2 frames]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{Verify JWT<br/>against public.pem<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx])
  CapV -- ok --> Opts[POST /api/v1/auth/register/options]
  Opts --> OptsV{Verify JWT<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>create key,<br/>sign challenge]
  SE -- error --> ErrLocal([fail: local code<br/>e.g. USER_CANCELLED])
  SE -- ok --> Ver[POST /api/v1/auth/register/verify]
  Ver --> VerV{Verify JWT<br/>+ credentialId === data.credentialId<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Save[ATOMICALLY save:<br/>deviceId + credentialId]
  Save --> Done([ok])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313

  class Gen,Cam,Save sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal err
Loading

Verification subflow

flowchart TD
  Start([Subflow: verification<br/>identity = deviceId + credentialId<br/>from local storage])
  Start --> Cam[Camera + liveness, 2 frames]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{Verify JWT<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx<br/>caller will wipe])
  CapV -- ok --> Opts[POST /api/v1/auth/verify/options]
  Opts --> WErr1{Server returned<br/>WEBAUTHN_xxx?}
  WErr1 -- yes --> WebAuth([fail: WEBAUTHN_xxx<br/>caller will wipe])
  WErr1 -- no --> OptsV{Verify JWT<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>sign challengeJwt<br/>via credentialId]
  SE -- error --> ErrLocal([fail: local code])
  SE -- ok --> Ver[POST /api/v1/auth/verify]
  Ver --> WErr2{Server returned<br/>WEBAUTHN_xxx?}
  WErr2 -- yes --> WebAuth
  WErr2 -- no --> Other{Server returned<br/>FACEIDS_xxx or other error?}
  Other -- yes --> ErrOther([fail: FACEIDS_xxx<br/>registration intact])
  Other -- no --> VerV{Verify JWT<br/>+ dto fields deepEqual data<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Done([ok<br/>jwt stays inside SDK])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313
  classDef wb fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Cam sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV,WErr1,WErr2,Other chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal,ErrOther err
  class WebAuth wb
Loading

Sequence: what the caller sees

sequenceDiagram
    autonumber
    participant App as Bank app code
    participant SDK as TadSigningSDK
    participant Store as Storage
    participant API as Server

    App->>SDK: TadSigning.sign(dto)
    SDK->>SDK: integrity checks
    SDK->>Store: read(bankId)

    alt no pair (isRegistered == false) — REGISTRATION
        Store-->>SDK: nil
        Note over SDK: one call to /api/v1/auth/register/*<br/>with a new deviceId
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/register/options<br/>POST /api/v1/auth/register/verify
        alt register ok
            API-->>SDK: credentialId
            SDK->>Store: save(deviceId, credentialId)
            SDK-->>App: statusOk
            Note over App: dto NOT signed in this call —<br/>call sign(dto) again to sign
        else register error
            API-->>SDK: error
            SDK->>Store: wipe (if a partial record was created)
            SDK-->>App: statusError(errorCode: ...)
        end
    else pair present (isRegistered == true) — VERIFICATION
        Store-->>SDK: deviceId, credentialId
        Note over SDK: one call to /api/v1/auth/verify<br/>with the supplied dto
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/verify/options<br/>POST /api/v1/auth/verify

        alt API: WEBAUTHN_xxx
            API-->>SDK: error WEBAUTHN_xxx
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: WEBAUTHN_xxx)
            Note over App: next sign() will start with registration
        else local: JWT_xxx
            Note over SDK: JWT verification against public.pem failed
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: JWT_xxx)
        else local: INTEGRITY_xxx
            Note over SDK: jailbreak / Frida / proxy / ...
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: INTEGRITY_xxx)
        else API: FACEIDS_xxx
            API-->>SDK: error FACEIDS_xxx
            Note over SDK,Store: registration intact — no wipe
            SDK-->>App: statusError(errorCode: FACEIDS_xxx)
        else local: USER_CANCELLED / CAMERA_UNAVAILABLE / NETWORK
            Note over SDK,Store: registration intact — no wipe
            SDK-->>App: statusError(errorCode: ...)
        else verify ok
            API-->>SDK: signature
            SDK-->>App: statusOk
        end
    end
Loading

Recovery rule (precise)

function sign(dto):
    // 1. Environment integrity (jailbreak / Frida / proxy / pinning / ...)
    if !checkIntegrity():
        secureStorage.wipe(bankId)            // registration is at risk
        return statusError(error, errorCode: "INTEGRITY_xxx")

    under mutex(bankId):
        identity = secureStorage.read(bankId)

        // BRANCH A: no registration → ONLY do registration
        if identity == nil:
            result = doRegister(new random deviceId)
            if result.ok:
                secureStorage.save(bankId, result.identity)
                return statusOk
            else:
                // if a partial record was created — wipe; the UUID is single-use
                if secureStorage.read(bankId) != nil:
                    secureStorage.wipe(bankId)
                return statusError(error, errorCode: result.errorCode)

        // BRANCH B: registration exists → ONLY do verification
        result = doVerify(identity, dto)
        if result.ok:
            return statusOk

        code = result.errorCode

        // error classes after which the registration is considered tainted
        if code.hasPrefix("WEBAUTHN_")
            or code.hasPrefix("JWT_")
            or code.hasPrefix("INTEGRITY_"):
            secureStorage.wipe(bankId)
            return statusError(error, errorCode: code)

        // FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK — registration intact
        return statusError(error, errorCode: code)


function isRegistered() -> Bool:
    return secureStorage.read(bankId) != nil

function logout():
    secureStorage.wipe(bankId)
    // deviceId was a random UUID issued by the SDK at registration time.
    // It is single-use and not tied to the device. After logout()
    // any subsequent registration will issue a NEW random UUID.

Wipe rules:

  • errorCode.hasPrefix("WEBAUTHN_") — the server side no longer recognizes our pair → wipe.
  • errorCode.hasPrefix("JWT_") — local JWT validation against public.pem (see HOWTO.md) failed → wipe (the session is compromised).
  • errorCode.hasPrefix("INTEGRITY_") — the runtime environment is compromised → wipe.
  • Everything else (FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK) — the registration stays valid.

No auto-retry inside sign(). The caller can check isRegistered() and/or simply call sign() again — the next call after a wipe will start with a fresh registration with a new random deviceId.


Nature of deviceId

deviceId is a random UUID that the SDK generates for every new registration. It is not a device identifier, not identifierForVendor, not any kind of stable code. Properties:

  • Issued anew at every first sign() after logout() or after a wipe due to a WEBAUTHN_* / JWT_* / INTEGRITY_* error.
  • Not derived from any hardware characteristics, IMEI, serial numbers etc. — strictly UUID() from a cryptographically secure generator.
  • Over the lifetime of a single physical device dozens of different deviceId values may exist — that is normal.
  • One deviceId lives from its registration until its wipe or logout().

Why so: the server side (MongoDB) holds a unique compound index {deviceId, bankId} and explicitly checks for an existing credential before creating one. Any attempt to re-register with the old deviceId is rejected. Therefore the only correct path is to always issue a new random UUID at every registration.

The caller does not use this knowledge and never receives deviceId. But the SDK team must understand it to avoid bugs of the kind "let's cache deviceId and reuse it".


Secure local storage

The storage contract is abstract — this is a Flutter-binding requirement. The concrete iOS implementation hides behind the SecureStorage interface (read / save / wipe) and does not affect the public SDK contract.

Parameter Requirement
Contract read(bankId) -> Identity?, save(bankId, Identity), wipe(bankId), wipeAll()
Guarantee The record is not accessible to other apps, does not leak into cloud backup, does not survive app deletion
Record format (JSON) { "deviceId": "<uuid>", "credentialId": "<base64url>", "registeredAt": "<iso8601>" }
Record identity By bankId from TadSigningConfig
Concurrency Mutex / actor by bankId. Concurrent sign() calls are serialized.
Logs deviceId, credentialId are masked (4+4 chars)

iOS implementation (internal detail, not part of the public API): Keychain, kSecClassGenericPassword, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrSynchronizable = false. In the mirror Android SDK — EncryptedSharedPreferences (AES-256-GCM, AndroidKeyStore). The Flutter wrapper via MethodChannel reuses the very same Keychain on iOS and the very same EncryptedSharedPreferences on Android — no separate Dart-side implementation is planned.

No UserDefaults, no files in Documents, no duplicate copies.


Server contract (fixed)

Endpoint Request Response data
POST /api/v1/check/capture deviceId, bankId, originalImageBase64 hash
POST /api/v1/auth/register/options deviceId, bankId, embeddingChecksum, dto registrationId, challenge, ...WebAuthnOptions
POST /api/v1/auth/register/verify registrationId, attestation, originalImageBase64 credentialId
POST /api/v1/auth/verify/options deviceId, bankId, dto verificationId, challengeJwt
POST /api/v1/auth/verify payload {deviceId, bankId, verificationId, replay}, assertion, originalImageBase64, compareImageBase64 dto

Every successful response contains a signature — a JWT signed by the server's private key. The algorithm is taken from TadSigningConfig.publicKeyPem (currently — ES512). TTL — 15 minutes.

Mandatory signature checks

Endpoint Beyond deviceId, bankId, exp
POST /api/v1/check/capture decoded.hash === data.hash
POST /api/v1/auth/register/options decoded.registrationId === data.registrationId, decoded.challenge === data.challenge, decoded.dto deepEqual request.dto
POST /api/v1/auth/register/verify decoded.credentialId === data.credentialId
POST /api/v1/auth/verify/options decoded.verificationId === data.verificationId, decoded.challengeJwt === data.challengeJwt, decoded.dto deepEqual request.dto
POST /api/v1/auth/verify every dto field present in data must match the same-named field in decoded

alg-confusion guard

The JWT header algorithm must match the algorithm of the key from publicKeyPem. Any mismatch — errorCode: "JWT_ALG_MISMATCH". No alg=none, no alg=HS256 with a public key.


Acceptance criteria

Unit tests

Scenario Expected
isRegistered() with empty storage false, no network requests
isRegistered() after a successful registration sign() true, no network requests
logout() after a registration isRegistered() becomes false, no network requests, the old deviceId is not stored anywhere
sign() with empty storage one call to POST /api/v1/check/capture + POST /api/v1/auth/register/options + POST /api/v1/auth/register/verify (WITHOUT POST /api/v1/auth/verify/options and POST /api/v1/auth/verify), exactly one record in storage, statusOk
sign() with populated storage one call to POST /api/v1/check/capture + POST /api/v1/auth/verify/options + POST /api/v1/auth/verify (WITHOUT POST /api/v1/auth/register/options and POST /api/v1/auth/register/verify), statusOk
sign() (registration) → sign() (verification) the first call does only register, the second does only verify, both return statusOk. POST /api/v1/auth/register/options and POST /api/v1/auth/register/verify are hit exactly during the first call, POST /api/v1/auth/verify/options and POST /api/v1/auth/verify exactly during the second.
sign() after logout() one register call, statusOk. The deviceId differs from the one used before logout().
verifyWEBAUTHN_USER_NOT_FOUND wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered() becomes false. Auto-register is not triggered.
verifyWEBAUTHN_FACE_EXPIRED wipe, statusError(errorCode: "WEBAUTHN_FACE_EXPIRED"), isRegistered() becomes false
verify → any WEBAUTHN_* wipe, statusError(errorCode: <same>), isRegistered() becomes false
Subsequent sign() after a wipe due to WEBAUTHN_* one register call with a new random deviceId (different from the wiped one), statusOk. To sign — another sign() is needed.
verify JWT failed validation (signature, exp, deviceId mismatch) wipe, statusError(errorCode: "JWT_xxx"), isRegistered() becomes false
verifyFACEIDS_SIMILARITY_SCORE_TOO_LOW no wipe, statusError(errorCode: "FACEIDS_SIMILARITY_SCORE_TOO_LOW"), isRegistered() stays true
Biometry refused no wipe, statusError(errorCode: "USER_CANCELLED"), isRegistered() unchanged
Camera unavailable no wipe, statusError(errorCode: "CAMERA_UNAVAILABLE")
Network failure no wipe, statusError(errorCode: "NETWORK")
decoded.dto != request.dto at POST /api/v1/auth/register/options wipe, statusError(errorCode: "JWT_FIELD_MISMATCH")
JWT alg does not match the configured key wipe, statusError(errorCode: "JWT_ALG_MISMATCH")
Integrity check trips wipe, statusError(errorCode: "INTEGRITY_xxx"), no network requests reach the server
Concurrent sign(dto) serialized by mutex. If both are called against an empty storage — the first does register and returns statusOk, the second sees a valid registration and does verify.

Integration against staging

  • "Registration and signing" scenario: logout()sign(dto: [:])sign(dto: realDto). The first sign() does only register, the second does only verify. Both return statusOk. Between them a deviceId + credentialId pair appears in storage.
  • "Server-side credential deletion" scenario: after a successful registration the operator deletes the credential on the server → sign(dto: realDto) returns statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND") and wipes → the next sign() does registration again (now with a different deviceId) and returns statusOk without DTO signing → another sign(dto: realDto) performs the signing.
  • An attacking middleware tampers with credentialId in the response of POST /api/v1/auth/register/verifystatusError(errorCode: "JWT_FIELD_MISMATCH"), nothing is written to storage (atomicity).
  • An attacking middleware tampers with dto in the response of POST /api/v1/auth/verify/optionsstatusError(errorCode: "JWT_FIELD_MISMATCH"). Prefix JWT_ — wipe. The next sign() will go through registration.
  • On a jailbroken (iOS) or rooted (Android) device sign() returns statusError(errorCode: "INTEGRITY_TAMPERED_OS") with no network requests.

Scope of work

Breaking change. This is a release without backward compatibility. The old API (TadSigningViewController(mode:), TadSigningMode, completion-based TadSigningResult) is removed in full. No @available(*, deprecated), no transitional versions, no wrappers. SDK consumers migrate once — to the new TadSigning.sign(dto:).

Module What is done
Public API namespace TadSigning: configure(), sign(), isRegistered(), logout(), TadSigningStatus. The old TadSigningViewController(mode:), TadSigningMode and related types are removed from the bundle.
sign() orchestration register / verify branches by isRegistered(), state machine, mutex, random deviceId generator for every registration
SecureStorage abstraction read / save / wipe / wipeAll interface, isolation by bankId. iOS Keychain implementation is a hidden detail.
JWT validation Validator against public.pem for all 5 endpoints per HOWTO.md: signature, exp, deviceId / bankId, endpoint-specific fields, dto deepEqual, alg-confusion guard. All errors under the JWT_* prefix.
Error mapping classification by WEBAUTHN_* / JWT_* / INTEGRITY_* / other prefixes → wipe or not
Integrity checks jailbreak / debugger / Frida / emulator / proxy / cert pinning / bundle integrity. Result — errorCode under the neutral INTEGRITY_* prefix. The implementation is iOS-specific and not exposed.
Tests unit + integration per the tables above
Sample app + documentation a single Sign button, README, migration guide

Out of scope

  • The server is not changed. The API contract is fixed.
  • The signature algorithm is not changed (RS256 → ES512 — separate backlog).
  • Camera / liveness UI is not changed.
  • JWT, deviceId, credentialId, verificationId are never exposed via the public API. Never. Not via completion, not via delegate.

Целевая архитектура SDK

TODO

TadSigningSDK экспортирует один метод sign(dto). Прикладной программист банка пишет одну строку:

let result = await TadSigning.sign(dto: ["amount": 1000, "to": "IBAN..."])

И получает statusOk или statusError(error:, errorCode:). Всё остальное — внутри SDK.


Roadmap: Flutter-привязка

Позже SDK получит Flutter-обёртку через MethodChannel / FFI — это тонкий слой абстракции, который делегирует вызовы в нативные iOS/Android-модули SDK. Отдельной реализации SDK на Dart не будет. Dart-код декомпилируется и крутится в VM, поэтому вся криптография, биометрия, хранение и сетевые вызовы остаются на нативной стороне.

Из этого вытекают требования к публичному поведению SDK на iOS:

  • errorCode — это enum-подобная строка, имена которой одинаково естественно звучат и на iOS, и на Android. WEBAUTHN_USER_NOT_FOUND, INTEGRITY_TAMPERED_OS, JWT_SIGNATURE_INVALID — нейтральные. KEYCHAIN_FAILURE, SECURE_ENCLAVE_UNAVAILABLE, KEYSTORE_LOCKED, STRONGBOX_UNAVAILABLE, INTEGRITY_JAILBREAK (iOS-термин), INTEGRITY_ROOT (Android-термин) — платформенные, наружу выходить не должны. Под капотом iOS-SDK волен делать любые проверки и использовать любые платформенные API; в публичном errorCode всё это нормализуется до общего набора имён.
  • Все WEBAUTHN-ошибки приходят с бэкенда. SDK их не выдумывает — только пробрасывает наружу строкой и принимает решение о wipe регистрации по префиксу WEBAUTHN_.
  • Хранилище — нативный механизм каждой платформы за общим интерфейсом. На iOS — Keychain. На Android — EncryptedSharedPreferences. На Flutter ничего нового не добавляется: MethodChannel пробрасывает вызов в платформу, и работает тот же Keychain или тот же EncryptedSharedPreferences. Никакого flutter_secure_storage или другого «кросс-платформенного» решения. Единственное требование — внутри iOS-SDK вынести Keychain за тонкий интерфейс SecureStorage, чтобы зеркальный Android-SDK имел такой же контракт.
  • Любой публичный API метод обязан иметь зеркальный Kotlin-эквивалент, иначе MethodChannel не сможет его пробросить. Имена методов и enum-кодов — единые.

Любой PR, добавляющий platform-specific имя в публичный errorCode или нарушающий зеркальность iOS/Android API, должен быть отклонён.


Публичный API

Конфигурация (один раз при инициализации приложения)

TadSigning.configure(
    apiBaseUrl:    URL(string: "https://signing.tadi.uz")!,
    publicKeyPem:  "-----BEGIN PUBLIC KEY-----\n...",
    rpId:          "signing.tadi.uz",
    serviceName:   "tad-signing-demo",
    bankId:        "demo-user-001"    // зашит здесь, не передаётся в sign()
)

Подпись DTO

public enum TadSigning {

    /// Конфигурация SDK. Вызывается один раз при старте приложения.
    public static func configure(
        apiBaseUrl:   URL,
        publicKeyPem: String,
        rpId:         String,
        serviceName:  String,
        bankId:       String
    )

    /// Проверка регистрации. Локальная: возвращает true, если в защищённом
    /// хранилище есть пара (deviceId, credentialId) для текущего bankId.
    /// Сетевых запросов не делает.
    public static func isRegistered() -> Bool

    /// Удалить регистрацию из локального хранилища.
    /// deviceId — это случайный UUID, выпущенный SDK при регистрации; он
    /// НЕ привязан к устройству и НЕ переиспользуется. Следующий sign() после
    /// logout() выполнит полную регистрацию с новым случайным deviceId.
    /// Сетевых запросов не делает; серверный credential протухнет сам.
    public static func logout()

    /// Выполнить одну операцию по флагу isRegistered():
    /// - Если isRegistered() == false — выполняется регистрация. Один вызов /register.
    /// - Если isRegistered() == true  — выполняется верификация. Один вызов /api/v1/auth/verify.
    /// За один вызов sign() делается ровно ОДНА операция, не обе.
    ///
    /// При errorCode с префиксом WEBAUTHN_, JWT_ или INTEGRITY_ — SDK очищает
    /// локальную регистрацию и возвращает statusError. Прикладной код может
    /// вызвать sign(dto) повторно — следующий вызов начнётся с регистрации.
    public static func sign(dto: [String: Any]) async -> TadSigningStatus
}

public enum TadSigningStatus {
    case statusOk
    case statusError(error: String, errorCode: String)
}

Поля statusError

Поле Содержимое
error Человекочитаемое сообщение для логов / диалога пользователю
errorCode Строка. Один из четырёх классов: см. таблицу ниже

JWT, deviceId, credentialId, verificationId, registrationIdнаружу не возвращаются.

Классы errorCode

Класс / префикс Источник Что делает SDK
WEBAUTHN_* (например, WEBAUTHN_USER_NOT_FOUND, WEBAUTHN_FACE_EXPIRED, WEBAUTHN_REPLAY_USED, WEBAUTHN_EMBEDDING_MISMATCH, WEBAUTHN_SIGN_COUNT, WEBAUTHN_REGISTRATION_ID_MISMATCH, WEBAUTHN_CHALLENGE_EXPIRED, WEBAUTHN_DTO_EXPIRED) Сервер Wipe регистрации, statusError. Auto-retry нет. Следующий sign() начнётся с новой регистрации.
JWT_* (например, JWT_SIGNATURE_INVALID, JWT_EXPIRED, JWT_DEVICE_MISMATCH, JWT_BANK_MISMATCH, JWT_FIELD_MISMATCH, JWT_ALG_MISMATCH) Локальная JWT-валидация в SDK по public.pem и HOWTO.md Wipe регистрации, statusError. Подпись скомпрометирована — продолжать нельзя.
INTEGRITY_* (например, INTEGRITY_TAMPERED_OS, INTEGRITY_DEBUGGER, INTEGRITY_PROXY, INTEGRITY_HOOKING, INTEGRITY_EMULATOR, INTEGRITY_PINNING) Локальные проверки целостности Wipe регистрации, statusError. Под капотом SDK волен реализовать любые проверки; наружу идут только эти нейтральные строки. INTEGRITY_TAMPERED_OS покрывает и jailbreak (iOS), и root (Android) — единое имя для обеих платформ.
FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK Сервер / пользователь / платформа statusError без wipe. Регистрация остаётся валидной, повторный sign() пойдёт по короткому пути «только верификация».

Никаких платформенных имён (SECURE_ENCLAVE_*, KEYCHAIN_*, KEYSTORE_*, STRONGBOX_*, JAILBREAK_*, ROOT_*, PROXY_* без префикса INTEGRITY_) в публичном errorCode. Каждое имя должно одинаково естественно читаться на iOS и на Android — это требование Flutter-обёртки, которая будет проксировать одни и те же значения с обеих платформ.


Жизненный цикл sign(dto)

flowchart TD
  Start([App: TadSigning.sign&#40;dto&#41;])
  Start --> Integrity{Integrity checks<br/>pass?}
  Integrity -- no --> ErrInt([statusError<br/>INTEGRITY_xxx])
  Integrity -- yes --> Lock[Захватить mutex по bankId]
  Lock --> Read{isRegistered?<br/>хранилище: есть пара<br/>deviceId + credentialId}

  Read -- no --> Reg[Subflow: регистрация<br/>один вызов /register]
  Read -- yes --> Ver[Subflow: верификация<br/>один вызов /api/v1/auth/verify]

  Reg -- ok --> Done([statusOk<br/>регистрация записана])
  Reg -- error --> WipeIfDirty[Если запись частично<br/>создалась — wipe]
  WipeIfDirty --> ErrReg([statusError<br/>конкретный код])

  Ver -- ok --> Done
  Ver -- WEBAUTHN_xxx --> Wipe[wipe регистрации<br/>из хранилища]
  Ver -- JWT_xxx --> Wipe
  Ver -- INTEGRITY_xxx --> Wipe
  Ver -- other error --> ErrOther([statusError<br/>конкретный код<br/>регистрация цела])

  Wipe --> ErrWipe([statusError<br/>конкретный код<br/>следующий sign&#40;&#41;<br/>пойдёт через регистрацию])

  Done --> Unlock[Освободить mutex]
  ErrInt --> Unlock
  ErrReg --> Unlock
  ErrOther --> Unlock
  ErrWipe --> Unlock

  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef sub fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313,font-weight:bold
  classDef rec fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Integrity,Read chk
  class Reg,Ver,Lock,Unlock,WipeIfDirty sub
  class Done ok
  class ErrInt,ErrReg,ErrOther,ErrWipe err
  class Wipe rec
Loading

Ключевые правила:

  • sign() делает одну операцию за вызов: либо регистрацию, либо верификацию. Не обе. Решение принимается по isRegistered().
  • Если isRegistered() == falsesign() выполняет регистрацию. После успеха в хранилище появляется пара deviceId + credentialId, и sign() возвращает statusOk. DTO в этом вызове не подписан — для подписи нужен ещё один вызов sign().
  • Если isRegistered() == truesign() выполняет верификацию с переданным dto. После успеха statusOk, регистрация сохраняется.
  • Если verify упал с WEBAUTHN_* / JWT_* / INTEGRITY_* — wipe + statusError. Следующий sign() снова начнётся с регистрации (потому что isRegistered() стал false).
  • Все остальные ошибки (USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK, FACEIDS_*) — statusError без wipe. Регистрация остаётся валидной.
  • Auto-retry внутри одного вызова sign() нет.

Типичная последовательность вызовов из прикладного кода для подписи документа:

if !TadSigning.isRegistered() {
    // первый sign() сделает регистрацию; dto можно передать пустой
    let s1 = await TadSigning.sign(dto: [:])
    guard case .statusOk = s1 else { return /* обработать ошибку */ }
}
// после регистрации — sign() с настоящим DTO выполнит подпись
let s2 = await TadSigning.sign(dto: realDto)

Если регистрация уже есть, первый if пропускается и подпись делается одним вызовом.


Подцикл регистрации

flowchart TD
  Start([Subflow: регистрация])
  Start --> Gen[deviceId = UUID&#40;&#41;<br/>случайный на КАЖДУЮ регистрацию,<br/>не привязан к устройству]
  Gen --> Cam[Камера + liveness, 2 кадра]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{Verify JWT<br/>по public.pem<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx])
  CapV -- ok --> Opts[POST /api/v1/auth/register/options]
  Opts --> OptsV{Verify JWT<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>создать ключ,<br/>подписать challenge]
  SE -- error --> ErrLocal([fail: локальный код<br/>напр. USER_CANCELLED])
  SE -- ok --> Ver[POST /api/v1/auth/register/verify]
  Ver --> VerV{Verify JWT<br/>+ credentialId === data.credentialId<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Save[АТОМАРНО сохранить:<br/>deviceId + credentialId]
  Save --> Done([ok])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313

  class Gen,Cam,Save sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal err
Loading

Подцикл верификации

flowchart TD
  Start([Subflow: верификация<br/>identity = deviceId + credentialId<br/>из локального хранилища])
  Start --> Cam[Камера + liveness, 2 кадра]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{Verify JWT<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx<br/>wipe в caller'е])
  CapV -- ok --> Opts[POST /api/v1/auth/verify/options]
  Opts --> WErr1{Сервер вернул<br/>WEBAUTHN_xxx?}
  WErr1 -- yes --> WebAuth([fail: WEBAUTHN_xxx<br/>wipe в caller'е])
  WErr1 -- no --> OptsV{Verify JWT<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>подписать challengeJwt<br/>через credentialId]
  SE -- error --> ErrLocal([fail: локальный код])
  SE -- ok --> Ver[POST /api/v1/auth/verify]
  Ver --> WErr2{Сервер вернул<br/>WEBAUTHN_xxx?}
  WErr2 -- yes --> WebAuth
  WErr2 -- no --> Other{Сервер вернул<br/>FACEIDS_xxx или другую ошибку?}
  Other -- yes --> ErrOther([fail: FACEIDS_xxx<br/>регистрация цела])
  Other -- no --> VerV{Verify JWT<br/>+ поля dto deepEqual data<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Done([ok<br/>jwt остаётся внутри SDK])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313
  classDef wb fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Cam sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV,WErr1,WErr2,Other chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal,ErrOther err
  class WebAuth wb
Loading

Sequence: что видит прикладной код

sequenceDiagram
    autonumber
    participant App as Прикладной код банка
    participant SDK as TadSigningSDK
    participant Store as Хранилище
    participant API as Сервер

    App->>SDK: TadSigning.sign(dto)
    SDK->>SDK: integrity checks
    SDK->>Store: read(bankId)

    alt пары нет (isRegistered == false) — РЕГИСТРАЦИЯ
        Store-->>SDK: nil
        Note over SDK: один вызов /register<br/>с новым deviceId
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/register/options<br/>POST /api/v1/auth/register/verify
        alt register ok
            API-->>SDK: credentialId
            SDK->>Store: save(deviceId, credentialId)
            SDK-->>App: statusOk
            Note over App: dto в этом вызове НЕ подписан<br/>чтобы подписать — вызвать sign(dto) снова
        else register error
            API-->>SDK: error
            SDK->>Store: wipe (если запись частично создалась)
            SDK-->>App: statusError(errorCode: ...)
        end
    else пара есть (isRegistered == true) — ВЕРИФИКАЦИЯ
        Store-->>SDK: deviceId, credentialId
        Note over SDK: один вызов /api/v1/auth/verify<br/>с переданным dto
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/verify/options<br/>POST /api/v1/auth/verify

        alt API: WEBAUTHN_xxx
            API-->>SDK: error WEBAUTHN_xxx
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: WEBAUTHN_xxx)
            Note over App: следующий sign() начнётся с регистрации
        else local: JWT_xxx
            Note over SDK: ошибка верификации JWT по public.pem
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: JWT_xxx)
        else local: INTEGRITY_xxx
            Note over SDK: jailbreak / Frida / proxy / ...
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: INTEGRITY_xxx)
        else API: FACEIDS_xxx
            API-->>SDK: error FACEIDS_xxx
            Note over SDK,Store: регистрация цела — без wipe
            SDK-->>App: statusError(errorCode: FACEIDS_xxx)
        else local: USER_CANCELLED / CAMERA_UNAVAILABLE / NETWORK
            Note over SDK,Store: регистрация цела — без wipe
            SDK-->>App: statusError(errorCode: ...)
        else verify ok
            API-->>SDK: signature
            SDK-->>App: statusOk
        end
    end
Loading

Правило восстановления (точное)

функция sign(dto):
    // 1. Целостность среды (jailbreak / Frida / proxy / pinning / ...)
    если !checkIntegrity():
        secureStorage.wipe(bankId)            // регистрация под угрозой
        вернуть statusError(error, errorCode: "INTEGRITY_xxx")

    под mutex(bankId):
        identity = secureStorage.read(bankId)

        // ВЕТКА A: регистрации нет → делаем ТОЛЬКО регистрацию
        если identity == nil:
            результат = doRegister(новый случайный deviceId)
            если результат.ok:
                secureStorage.save(bankId, результат.identity)
                вернуть statusOk
            иначе:
                // если запись частично создалась — wipe; UUID одноразовый
                если secureStorage.read(bankId) != nil:
                    secureStorage.wipe(bankId)
                вернуть statusError(error, errorCode: результат.errorCode)

        // ВЕТКА B: регистрация есть → делаем ТОЛЬКО верификацию
        результат = doVerify(identity, dto)
        если результат.ok:
            вернуть statusOk

        код = результат.errorCode

        // классы ошибок, после которых регистрация считается испорченной
        если код.hasPrefix("WEBAUTHN_")
            или код.hasPrefix("JWT_")
            или код.hasPrefix("INTEGRITY_"):
            secureStorage.wipe(bankId)
            вернуть statusError(error, errorCode: код)

        // FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK — регистрация цела
        вернуть statusError(error, errorCode: код)


функция isRegistered() -> Bool:
    вернуть secureStorage.read(bankId) != nil

функция logout():
    secureStorage.wipe(bankId)
    // deviceId был случайным UUID, выпущенным SDK при регистрации.
    // Он одноразовый и не привязан к устройству. После logout()
    // любая следующая регистрация выпустит НОВЫЙ случайный UUID.

Правила wipe:

  • errorCode.hasPrefix("WEBAUTHN_") — серверная сторона больше не признаёт нашу пару → wipe.
  • errorCode.hasPrefix("JWT_") — локальная JWT-валидация по public.pem (см. HOWTO.md) не прошла → wipe (сессия скомпрометирована).
  • errorCode.hasPrefix("INTEGRITY_") — среда исполнения скомпрометирована → wipe.
  • Всё остальное (FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK) — регистрация остаётся валидной.

Auto-retry внутри sign() нет. Прикладной код может проверить isRegistered() и/или просто вызвать sign() повторно — следующий вызов после wipe начнётся с новой регистрации с новым случайным deviceId.


Природа deviceId

deviceId — это случайный UUID, который SDK генерирует на каждую новую регистрацию. Это не идентификатор устройства, не identifierForVendor, не какой-либо стабильный код. Свойства:

  • Выпускается заново при каждом первом sign() после logout() или после wipe из-за WEBAUTHN_* / JWT_* / INTEGRITY_* ошибки.
  • Не выводится из аппаратных характеристик устройства, IMEI, серийных номеров и т. п. — только UUID() из криптостойкого генератора.
  • На одном физическом устройстве за время его жизни могут существовать десятки разных deviceId — это нормально.
  • Один deviceId живёт ровно от своей регистрации до своего wipe или logout().

Почему так: серверная сторона (MongoDB) держит уникальный составной индекс {deviceId, bankId} и явно проверяет существование credential перед созданием. Любая попытка повторно зарегистрироваться со старым deviceId отклоняется. Поэтому единственный корректный путь — всегда выпускать новый случайный UUID при каждой регистрации.

Прикладной код это знание не использует и deviceId наружу не получает. Но команда SDK обязана это понимать, чтобы не делать ошибок вида «давайте кешировать deviceId и переиспользовать».


Защищённое локальное хранилище

Контракт хранилища абстрактный — это требование под Flutter-привязку. Конкретная реализация для iOS прячется за интерфейс SecureStorage (read / save / wipe) и не влияет на публичный контракт SDK.

Параметр Требование
Контракт read(bankId) -> Identity?, save(bankId, Identity), wipe(bankId), wipeAll()
Гарантия Запись не доступна другим приложениям, не уезжает в облачный backup, не сохраняется при удалении приложения
Формат записи (JSON) { "deviceId": "<uuid>", "credentialId": "<base64url>", "registeredAt": "<iso8601>" }
Идентификация записи По bankId из TadSigningConfig
Параллелизм Mutex / actor по bankId. Параллельные sign() сериализуются.
Логи deviceId, credentialId маскируются (4+4 символа)

Реализация на iOS (внутренняя деталь, не часть публичного API): Keychain, kSecClassGenericPassword, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrSynchronizable = false. На Android в зеркальном SDK — EncryptedSharedPreferences (AES-256-GCM, AndroidKeyStore). Flutter-обёртка через MethodChannel переиспользует тот же Keychain на iOS и тот же EncryptedSharedPreferences на Android — отдельная реализация Dart-стороной не предусмотрена.

Никаких UserDefaults, никаких файлов в Documents, никаких дублирующих копий.


Серверный контракт (фиксированный)

Эндпоинт Запрос Ответ data
POST /api/v1/check/capture deviceId, bankId, originalImageBase64 hash
POST /api/v1/auth/register/options deviceId, bankId, embeddingChecksum, dto registrationId, challenge, ...WebAuthnOptions
POST /api/v1/auth/register/verify registrationId, attestation, originalImageBase64 credentialId
POST /api/v1/auth/verify/options deviceId, bankId, dto verificationId, challengeJwt
POST /api/v1/auth/verify payload {deviceId, bankId, verificationId, replay}, assertion, originalImageBase64, compareImageBase64 dto

Каждый успешный ответ содержит signature — JWT, подписанный приватным ключом сервера. Алгоритм считывается из TadSigningConfig.publicKeyPem (текущий — ES512). TTL — 15 минут.

Обязательные проверки signature

Эндпоинт Сверх deviceId, bankId, exp
POST /api/v1/check/capture decoded.hash === data.hash
POST /api/v1/auth/register/options decoded.registrationId === data.registrationId, decoded.challenge === data.challenge, decoded.dto deepEqual request.dto
POST /api/v1/auth/register/verify decoded.credentialId === data.credentialId
POST /api/v1/auth/verify/options decoded.verificationId === data.verificationId, decoded.challengeJwt === data.challengeJwt, decoded.dto deepEqual request.dto
POST /api/v1/auth/verify каждое поле dto, присутствующее в data, должно совпасть с одноимённым полем в decoded

Защита от alg-confusion

Алгоритм заголовка JWT обязан совпадать с алгоритмом ключа из publicKeyPem. Любое расхождение — errorCode: "JWT_ALG_MISMATCH". Никаких alg=none, никаких alg=HS256 с публичным ключом.


Acceptance criteria

Юнит-тесты

Сценарий Ожидаемое
isRegistered() при пустом хранилище false, сетевых запросов нет
isRegistered() после успешного регистрационного sign() true, сетевых запросов нет
logout() после регистрации isRegistered() становится false, сетевых запросов нет, deviceId из старой записи нигде не сохранился
sign() при пустом хранилище один вызов POST /api/v1/check/capture + POST /api/v1/auth/register/options + POST /api/v1/auth/register/verify (БЕЗ POST /api/v1/auth/verify/options и POST /api/v1/auth/verify), в хранилище ровно одна запись, statusOk
sign() при заполненном хранилище один вызов POST /api/v1/check/capture + POST /api/v1/auth/verify/options + POST /api/v1/auth/verify (БЕЗ POST /api/v1/auth/register/options и POST /api/v1/auth/register/verify), statusOk
sign() (регистрация) → sign() (верификация) первый делает только register, второй — только verify, оба statusOk. Эндпоинты POST /api/v1/auth/register/options и POST /api/v1/auth/register/verify дёргаются ровно во время первого вызова, POST /api/v1/auth/verify/options и POST /api/v1/auth/verify — ровно во время второго.
sign() после logout() один вызов register, statusOk. deviceId отличается от использованного до logout().
verifyWEBAUTHN_USER_NOT_FOUND wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered() становится false. Auto-register не запускается.
verifyWEBAUTHN_FACE_EXPIRED wipe, statusError(errorCode: "WEBAUTHN_FACE_EXPIRED"), isRegistered() становится false
verify → любой WEBAUTHN_* wipe, statusError(errorCode: <тот же>), isRegistered() становится false
Повторный sign() после wipe из-за WEBAUTHN_* один вызов register с новым случайным deviceId (отличается от выпотрошенного), statusOk. Чтобы подписать — нужен ещё один sign().
verify JWT не прошла проверку (signature, exp, deviceId mismatch) wipe, statusError(errorCode: "JWT_xxx"), isRegistered() становится false
verifyFACEIDS_SIMILARITY_SCORE_TOO_LOW без wipe, statusError(errorCode: "FACEIDS_SIMILARITY_SCORE_TOO_LOW"), isRegistered() остаётся true
Отказ от биометрии без wipe, statusError(errorCode: "USER_CANCELLED"), isRegistered() не меняется
Недоступность камеры без wipe, statusError(errorCode: "CAMERA_UNAVAILABLE")
Сетевой сбой без wipe, statusError(errorCode: "NETWORK")
decoded.dto != request.dto на POST /api/v1/auth/register/options wipe, statusError(errorCode: "JWT_FIELD_MISMATCH")
alg в JWT не совпадает с ключом конфигурации wipe, statusError(errorCode: "JWT_ALG_MISMATCH")
Срабатывание integrity-проверки wipe, statusError(errorCode: "INTEGRITY_xxx"), ни один сетевой запрос к серверу не идёт
Параллельные sign(dto) сериализуются mutex'ом. Если оба вызваны при пустом хранилище — первый делает register и возвращает statusOk, второй видит уже валидную регистрацию и делает verify.

Интеграция против staging

  • Сценарий «регистрация и подпись»: logout()sign(dto: [:])sign(dto: realDto). Первый sign() делает только register, второй — только verify. Оба возвращают statusOk. Между ними в хранилище появляется пара deviceId + credentialId.
  • Сценарий «удаление credential на сервере»: после успешной регистрации оператор удаляет credential на сервере → sign(dto: realDto) возвращает statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND") и делает wipe → следующий sign() снова делает регистрацию (теперь с другим deviceId) и возвращает statusOk без подписи DTO → ещё один sign(dto: realDto) подписывает.
  • Атакующий middleware подменяет credentialId в ответе POST /api/v1/auth/register/verifystatusError(errorCode: "JWT_FIELD_MISMATCH"), в хранилище ничего не записано (атомарность).
  • Атакующий middleware подменяет dto в ответе POST /api/v1/auth/verify/optionsstatusError(errorCode: "JWT_FIELD_MISMATCH"). Префикс JWT_ — wipe. Следующий sign() пойдёт через регистрацию.
  • На устройстве с jailbreak (iOS) или root (Android) sign() возвращает statusError(errorCode: "INTEGRITY_TAMPERED_OS") без сетевых запросов.

Состав работ

Breaking change. Это релиз без обратной совместимости. Старый API (TadSigningViewController(mode:), TadSigningMode, completion-based TadSigningResult) удаляется целиком. Никаких @available(*, deprecated), никаких переходных версий, никаких обёрток. Пользователи SDK мигрируют один раз — на новый TadSigning.sign(dto:).

Модуль Что делается
Публичный API namespace TadSigning: configure(), sign(), isRegistered(), logout(), TadSigningStatus. Старый TadSigningViewController(mode:), TadSigningMode и связанные типы удаляются из бандла.
Оркестрация sign() ветки register / verify по isRegistered(), машина состояний, mutex, генератор случайного deviceId на каждую регистрацию
SecureStorage абстракция Интерфейс read / save / wipe / wipeAll, изоляция по bankId. iOS-реализация на Keychain — деталь под капотом.
JWT-валидация Валидатор по public.pem для всех 5 эндпоинтов согласно HOWTO.md: подпись, exp, deviceId/bankId, специфичные поля, dto deepEqual, alg-confusion guard. Все ошибки под префиксом JWT_*.
Маппинг ошибок классификация по префиксам WEBAUTHN_* / JWT_* / INTEGRITY_* / прочее → wipe или нет
Integrity-проверки jailbreak / отладчик / Frida / эмулятор / proxy / cert pinning / целостность бандла. Результат — errorCode под нейтральным префиксом INTEGRITY_*. Реализация iOS-специфичная, наружу не светится.
Тесты юнит + интеграционные по таблицам выше
Sample app + документация одна кнопка Sign, README, migration guide

Что не делаем

  • Не меняем сервер. Контракт API фиксирован.
  • Не меняем алгоритм подписи (RS256 → ES512 — отдельный backlog).
  • Не меняем UI камеры/liveness.
  • Не светим JWT, deviceId, credentialId, verificationId в публичной сигнатуре. Никогда. Ни через completion, ни через delegate.

SDK maqsadli arxitekturasi

TODO

TadSigningSDK faqat bitta metodni taqdim etadi — sign(dto). Bank ilovasining dasturchisi bitta qator yozadi:

let result = await TadSigning.sign(dto: ["amount": 1000, "to": "IBAN..."])

Va javob sifatida statusOk yoki statusError(error:, errorCode:) oladi. Qolgan barchasi — SDK ichida.


Roadmap: Flutter ulashi

Keyinchalik SDK MethodChannel / FFI orqali Flutter o'ramini oladi — bu nativ iOS/Android SDK modullariga chaqiruvlarni yo'naltiradigan ingichka abstraksiya qatlami. Dart'da SDK alohida amalga oshirilmaydi. Dart kod dekompilyatsiya qilinadi va VM ichida ishlaydi, shuning uchun barcha kriptografiya, biometriya, saqlash va tarmoq chaqiruvlari nativ tomonda qoladi.

iOS SDK'ning ommaviy xulq-atvoriga talablar:

  • errorCode — bu enum kabi satr, uning nomlari iOS va Android'da bir xilda tabiiy yangraydi. WEBAUTHN_USER_NOT_FOUND, INTEGRITY_TAMPERED_OS, JWT_SIGNATURE_INVALID — neytral. KEYCHAIN_FAILURE, SECURE_ENCLAVE_UNAVAILABLE, KEYSTORE_LOCKED, STRONGBOX_UNAVAILABLE, INTEGRITY_JAILBREAK (faqat iOS atamasi), INTEGRITY_ROOT (faqat Android atamasi) — platformaga bog'liq, tashqariga chiqmasligi kerak. Ichida iOS SDK istalgan tekshiruvlarni va istalgan platforma API'larini ishlatishi mumkin; ommaviy errorCode'da bularning barchasi umumiy nomlar to'plamiga normallashtiriladi.
  • Barcha WEBAUTHN xatolari backend'dan keladi. SDK ularni o'ylab topmaydi — faqat satr sifatida tashqariga uzatadi va WEBAUTHN_ prefiksi bo'yicha registratsiyani wipe qilish haqida qaror qabul qiladi.
  • Saqlash — har bir platformaning umumiy interfeys ortidagi nativ mexanizmi. iOS'da — Keychain. Android'da — EncryptedSharedPreferences. Flutter'da hech qanday yangilik qo'shilmaydi: MethodChannel chaqiruvni platformaga uzatadi va o'sha Keychain yoki o'sha EncryptedSharedPreferences ishlaydi. Hech qanday flutter_secure_storage yoki boshqa "kross-platforma" yechimi yo'q. Yagona talab — iOS SDK ichida Keychain'ni ingichka SecureStorage interfeysi ortiga olib o'tish, shunda kuzgu Android SDK'da xuddi shunday kontrakt bo'ladi.
  • Har bir ommaviy API metodida kuzgu Kotlin ekvivalenti bo'lishi shart, aks holda MethodChannel uni uzatolmaydi. Metod nomlari va enum kodlari yagona.

Ommaviy errorCode'ga platformaga xos nom qo'shadigan yoki iOS/Android API kuzgu mosligini buzadigan har qanday PR rad etilishi kerak.


Ommaviy API

Konfiguratsiya (ilova ishga tushirilganda bir marta)

TadSigning.configure(
    apiBaseUrl:    URL(string: "https://signing.tadi.uz")!,
    publicKeyPem:  "-----BEGIN PUBLIC KEY-----\n...",
    rpId:          "signing.tadi.uz",
    serviceName:   "tad-signing-demo",
    bankId:        "demo-user-001"    // shu yerda qotirilgan, sign()'ga uzatilmaydi
)

DTO'ni imzolash

public enum TadSigning {

    /// SDK konfiguratsiyasi. Ilova ishga tushirilganda bir marta chaqiriladi.
    public static func configure(
        apiBaseUrl:   URL,
        publicKeyPem: String,
        rpId:         String,
        serviceName:  String,
        bankId:       String
    )

    /// Registratsiya tekshiruvi. Lokal: agar himoyalangan saqlashda joriy
    /// bankId uchun (deviceId, credentialId) jufti mavjud bo'lsa true qaytaradi.
    /// Tarmoq so'rovlari yo'q.
    public static func isRegistered() -> Bool

    /// Lokal saqlashdan registratsiyani o'chiradi.
    /// deviceId — bu SDK tomonidan registratsiya paytida chiqarilgan tasodifiy UUID;
    /// u qurilmaga BOG'LANMAGAN va qayta ishlatilmaydi. logout()'dan keyingi
    /// birinchi sign() yangi tasodifiy deviceId bilan to'liq registratsiyani bajaradi.
    /// Tarmoq so'rovlari yo'q; serverdagi credential o'zi muddati tugaydi.
    public static func logout()

    /// isRegistered() bayrog'i bo'yicha BITTA operatsiyani bajaradi:
    /// - agar isRegistered() == false — registratsiya bajariladi. Bitta /api/v1/auth/register/* chaqiruvi.
    /// - agar isRegistered() == true  — verifikatsiya bajariladi. Bitta /api/v1/auth/verify chaqiruvi.
    /// Har bir sign() chaqiruvida aniq BITTA operatsiya, ikkalasi emas.
    ///
    /// errorCode WEBAUTHN_, JWT_ yoki INTEGRITY_ prefiksi bilan bo'lsa — SDK lokal
    /// registratsiyani tozalaydi va statusError qaytaradi. Chaqiruvchi sign(dto)'ni
    /// qaytadan chaqirishi mumkin — keyingi chaqiruv registratsiyadan boshlanadi.
    public static func sign(dto: [String: Any]) async -> TadSigningStatus
}

public enum TadSigningStatus {
    case statusOk
    case statusError(error: String, errorCode: String)
}

statusError maydonlari

Maydon Mazmuni
error Loglar / foydalanuvchi dialogi uchun odam o'qiydigan xabar
errorCode Satr. To'rt sinfdan biri — quyidagi jadvalga qarang

JWT, deviceId, credentialId, verificationId, registrationIdchaqiruvchiga qaytarilmaydi.

errorCode sinflari

Sinf / prefiks Manba SDK nima qiladi
WEBAUTHN_* (masalan, WEBAUTHN_USER_NOT_FOUND, WEBAUTHN_FACE_EXPIRED, WEBAUTHN_REPLAY_USED, WEBAUTHN_EMBEDDING_MISMATCH, WEBAUTHN_SIGN_COUNT, WEBAUTHN_REGISTRATION_ID_MISMATCH, WEBAUTHN_CHALLENGE_EXPIRED, WEBAUTHN_DTO_EXPIRED) Server Registratsiyani wipe qiladi, statusError qaytaradi. Auto-retry yo'q. Keyingi sign() registratsiyadan boshlanadi.
JWT_* (masalan, JWT_SIGNATURE_INVALID, JWT_EXPIRED, JWT_DEVICE_MISMATCH, JWT_BANK_MISMATCH, JWT_FIELD_MISMATCH, JWT_ALG_MISMATCH) SDK ichida public.pem va HOWTO.md bo'yicha lokal JWT validatsiya Registratsiyani wipe qiladi, statusError qaytaradi. Imzo buzilgan — davom ettirib bo'lmaydi.
INTEGRITY_* (masalan, INTEGRITY_TAMPERED_OS, INTEGRITY_DEBUGGER, INTEGRITY_PROXY, INTEGRITY_HOOKING, INTEGRITY_EMULATOR, INTEGRITY_PINNING) Lokal yaxlitlik tekshiruvlari Registratsiyani wipe qiladi, statusError qaytaradi. Ichida SDK istalgan tekshiruvlarni amalga oshirishi mumkin; tashqariga faqat shu neytral satrlar chiqadi. INTEGRITY_TAMPERED_OS jailbreak (iOS) va root (Android)'ni qoplaydi — ikkala platforma uchun yagona nom.
FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK Server / foydalanuvchi / platforma wipe'siz statusError. Registratsiya yaroqli qoladi, keyingi sign() qisqa "faqat verifikatsiya" yo'lidan o'tadi.

Ommaviy errorCode'da hech qanday platformaga xos nomlar (SECURE_ENCLAVE_*, KEYCHAIN_*, KEYSTORE_*, STRONGBOX_*, JAILBREAK_*, ROOT_*, INTEGRITY_ prefiksisiz PROXY_*) bo'lmasligi kerak. Har bir nom iOS va Android'da bir xil tabiiy o'qilishi shart — bu Flutter o'ramining talabi, u ikkala platformadan bir xil qiymatlarni proksilaydi.


sign(dto) hayot sikli

flowchart TD
  Start([App: TadSigning.sign&#40;dto&#41;])
  Start --> Integrity{Integrity checks<br/>pass?}
  Integrity -- no --> ErrInt([statusError<br/>INTEGRITY_xxx])
  Integrity -- yes --> Lock[bankId bo'yicha<br/>mutex olish]
  Lock --> Read{isRegistered?<br/>saqlashda<br/>deviceId + credentialId bormi}

  Read -- no --> Reg[Subflow: registratsiya<br/>bitta /api/v1/auth/register/* chaqiruvi]
  Read -- yes --> Ver[Subflow: verifikatsiya<br/>bitta /api/v1/auth/verify chaqiruvi]

  Reg -- ok --> Done([statusOk<br/>registratsiya yozildi])
  Reg -- error --> WipeIfDirty[Agar yozuv qisman<br/>yaratilgan bo'lsa — wipe]
  WipeIfDirty --> ErrReg([statusError<br/>aniq kod])

  Ver -- ok --> Done
  Ver -- WEBAUTHN_xxx --> Wipe[saqlashdan<br/>registratsiyani wipe qilish]
  Ver -- JWT_xxx --> Wipe
  Ver -- INTEGRITY_xxx --> Wipe
  Ver -- other error --> ErrOther([statusError<br/>aniq kod<br/>registratsiya butun])

  Wipe --> ErrWipe([statusError<br/>aniq kod<br/>keyingi sign&#40;&#41;<br/>registratsiyadan boshlanadi])

  Done --> Unlock[mutex'ni qaytarish]
  ErrInt --> Unlock
  ErrReg --> Unlock
  ErrOther --> Unlock
  ErrWipe --> Unlock

  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef sub fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313,font-weight:bold
  classDef rec fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Integrity,Read chk
  class Reg,Ver,Lock,Unlock,WipeIfDirty sub
  class Done ok
  class ErrInt,ErrReg,ErrOther,ErrWipe err
  class Wipe rec
Loading

Asosiy qoidalar:

  • sign() har bir chaqiruvda bitta operatsiya bajaradi: yo registratsiya, yo verifikatsiya. Ikkalasi emas. Qaror isRegistered() bo'yicha qabul qilinadi.
  • Agar isRegistered() == falsesign() registratsiyani bajaradi. Muvaffaqiyatdan keyin saqlashda deviceId + credentialId jufti paydo bo'ladi va sign() statusOk qaytaradi. Bu chaqiruvda DTO imzolanmagan — imzolash uchun yana bitta sign() chaqiruvi kerak.
  • Agar isRegistered() == truesign() uzatilgan dto bilan verifikatsiyani bajaradi. Muvaffaqiyatdan keyin statusOk, registratsiya saqlanadi.
  • Agar verify WEBAUTHN_* / JWT_* / INTEGRITY_* bilan tushsa — wipe + statusError. Keyingi sign() registratsiyadan boshlanadi (chunki isRegistered() endi false).
  • Boshqa barcha xatolar (USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK, FACEIDS_*) — wipe'siz statusError. Registratsiya yaroqli qoladi.
  • Bitta sign() chaqiruvi ichida auto-retry yo'q.

Hujjatni imzolash uchun chaqiruvchi koddagi tipik ketma-ketlik:

if !TadSigning.isRegistered() {
    // birinchi sign() registratsiyani bajaradi; bo'sh dto bo'lishi mumkin
    let s1 = await TadSigning.sign(dto: [:])
    guard case .statusOk = s1 else { return /* xatoni qayta ishlash */ }
}
// registratsiyadan keyin — haqiqiy DTO bilan sign() imzolashni bajaradi
let s2 = await TadSigning.sign(dto: realDto)

Agar registratsiya allaqachon mavjud bo'lsa, birinchi if o'tkazib yuboriladi va imzolash bitta chaqiruvda amalga oshiriladi.


Registratsiya pastsikli

flowchart TD
  Start([Subflow: registratsiya])
  Start --> Gen[deviceId = UUID&#40;&#41;<br/>HAR registratsiya uchun tasodifiy,<br/>qurilmaga bog'lanmagan]
  Gen --> Cam[Kamera + liveness, 2 kadr]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{public.pem bo'yicha<br/>JWT verifikatsiyasi<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx])
  CapV -- ok --> Opts[POST /api/v1/auth/register/options]
  Opts --> OptsV{JWT verifikatsiyasi<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>kalit yaratish,<br/>challenge'ni imzolash]
  SE -- error --> ErrLocal([fail: lokal kod<br/>masalan, USER_CANCELLED])
  SE -- ok --> Ver[POST /api/v1/auth/register/verify]
  Ver --> VerV{JWT verifikatsiyasi<br/>+ credentialId === data.credentialId<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Save[ATOMAR saqlash:<br/>deviceId + credentialId]
  Save --> Done([ok])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313

  class Gen,Cam,Save sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal err
Loading

Verifikatsiya pastsikli

flowchart TD
  Start([Subflow: verifikatsiya<br/>identity = deviceId + credentialId<br/>lokal saqlashdan])
  Start --> Cam[Kamera + liveness, 2 kadr]
  Cam -- error --> ErrCam([fail: USER_CANCELLED /<br/>CAMERA_UNAVAILABLE])
  Cam -- ok --> Cap[POST /api/v1/check/capture]
  Cap --> CapV{JWT verifikatsiyasi<br/>HOWTO.md}
  CapV -- error --> ErrJwt([fail: JWT_xxx<br/>chaqiruvchi wipe qiladi])
  CapV -- ok --> Opts[POST /api/v1/auth/verify/options]
  Opts --> WErr1{Server<br/>WEBAUTHN_xxx qaytardimi?}
  WErr1 -- yes --> WebAuth([fail: WEBAUTHN_xxx<br/>chaqiruvchi wipe qiladi])
  WErr1 -- no --> OptsV{JWT verifikatsiyasi<br/>+ decoded.dto deepEqual request.dto<br/>HOWTO.md}
  OptsV -- error --> ErrJwt
  OptsV -- ok --> SE[Passkey:<br/>credentialId orqali<br/>challengeJwt'ni imzolash]
  SE -- error --> ErrLocal([fail: lokal kod])
  SE -- ok --> Ver[POST /api/v1/auth/verify]
  Ver --> WErr2{Server<br/>WEBAUTHN_xxx qaytardimi?}
  WErr2 -- yes --> WebAuth
  WErr2 -- no --> Other{Server FACEIDS_xxx<br/>yoki boshqa xato qaytardimi?}
  Other -- yes --> ErrOther([fail: FACEIDS_xxx<br/>registratsiya butun])
  Other -- no --> VerV{JWT verifikatsiyasi<br/>+ dto maydonlari deepEqual data<br/>HOWTO.md}
  VerV -- error --> ErrJwt
  VerV -- ok --> Done([ok<br/>jwt SDK ichida qoladi])

  classDef sdk fill:#E6EEF7,stroke:#1F3A5F,color:#1F3A5F
  classDef api fill:#F4F4F2,stroke:#666666,color:#1A1A1A
  classDef chk fill:#FFF4E5,stroke:#A86200,color:#5C3700
  classDef se  fill:#E1F5EE,stroke:#0F6E56,color:#085041
  classDef ok  fill:#EAF3DE,stroke:#3B6D11,color:#27500A,font-weight:bold
  classDef err fill:#FCEBEB,stroke:#A32D2D,color:#501313
  classDef wb fill:#FFE8CC,stroke:#A86200,color:#5C3700

  class Cam sdk
  class Cap,Opts,Ver api
  class CapV,OptsV,VerV,WErr1,WErr2,Other chk
  class SE se
  class Done ok
  class ErrCam,ErrJwt,ErrLocal,ErrOther err
  class WebAuth wb
Loading

Sequence: chaqiruvchi nimani ko'radi

sequenceDiagram
    autonumber
    participant App as Bank ilova kodi
    participant SDK as TadSigningSDK
    participant Store as Saqlash
    participant API as Server

    App->>SDK: TadSigning.sign(dto)
    SDK->>SDK: integrity checks
    SDK->>Store: read(bankId)

    alt juft yo'q (isRegistered == false) — REGISTRATSIYA
        Store-->>SDK: nil
        Note over SDK: yangi deviceId bilan<br/>bitta /api/v1/auth/register/* chaqiruvi
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/register/options<br/>POST /api/v1/auth/register/verify
        alt register ok
            API-->>SDK: credentialId
            SDK->>Store: save(deviceId, credentialId)
            SDK-->>App: statusOk
            Note over App: bu chaqiruvda dto IMZOLANMAGAN —<br/>imzolash uchun sign(dto)'ni qayta chaqiring
        else register error
            API-->>SDK: error
            SDK->>Store: wipe (agar yozuv qisman yaratilgan bo'lsa)
            SDK-->>App: statusError(errorCode: ...)
        end
    else juft mavjud (isRegistered == true) — VERIFIKATSIYA
        Store-->>SDK: deviceId, credentialId
        Note over SDK: uzatilgan dto bilan<br/>bitta /api/v1/auth/verify chaqiruvi
        SDK->>API: POST /api/v1/check/capture<br/>POST /api/v1/auth/verify/options<br/>POST /api/v1/auth/verify

        alt API: WEBAUTHN_xxx
            API-->>SDK: error WEBAUTHN_xxx
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: WEBAUTHN_xxx)
            Note over App: keyingi sign() registratsiyadan boshlanadi
        else local: JWT_xxx
            Note over SDK: public.pem bo'yicha JWT verifikatsiyasi xatosi
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: JWT_xxx)
        else local: INTEGRITY_xxx
            Note over SDK: jailbreak / Frida / proxy / ...
            SDK->>Store: wipe(bankId)
            SDK-->>App: statusError(errorCode: INTEGRITY_xxx)
        else API: FACEIDS_xxx
            API-->>SDK: error FACEIDS_xxx
            Note over SDK,Store: registratsiya butun — wipe yo'q
            SDK-->>App: statusError(errorCode: FACEIDS_xxx)
        else local: USER_CANCELLED / CAMERA_UNAVAILABLE / NETWORK
            Note over SDK,Store: registratsiya butun — wipe yo'q
            SDK-->>App: statusError(errorCode: ...)
        else verify ok
            API-->>SDK: signature
            SDK-->>App: statusOk
        end
    end
Loading

Tiklanish qoidasi (aniq)

funksiya sign(dto):
    // 1. Muhit yaxlitligi (jailbreak / Frida / proxy / pinning / ...)
    agar !checkIntegrity():
        secureStorage.wipe(bankId)            // registratsiya xavf ostida
        qaytarish statusError(error, errorCode: "INTEGRITY_xxx")

    mutex(bankId) ostida:
        identity = secureStorage.read(bankId)

        // BRANCH A: registratsiya yo'q → FAQAT registratsiya bajariladi
        agar identity == nil:
            natija = doRegister(yangi tasodifiy deviceId)
            agar natija.ok:
                secureStorage.save(bankId, natija.identity)
                qaytarish statusOk
            aks holda:
                // agar yozuv qisman yaratilgan bo'lsa — wipe; UUID bir martalik
                agar secureStorage.read(bankId) != nil:
                    secureStorage.wipe(bankId)
                qaytarish statusError(error, errorCode: natija.errorCode)

        // BRANCH B: registratsiya mavjud → FAQAT verifikatsiya bajariladi
        natija = doVerify(identity, dto)
        agar natija.ok:
            qaytarish statusOk

        kod = natija.errorCode

        // registratsiya buzilgan deb hisoblanadigan xato sinflari
        agar kod.hasPrefix("WEBAUTHN_")
            yoki kod.hasPrefix("JWT_")
            yoki kod.hasPrefix("INTEGRITY_"):
            secureStorage.wipe(bankId)
            qaytarish statusError(error, errorCode: kod)

        // FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK — registratsiya butun
        qaytarish statusError(error, errorCode: kod)


funksiya isRegistered() -> Bool:
    qaytarish secureStorage.read(bankId) != nil

funksiya logout():
    secureStorage.wipe(bankId)
    // deviceId — SDK tomonidan registratsiya paytida chiqarilgan tasodifiy UUID edi.
    // U bir martalik va qurilmaga bog'lanmagan. logout()'dan keyin
    // har qanday keyingi registratsiya YANGI tasodifiy UUID chiqaradi.

Wipe qoidalari:

  • errorCode.hasPrefix("WEBAUTHN_") — server tomoni bizning juftni endi tan olmaydi → wipe.
  • errorCode.hasPrefix("JWT_")public.pem bo'yicha lokal JWT validatsiyasi (qarang HOWTO.md) o'tmadi → wipe (sessiya buzilgan).
  • errorCode.hasPrefix("INTEGRITY_") — ishga tushirish muhiti buzilgan → wipe.
  • Boshqa barchasi (FACEIDS_*, USER_CANCELLED, CAMERA_UNAVAILABLE, NETWORK) — registratsiya yaroqli qoladi.

sign() ichida auto-retry yo'q. Chaqiruvchi isRegistered()'ni tekshirishi va/yoki sign()'ni shunchaki qayta chaqirishi mumkin — wipe'dan keyingi chaqiruv yangi tasodifiy deviceId bilan yangi registratsiyadan boshlanadi.


deviceId tabiati

deviceId — bu SDK tomonidan har bir yangi registratsiya uchun generatsiya qilinadigan tasodifiy UUID. Bu qurilma identifikatori emas, identifierForVendor emas, hech qanday turg'un kod emas. Xususiyatlari:

  • logout()'dan keyin yoki WEBAUTHN_* / JWT_* / INTEGRITY_* xato tufayli wipe'dan keyingi har bir birinchi sign()'da qaytadan chiqariladi.
  • Qurilmaning apparat xususiyatlaridan, IMEI, seriya raqamlaridan va h.k.dan kelib chiqmaydi — faqat kriptografik mustahkam generator orqali UUID().
  • Bitta jismoniy qurilmada uning butun hayoti davomida o'nlab turli deviceId'lar mavjud bo'lishi mumkin — bu normal.
  • Bitta deviceId o'z registratsiyasidan o'z wipe yoki logout()'gacha yashaydi.

Nega shunday: server tomoni (MongoDB) {deviceId, bankId} unikal kompozit indeksini ushlab turadi va credential yaratishdan oldin uning mavjudligini aniq tekshiradi. Eski deviceId bilan qayta ro'yxatdan o'tishga har qanday urinish rad etiladi. Shuning uchun yagona to'g'ri yo'l — har bir registratsiyada doim yangi tasodifiy UUID chiqarish.

Chaqiruvchi kod bu bilimni ishlatmaydi va deviceId'ni tashqariga olmaydi. Ammo SDK jamoasi buni tushunishi shart, "keling, deviceId'ni keshlaymiz va qayta ishlatamiz" turidagi xatolarga yo'l qo'ymaslik uchun.


Himoyalangan lokal saqlash

Saqlash kontrakti abstrakt — bu Flutter ulashi uchun talab. iOS uchun aniq amalga oshirish SecureStorage interfeysi (read / save / wipe) ortida yashiringan va SDK'ning ommaviy kontraktiga ta'sir qilmaydi.

Parametr Talab
Kontrakt read(bankId) -> Identity?, save(bankId, Identity), wipe(bankId), wipeAll()
Kafolat Yozuv boshqa ilovalarga ko'rinmaydi, bulutli backup'ga chiqmaydi, ilova o'chirilganda saqlanmaydi
Yozuv formati (JSON) { "deviceId": "<uuid>", "credentialId": "<base64url>", "registeredAt": "<iso8601>" }
Yozuv identifikatsiyasi TadSigningConfig'dan bankId bo'yicha
Parallellik bankId bo'yicha mutex / actor. Parallel sign()'lar serializatsiya qilinadi.
Loglar deviceId, credentialId maskalanadi (4+4 belgi)

iOS'da amalga oshirish (ichki tafsilot, ommaviy API qismi emas): Keychain, kSecClassGenericPassword, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrSynchronizable = false. Kuzgu Android SDK'da — EncryptedSharedPreferences (AES-256-GCM, AndroidKeyStore). MethodChannel orqali Flutter o'rami iOS'da o'sha Keychain'ni va Android'da o'sha EncryptedSharedPreferences'ni qayta ishlatadi — Dart tomonida alohida amalga oshirish ko'zda tutilmagan.

Hech qanday UserDefaults, Documents'dagi fayllar, takroriy nusxalar yo'q.


Server kontrakti (qotirilgan)

Endpoint So'rov Javob data
POST /api/v1/check/capture deviceId, bankId, originalImageBase64 hash
POST /api/v1/auth/register/options deviceId, bankId, embeddingChecksum, dto registrationId, challenge, ...WebAuthnOptions
POST /api/v1/auth/register/verify registrationId, attestation, originalImageBase64 credentialId
POST /api/v1/auth/verify/options deviceId, bankId, dto verificationId, challengeJwt
POST /api/v1/auth/verify payload {deviceId, bankId, verificationId, replay}, assertion, originalImageBase64, compareImageBase64 dto

Har bir muvaffaqiyatli javob signature maydonini o'z ichiga oladi — server'ning maxfiy kaliti bilan imzolangan JWT. Algoritm TadSigningConfig.publicKeyPem'dan o'qiladi (joriy — ES512). TTL — 15 daqiqa.

signature uchun majburiy tekshiruvlar

Endpoint deviceId, bankId, exp'dan tashqari
POST /api/v1/check/capture decoded.hash === data.hash
POST /api/v1/auth/register/options decoded.registrationId === data.registrationId, decoded.challenge === data.challenge, decoded.dto deepEqual request.dto
POST /api/v1/auth/register/verify decoded.credentialId === data.credentialId
POST /api/v1/auth/verify/options decoded.verificationId === data.verificationId, decoded.challengeJwt === data.challengeJwt, decoded.dto deepEqual request.dto
POST /api/v1/auth/verify data'da mavjud bo'lgan har bir dto maydoni decoded'dagi xuddi shu nomli maydonga mos kelishi shart

alg-confusion himoyasi

JWT sarlavhasidagi algoritm publicKeyPem'dagi kalit algoritmiga mos kelishi shart. Har qanday nomuvofiqlik — errorCode: "JWT_ALG_MISMATCH". Hech qanday alg=none, ochiq kalit bilan hech qanday alg=HS256.


Acceptance criteria

Unit testlar

Stsenariy Kutilgan
isRegistered() bo'sh saqlashda false, tarmoq so'rovlari yo'q
Muvaffaqiyatli registratsiya sign()'idan keyin isRegistered() true, tarmoq so'rovlari yo'q
Registratsiyadan keyin logout() isRegistered() false bo'ladi, tarmoq so'rovlari yo'q, eski deviceId hech qayerda saqlanmagan
sign() bo'sh saqlashda bitta POST /api/v1/check/capture + POST /api/v1/auth/register/options + POST /api/v1/auth/register/verify chaqiruvi (POST /api/v1/auth/verify/options va POST /api/v1/auth/verify SIZ), saqlashda aniq bitta yozuv, statusOk
sign() to'ldirilgan saqlashda bitta POST /api/v1/check/capture + POST /api/v1/auth/verify/options + POST /api/v1/auth/verify chaqiruvi (POST /api/v1/auth/register/options va POST /api/v1/auth/register/verify SIZ), statusOk
sign() (registratsiya) → sign() (verifikatsiya) birinchisi faqat register, ikkinchisi faqat verify, ikkalasi statusOk. POST /api/v1/auth/register/options va POST /api/v1/auth/register/verify aniq birinchi chaqiruv vaqtida, POST /api/v1/auth/verify/options va POST /api/v1/auth/verify aniq ikkinchi chaqiruv vaqtida ishlatiladi.
logout()'dan keyin sign() bitta register chaqiruvi, statusOk. deviceId logout()'gacha ishlatilganidan farq qiladi.
verifyWEBAUTHN_USER_NOT_FOUND wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered() false bo'ladi. Auto-register ishga tushmaydi.
verifyWEBAUTHN_FACE_EXPIRED wipe, statusError(errorCode: "WEBAUTHN_FACE_EXPIRED"), isRegistered() false bo'ladi
verify → istalgan WEBAUTHN_* wipe, statusError(errorCode: <o'sha>), isRegistered() false bo'ladi
WEBAUTHN_* tufayli wipe'dan keyin takroriy sign() yangi tasodifiy deviceId (wipe qilingandan farqli) bilan bitta register chaqiruvi, statusOk. Imzolash uchun yana bitta sign() kerak.
verify JWT tekshiruvdan o'tmadi (signature, exp, deviceId mismatch) wipe, statusError(errorCode: "JWT_xxx"), isRegistered() false bo'ladi
verifyFACEIDS_SIMILARITY_SCORE_TOO_LOW wipe'siz, statusError(errorCode: "FACEIDS_SIMILARITY_SCORE_TOO_LOW"), isRegistered() true bo'lib qoladi
Biometriyadan voz kechish wipe'siz, statusError(errorCode: "USER_CANCELLED"), isRegistered() o'zgarmaydi
Kamera mavjud emas wipe'siz, statusError(errorCode: "CAMERA_UNAVAILABLE")
Tarmoq nosozligi wipe'siz, statusError(errorCode: "NETWORK")
POST /api/v1/auth/register/options'da decoded.dto != request.dto wipe, statusError(errorCode: "JWT_FIELD_MISMATCH")
JWT'dagi alg konfiguratsiya kalitiga mos kelmadi wipe, statusError(errorCode: "JWT_ALG_MISMATCH")
Integrity tekshiruvi ishga tushdi wipe, statusError(errorCode: "INTEGRITY_xxx"), hech qanday tarmoq so'rovi serverga bormaydi
Parallel sign(dto) mutex bilan serializatsiya qilinadi. Agar ikkalasi bo'sh saqlashda chaqirilgan bo'lsa — birinchisi register'ni bajaradi va statusOk qaytaradi, ikkinchisi yaroqli registratsiyani ko'radi va verify'ni bajaradi.

Staging'ga qarshi integratsiya

  • "Registratsiya va imzolash" stsenariysi: logout()sign(dto: [:])sign(dto: realDto). Birinchi sign() faqat register, ikkinchisi faqat verify bajaradi. Ikkalasi statusOk qaytaradi. Ular orasida saqlashda deviceId + credentialId jufti paydo bo'ladi.
  • "Server tomonidan credential'ni o'chirish" stsenariysi: muvaffaqiyatli registratsiyadan keyin operator serverdagi credential'ni o'chiradi → sign(dto: realDto) statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND") qaytaradi va wipe qiladi → keyingi sign() registratsiyani qaytadan bajaradi (endi boshqa deviceId bilan) va DTO imzolanmasdan statusOk qaytaradi → yana bitta sign(dto: realDto) imzolaydi.
  • Hujum qiluvchi middleware POST /api/v1/auth/register/verify javobida credentialId'ni almashtiradi → statusError(errorCode: "JWT_FIELD_MISMATCH"), saqlashga hech narsa yozilmagan (atomarlik).
  • Hujum qiluvchi middleware POST /api/v1/auth/verify/options javobida dto'ni almashtiradi → statusError(errorCode: "JWT_FIELD_MISMATCH"). JWT_ prefiksi — wipe. Keyingi sign() registratsiyadan o'tadi.
  • Jailbreak (iOS) yoki root (Android) bilan qurilmada sign() tarmoq so'rovlarisiz statusError(errorCode: "INTEGRITY_TAMPERED_OS") qaytaradi.

Ish hajmi

Breaking change. Bu orqaga moslikssiz reliz. Eski API (TadSigningViewController(mode:), TadSigningMode, completion-asoslangan TadSigningResult) to'liq o'chiriladi. Hech qanday @available(*, deprecated), hech qanday o'tish davri versiyalari, hech qanday o'ramlar yo'q. SDK iste'molchilari bir marta migratsiya qiladi — yangi TadSigning.sign(dto:)'ga.

Modul Nima qilinadi
Ommaviy API TadSigning namespace: configure(), sign(), isRegistered(), logout(), TadSigningStatus. Eski TadSigningViewController(mode:), TadSigningMode va bog'liq turlar bandldan o'chiriladi.
sign() orkestratsiyasi isRegistered() bo'yicha register / verify shoxlari, holat mashinasi, mutex, har registratsiya uchun tasodifiy deviceId generatori
SecureStorage abstraksiyasi read / save / wipe / wipeAll interfeysi, bankId bo'yicha izolyatsiya. iOS'da Keychain amalga oshirilishi yashirin tafsilot.
JWT validatsiyasi HOWTO.md bo'yicha barcha 5 endpoint uchun public.pem'ga qarshi validator: imzo, exp, deviceId / bankId, o'ziga xos maydonlar, dto deepEqual, alg-confusion guard. Barcha xatolar JWT_* prefiksi ostida.
Xato mapping'i WEBAUTHN_* / JWT_* / INTEGRITY_* / boshqa prefikslar bo'yicha tasniflash → wipe yoki yo'q
Integrity tekshiruvlari jailbreak / debugger / Frida / emulator / proxy / cert pinning / bandl yaxlitligi. Natija — neytral INTEGRITY_* prefiksi ostidagi errorCode. Amalga oshirish iOS'ga xos, tashqariga ko'rinmaydi.
Testlar yuqoridagi jadvallar bo'yicha unit + integratsiya
Sample app + hujjatlar bitta Sign tugmasi, README, migration guide

Qo'shilmaydi

  • Server o'zgartirilmaydi. API kontrakti qotirilgan.
  • Imzo algoritmi o'zgartirilmaydi (RS256 → ES512 — alohida backlog).
  • Kamera / liveness UI o'zgartirilmaydi.
  • JWT, deviceId, credentialId, verificationId ommaviy imzoga chiqarilmaydi. Hech qachon. Completion orqali ham, delegate orqali ham emas.

Webhook Integration HOWTO

Сервер отправляет webhook-уведомления на внешний URL после успешной регистрации и верификации устройства. Webhook используется для интеграции с бэкендом банка: получения событий о регистрации и аутентификации.


Конфигурация

Переменная окружения По умолчанию Описание
CC_WEBHOOK_URL "" (отключён) Базовый URL получателя. Если пустой — запросы не отправляются, payload пишется в stdout
CC_WEBHOOK_FACE 0 Если 1 — в тело запроса включаются изображения в base64 (originalImageBase64, compareImageBase64)
CC_WEBHOOK_JWT_PRIVATE_KEY_PATH ./cert/private.pem Путь к приватному ключу RS256 для подписи Authorization Bearer
CC_WEBHOOK_JWT_PUBLIC_KEY_PATH ./cert/public.pem Путь к публичному ключу RS256 (для верификации на стороне получателя)

Механика отправки

  • Endpoint: POST {CC_WEBHOOK_URL}/api/v1/buy
  • Content-Type: application/json
  • Authorization: Bearer <jwt> — JWT подписан RS256 (тот же ключ, что в HOWTO.md)
  • Retry: до 5 попыток с интервалом 5 секунд при любой ошибке сети/HTTP
  • Concurrency: не более 25 параллельных запросов (execpool)
  • Асинхронность: webhook отправляется после формирования ответа клиенту, не блокирует SDK

Что подписывает Authorization Bearer JWT

JWT в заголовке подписывает data — тело запроса без originalImageBase64 и compareImageBase64 (изображения исключаются из подписи даже если CC_WEBHOOK_FACE=1).

Алгоритм верификации Bearer токена — идентичен HOWTO.md: RS256, public.pem, проверить deviceId и bankId.


Тип 1: регистрация устройства

Отправляется после успешного /api/v1/auth/register/verify.

Тело запроса

{
  "type": "register",
  "requestId": "<string>",
  "serviceName": "<string>",
  "credentialId": "<string>",
  "bankId": "<string>",
  "deviceId": "<string>",
  "dto": { ...произвольные поля банковского контекста... }
}

С CC_WEBHOOK_FACE=1 добавляется:

{
  "originalImageBase64": "<base64>"
}

Описание полей

Поле Тип Описание
type "register" Всегда "register" для этого события
requestId string Идентификатор запроса из исходного вызова SDK
serviceName string Имя сервиса из исходного вызова SDK
credentialId string ID WebAuthn-credential, созданного в Secure Enclave устройства
bankId string Идентификатор банка
deviceId string Идентификатор устройства
dto object Произвольные данные, переданные SDK на шаге /register/options
originalImageBase64 string (только при CC_WEBHOOK_FACE=1) Биометрическое изображение регистрации

Тип 2: верификация (аутентификация)

Отправляется после успешного /api/v1/auth/verify.

Тело запроса

{
  "type": "verify",
  "requestId": "<string>",
  "serviceName": "<string>",
  "bankId": "<string>",
  "deviceId": "<string>",
  "dto": { ...произвольные поля банковского контекста... },
  "score": 0.97
}

С CC_WEBHOOK_FACE=1 добавляется:

{
  "originalImageBase64": "<base64>",
  "compareImageBase64": "<base64>"
}

Описание полей

Поле Тип Описание
type "verify" Всегда "verify" для этого события
requestId string Идентификатор запроса из исходного вызова SDK
serviceName string Имя сервиса из исходного вызова SDK
bankId string Идентификатор банка
deviceId string Идентификатор устройства
dto object Произвольные данные, переданные SDK на шаге /verify/options
score number Биометрический скор совпадения лиц: 0.0–1.0, чем выше — тем лучше совпадение
originalImageBase64 string (только при CC_WEBHOOK_FACE=1) Эталонное изображение из регистрации
compareImageBase64 string (только при CC_WEBHOOK_FACE=1) Изображение текущей попытки аутентификации

Верификация подписи на стороне получателя

Получатель webhook должен верифицировать Authorization: Bearer <jwt> перед обработкой запроса.

const fs = require("fs");
const jwt = require("jsonwebtoken");

const PUBLIC_KEY = fs.readFileSync("public.pem");

function verifyWebhookRequest(req) {
  const authHeader = req.headers["authorization"] || "";
  const token = authHeader.replace(/^Bearer\s+/i, "");
  if (!token) throw new Error("Missing Authorization header");

  let decoded;
  try {
    decoded = jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"] });
  } catch (err) {
    throw new Error(`Invalid webhook signature: ${err.message}`);
  }

  // decoded содержит тело запроса без изображений
  // decoded.type === "register" | "verify"
  // decoded.deviceId, decoded.bankId — всегда присутствуют
  return decoded;
}
// Kotlin / Android (java-jwt Auth0)
fun verifyWebhookBearer(authHeader: String, publicKey: RSAPublicKey): DecodedJWT {
    val token = authHeader.removePrefix("Bearer ").trim()
    val algorithm = Algorithm.RSA256(publicKey, null)
    return JWT.require(algorithm).build().verify(token)
    // decoded.getClaim("type").asString() — "register" или "verify"
    // decoded.getClaim("deviceId").asString()
    // decoded.getClaim("bankId").asString()
}

Последовательность событий

SDK                         Сервер                      Получатель webhook
 |                            |                                |
 |-- POST /register/options ->|                                |
 |<- { data, signature } -----|                                |
 |                            |                                |
 |-- POST /register/verify -->|                                |
 |                            |-- POST /api/v1/buy ----------->|
 |                            |   Authorization: Bearer <jwt>  |
 |                            |   { type: "register", ... }    |
 |<- { data, signature } -----|<- 2xx --------------------------|
 |                            |                                |
 |-- POST /verify/options --->|                                |
 |<- { data, signature } -----|                                |
 |                            |                                |
 |-- POST /verify ----------->|                                |
 |                            |-- POST /api/v1/buy ----------->|
 |                            |   Authorization: Bearer <jwt>  |
 |                            |   { type: "verify", ... }      |
 |<- { data, signature } -----|<- 2xx --------------------------|

Webhook отправляется параллельно с ответом SDK — задержка получателя не влияет на время ответа SDK.


Важные замечания

  • Если CC_WEBHOOK_URL не задан, сервер не выбрасывает ошибку — payload просто печатается в stdout. Это позволяет запускать сервер в режиме разработки без получателя.
  • При недоступности получателя сервер повторяет запрос 5 раз с паузой 5 секунд. Если все попытки исчерпаны — ошибка логируется, но ответ SDK уже отправлен (webhook fire-and-forget).
  • score присутствует только в событии verify. В событии register биометрического сравнения нет — там проверяется лишь совпадение embeddingChecksum с эталоном, сохранённым на шаге /check/capture.
  • originalImageBase64 в событии verify — это эталонное изображение из БД (не текущее), compareImageBase64 — текущее изображение попытки аутентификации.
@tripolskypetr

Copy link
Copy Markdown
Author
1 2 3 4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment