You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Каждый успешный ответ сервера содержит поле 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 защищают от подстановки валидного токена другого пользователя или другой операции.
dto не возвращается в data — он подписан только внутри signature.
Это криптографическое подтверждение того, что сервер принял именно этот dto и сохранил его в Redis.
Примечание: поле challenge — это base64url(challengeJwt),
где challengeJwt сам является JWT с { deviceId, bankId, registrationId }.
Его дополнительная верификация необязательна, если проверены поля верхнего уровня.
dto не возвращается в data — он подписан только внутри signature.
Это криптографическое подтверждение того, что сервер принял именно этот dto и сохранил его в Redis.
challengeJwt будет использован как WebAuthn challenge на следующем шаге.
Верифицировать его содержимое через тот же public.pem: внутри { deviceId, bankId, verificationId }.
Это финальное подтверждение аутентификации. Валидная подпись означает:
сервер прошёл все 8 проверок (WebAuthn assertion, replay, freshness, biometry, signCount и др.)
и подтверждает, что именно этот deviceId/bankId прошёл верификацию.
Пример кода (Swift / использование сторонней библиотеки)
// Рекомендуется библиотека: SwiftJWT (IBM) или JWTDecode (Auth0)
// Пример с JWTDecode (только decode без верификации подписи — недостаточно для безопасности)
// Для полной верификации RS256 используйте SwiftJWT
import SwiftJWT
structSignatureClaims:Claims{letdeviceId:StringletbankId:Stringletiat:Date?letexp:Date?
// добавить специфичные для эндпоинта поля
}func verifySignature(_ token:String, expectedDeviceId:String, expectedBankId:String)throws->SignatureClaims{letpublicKey=... // Data из public.pem
letverifier=JWTVerifier.rs256(publicKey: publicKey)letjwt=tryJWT<SignatureClaims>(jwtString: token, verifier: verifier)guard jwt.claims.deviceId == expectedDeviceId else{throwVerificationError.deviceIdMismatch
}guard jwt.claims.bankId == expectedBankId else{throwVerificationError.bankIdMismatch
}return jwt.claims
}
Пример кода (Kotlin / Android)
// Рекомендуется библиотека: java-jwt (Auth0)// implementation("com.auth0:java-jwt:4.4.0")importcom.auth0.jwt.JWTimportcom.auth0.jwt.algorithms.Algorithmimportjava.security.KeyFactoryimportjava.security.interfaces.RSAPublicKeyimportjava.security.spec.X509EncodedKeySpecimportjava.util.Base64funverifySignature(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)) asRSAPublicKeyval 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" }
returnmapOf("deviceId" to deviceId, "bankId" to bankId)
// добавить извлечение специфичных для эндпоинта полей
}
Важные замечания
Никогда не доверяйте data из ответа без верификации signature — именно signature является
криптографическим доказательством того, что сервер выдал эти данные.
Порядок проверок: сначала верификация подписи, затем сравнение полей — не наоборот.
Токен истекает через 15 минут — не кешируйте signature для повторного использования.
public.pem не является секретом, но его целостность критична: хранить в бандле приложения,
не загружать динамически с сервера.
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
publicenumTadSigning{
/// SDK configuration. Called once at app start.
publicstaticfunc 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, registrationId — never returned to the caller.
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.
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(dto)])
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()<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() == false — sign() 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() == true — sign() 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
lets1=awaitTadSigning.sign(dto:[:])guard case .statusOk = s1 else{return /* handle error */ }}
// once registered — sign() with the real DTO performs the signing
lets2=awaitTadSigning.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()<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.
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.
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.
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.
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().
verify → WEBAUTHN_USER_NOT_FOUND
wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered() becomes false. Auto-register is not triggered.
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/verify → statusError(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/options → statusError(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.
И получает 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
publicenumTadSigning{
/// Конфигурация SDK. Вызывается один раз при старте приложения.
publicstaticfunc 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
INTEGRITY_* (например, INTEGRITY_TAMPERED_OS, INTEGRITY_DEBUGGER, INTEGRITY_PROXY, INTEGRITY_HOOKING, INTEGRITY_EMULATOR, INTEGRITY_PINNING)
Локальные проверки целостности
Wipe регистрации, statusError. Под капотом SDK волен реализовать любые проверки; наружу идут только эти нейтральные строки. INTEGRITY_TAMPERED_OS покрывает и jailbreak (iOS), и root (Android) — единое имя для обеих платформ.
statusError без wipe. Регистрация остаётся валидной, повторный sign() пойдёт по короткому пути «только верификация».
Никаких платформенных имён (SECURE_ENCLAVE_*, KEYCHAIN_*, KEYSTORE_*, STRONGBOX_*, JAILBREAK_*, ROOT_*, PROXY_* без префикса INTEGRITY_) в публичном errorCode. Каждое имя должно одинаково естественно читаться на iOS и на Android — это требование Flutter-обёртки, которая будет проксировать одни и те же значения с обеих платформ.
Жизненный цикл sign(dto)
flowchart TD
Start([App: TadSigning.sign(dto)])
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()<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() == false — sign() выполняет регистрацию. После успеха в хранилище появляется пара deviceId + credentialId, и sign() возвращает statusOk. DTO в этом вызове не подписан — для подписи нужен ещё один вызов sign().
Если isRegistered() == true — sign() выполняет верификацию с переданным 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 можно передать пустой
lets1=awaitTadSigning.sign(dto:[:])guard case .statusOk = s1 else{return /* обработать ошибку */ }}
// после регистрации — sign() с настоящим DTO выполнит подпись
lets2=awaitTadSigning.sign(dto: realDto)
Если регистрация уже есть, первый if пропускается и подпись делается одним вызовом.
Подцикл регистрации
flowchart TD
Start([Subflow: регистрация])
Start --> Gen[deviceId = UUID()<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.
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, никаких дублирующих копий.
Каждый успешный ответ содержит signature — JWT, подписанный приватным ключом сервера. Алгоритм считывается из TadSigningConfig.publicKeyPem (текущий — ES512). TTL — 15 минут.
каждое поле 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().
verify → WEBAUTHN_USER_NOT_FOUND
wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered() становится false. Auto-register не запускается.
verify → WEBAUTHN_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
verify → FACEIDS_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: "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/verify → statusError(errorCode: "JWT_FIELD_MISMATCH"), в хранилище ничего не записано (атомарность).
Атакующий middleware подменяет dto в ответе POST /api/v1/auth/verify/options → statusError(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.
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
publicenumTadSigning{
/// SDK konfiguratsiyasi. Ilova ishga tushirilganda bir marta chaqiriladi.
publicstaticfunc 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
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.
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(dto)])
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()<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() == false — sign() 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() == true — sign() uzatilgan dto bilan verifikatsiyani bajaradi. Muvaffaqiyatdan keyin statusOk, registratsiya saqlanadi.
Agar verifyWEBAUTHN_* / 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
lets1=awaitTadSigning.sign(dto:[:])guard case .statusOk = s1 else{return /* xatoni qayta ishlash */ }}
// registratsiyadan keyin — haqiqiy DTO bilan sign() imzolashni bajaradi
lets2=awaitTadSigning.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()<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.
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.
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.
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. deviceIdlogout()'gacha ishlatilganidan farq qiladi.
verify → WEBAUTHN_USER_NOT_FOUND
wipe, statusError(errorCode: "WEBAUTHN_USER_NOT_FOUND"), isRegistered()false bo'ladi. Auto-register ishga tushmaydi.
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-уведомления на внешний 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).
Webhook отправляется параллельно с ответом SDK — задержка получателя не влияет на время ответа SDK.
Важные замечания
Если CC_WEBHOOK_URL не задан, сервер не выбрасывает ошибку — payload просто печатается в stdout.
Это позволяет запускать сервер в режиме разработки без получателя.
При недоступности получателя сервер повторяет запрос 5 раз с паузой 5 секунд.
Если все попытки исчерпаны — ошибка логируется, но ответ SDK уже отправлен (webhook fire-and-forget).
score присутствует только в событии verify. В событии register биометрического сравнения нет —
там проверяется лишь совпадение embeddingChecksum с эталоном, сохранённым на шаге /check/capture.
originalImageBase64 в событии verify — это эталонное изображение из БД (не текущее),
compareImageBase64 — текущее изображение попытки аутентификации.