앱 무결성 검증 API (v2)
앱 무결성 검증 API는 요청이 변조되지 않은 정품 앱에서, 정품 기기를 통해 왔는지를 플랫폼 네이티브 메커니즘으로 검증한다. iOS는 Apple App Attest, Android는 Google Play Integrity 를 사용한다. 본 endpoint(ios/attest/* · android/integrity/*) 의 spec 만 본 페이지에 두고, app-token 발급 등 사전 단계는 공통 사전 단계 페이지를 참조한다.
- 인증 채널은
Authorization: Bearer <app-token>한 줄. - deviceId 는 token payload 에서. body 에
deviceId를 넣지 않는다. - 이 검증은 app-token 발급과 무관 — 검증 결과로 토큰을 발급하거나 그 발급을 게이트하지 않는다.
- 모든 바이너리(토큰/서명/키)는 base64url 문자열. challenge/nonce 는 1회용 + 단기 TTL(기본 300s).
- challenge/nonce 는 base64url 문자열로 전달하되, 서명·해시 입력은 base64url 디코드한 raw bytes 를 사용한다 — 클라이언트·서버가 동일 바이트에 적용해야 하며 문자열 자체에 서명/해시하지 않는다.
- 검증 실패는 HTTP 200 +
{ verified: false, reason }로 응답(4xx 아님). 단 body validation 실패는 400.
- 기존 Firebase App Check 를 iOS=App Attest / Android=Play Integrity 로 전환하기 위한 v2 API다. (BSI
O.Resi_5대응) - app-token(앱 토큰) 발급은 별도 메커니즘이다 — 앱 인증 API 참조.
0. 호출 순서
iOS (App Attest)
| # | API | Authorization | 시점 | 응답에서 추출할 값 |
|---|---|---|---|---|
| 1 | POST /v2/security/ios/attest/challenge | Bearer <app-token> | 키 등록·증명 직전 | challenge |
| 2 | POST /v2/security/ios/attest/key ⭐ | Bearer <app-token> | 앱 첫 실행 1회 | registered |
| 3 | POST /v2/security/ios/attest/verify ⭐ | Bearer <app-token> | 무결성 증명(최초 1회) | verified |
앱 첫 실행(설치 직후) 시 "키 등록(attest/key)" 과 "증명(attest/verify)" 을 한 번 수행하고, 이후에는 반복하지 않는다.
Android (Play Integrity)
| # | API | Authorization | 시점 | 응답에서 추출할 값 |
|---|---|---|---|---|
| 1 | POST /v2/security/android/integrity/challenge | Bearer <app-token> | 검증 직전 | nonce |
| 2 | POST /v2/security/android/integrity/verify ⭐ | Bearer <app-token> | 무결성 토큰 검증(최초 1회) | verified |
challenge(1) 와 verify(2) 사이에 앱이 Play Integrity 로부터 무결성 토큰(nonce 포함)을 직접 받는다 — 앱↔Google 직접 통신으로 서버 API 가 아니다(아래 Android verify 참조).
Android 는 키 등록이 없다. 앱 첫 실행(설치 직후) 시 Google 이 발급한 무결성 토큰을 서버가 한 번 검증한다.
⭐ 표시 endpoint 가 본 검증의 본 호출. challenge/nonce 발급은 그 직전 사전 호출이다.
검증 원리 (개념)
- App Attest (iOS) — 키 등록(attest) 시 "이 키가 정품 기기·정품 앱에서 생성됐다" 는 Apple CA 서명 증명서를 서버가 검증하고 public key 를 keyId 로 저장한다. 증명(assert) 시 저장한 public key 로 서명을 검증(위조·replay 차단)한다.
- Play Integrity (Android) — 앱이 Play Integrity 로 받은 무결성 토큰을 서버가 검증하여 앱/기기/Play Protect 판정(verdict)을 확인한다.
공통 에러 (HTTP status)
무결성 검증 자체의 실패(attestation 거부)는 에러 status 가 아니라 HTTP 200 + { verified: false, reason } 로 응답한다(각 endpoint 의 Errors 표 참조). 그러나 아래 전송·인증 단계 에러는 표준 HTTP status 로 응답하며 모든 endpoint 공통이다(AppTokenGuard + body validation).
| HTTP | code | message | 발생 조건 |
|---|---|---|---|
| 400 | 1001 | VALIDATION_ERROR | body 필드 누락/형식 오류 또는 unknown 필드 포함 |
| 401 | 1000 | App token not provided / has expired / has been revoked / invalid format or signature | AppTokenGuard — app token 누락·만료·폐기·형식/서명 오류 |
| 500 | — | INTERNAL_SERVER_ERROR | 서버 내부 오류 |
iOS — App Attest
키 등록(attest/key) 의 clientDataHash 는 SHA256(challenge 의 base64url 디코드 32바이트) 로 정의한다 — 클라이언트·서버가 디코드한 32바이트 에 동일하게 적용하며, 문자열 자체에 해시하지 않는다.
증명(attest/verify) 의 clientDataHash 는 SHA256(compact JSON {"appToken":"<app-token>"} 의 UTF-8 bytes ‖ challenge 의 base64url 디코드 bytes) 로 정의한다 — 공백 없는 compact JSON(Swift JSONEncoder 기본 출력과 동일) 바이트 뒤에 디코드한 challenge 바이트를 이어붙인 뒤 SHA256 한다. <app-token> 은 그 요청의 Authorization: Bearer 토큰과 동일한 문자열이다. challenge 는 1회용 재전송 차단인 동시에 clientData 의 일부로 서명 입력에 포함된다.
ECDSA 서명(assertion)은 ASN.1 DER(X9.62 Ecdsa-Sig-Value, 0x30 SEQUENCE prefix) 형식이다 — iOS Secure Enclave / Android Keystore 의 native 출력이자 WebAuthn ES256 표준이며, P1363(raw r‖s) 이 아니다. (native 2FA 서명 검증과 동일 규약 — libs/feature/auth/eu 의 Native2faSignatureVerifierService 및 docs/security/bsi-audit-evidence/177-cryptography-justification.md 참조.)
1. POST /v2/security/ios/attest/challenge
🔗 라이브 명세 (Swagger UI): dev
challenge 를 발급한다. 키 증명(attestation)·어서션(assertion) 생성 시 clientDataHash 입력으로 사용된다.
- Path:
POST /v2/security/ios/attest/challenge - 인증: App Token 필요 (app token 검증)
Request
| Header | Value |
|---|---|
Authorization | Bearer <app-token> |
body 없음.
Response 200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
challenge | string | base64url 인코딩된 32바이트 난수 |
expiresIn | number | 만료(초) |
{
"challenge": "qmF7c2k9Zk...",
"expiresIn": 300
}
2. POST /v2/security/ios/attest/key ⭐ (키 등록 · 첫 실행 시 1회)
🔗 라이브 명세 (Swagger UI): dev
앱 첫 실행 시 App Attest 키를 생성하고, 발급받은 challenge 로 키 증명(attestation) 객체를 만들어 전송한다. 가입/로그인 플로우와 분리된 독립 단계다.
- Path:
POST /v2/security/ios/attest/key - 인증: App Token 필요 (app token 검증)
Request
| Header | Value |
|---|---|
Authorization | Bearer <app-token> |
Content-Type | application/json |
{
"keyId": "RkVCQ0Q...",
"attestationObject": "o2NmbXR...",
"challenge": "qmF7c2k9Zk..."
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
keyId | string | Yes | App Attest 키 식별자. generateKey() 가 돌려준 값을 그대로 전송 — base64 / base64url 모두 허용 (Apple 은 표준 base64 로 반환) |
attestationObject | string | Yes | 키 증명(attestation) 객체 (CBOR, base64url) |
challenge | string | Yes | 1 에서 받은 값 |
Response 200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
registered | boolean | 등록 성공 여부 |
reason | string | null | 실패 사유 (아래 Errors) |
{
"registered": true
}
서버 검증 단계 (attestationObject)
- CBOR 디코드 → 인증서 체인 / authenticator data / public key 추출
- 인증서 체인을 Apple App Attest Root CA 까지 ECDSA(P-256) 서명 검증
SHA256(challenge)기반 nonce 가 인증서 확장 nonce 와 일치하는지 확인- App ID 해시 =
SHA256(TeamID.BundleID)일치 확인 - AAGUID(환경) =
appattest(운영) /appattestdevelop(개발) 확인 - public key 해시 =
keyId일치, counter = 0 확인 - 통과 시 public key + counter 를 keyId 로 저장
Errors
무결성 판정 실패는 HTTP 200 + { registered: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.
reason | 발생 조건 |
|---|---|
CHALLENGE_EXPIRED | challenge TTL 만료 |
NONCE_MISMATCH | challenge 기반 nonce 가 인증서 확장 nonce 와 불일치 |
ATTESTATION_CHAIN_INVALID | 인증서 체인을 Apple App Attest Root CA 까지 검증 실패 |
KEY_ID_MISMATCH | public key 해시가 keyId 와 불일치 |
APP_ID_MISMATCH | App ID 해시(SHA256(TeamID.BundleID)) 불일치 |
ENVIRONMENT_MISMATCH | AAGUID(운영/개발 환경) 불일치 |
3. POST /v2/security/ios/attest/verify ⭐ (무결성 증명 · 최초 1회)
🔗 라이브 명세 (Swagger UI): dev
어서션(assertion)을 생성해 전송한다. clientDataHash 는 compact JSON {"appToken":"<app-token>"} (공백 없는 UTF-8) 바이트 뒤에 디코드한 challenge 바이트를 이어붙여 SHA256 한 값이며, <app-token> 은 Authorization 헤더의 Bearer 토큰과 동일하다. challenge 는 재전송 차단인 동시에 clientData 의 일부다.
- Path:
POST /v2/security/ios/attest/verify - 인증: App Token 필요 (app token 검증)
Request
| Header | Value |
|---|---|
Authorization | Bearer <app-token> |
Content-Type | application/json |
{
"keyId": "RkVCQ0Q...",
"assertion": "omlzaWdu...",
"challenge": "Zk93x2..."
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
keyId | string | Yes | 등록된 키 식별자 (base64/base64url) |
assertion | string | Yes | 어서션(assertion) 객체 (CBOR, base64url). clientDataHash = SHA256({"appToken":"<app-token>"} ‖ 디코드(challenge)) 에 서명 |
challenge | string | Yes | 1 에서 받은 값 (재전송 차단 + clientData 의 일부) |
Response 200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
verified | boolean | 검증 성공 여부 |
reason | string | null | 실패 사유 |
{
"verified": true
}
서버 검증 단계 (assertion)
- challenge 1회용 소비(재전송 차단). keyId 는 canonical base64url 로 정규화
keyId로 저장된 public key·counter 조회- 저장된 public key 로
SHA256({"appToken":"<app-token>"} ‖ 디코드(challenge))(공백 없는 compact JSON 바이트 + 디코드한 challenge 바이트) 기반 서명을 ECDSA(P-256) 검증 - counter 가 저장값보다 큰지 확인(증가 안 했으면 replay) → counter 갱신
Errors
무결성 판정 실패는 HTTP 200 + { verified: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.
reason | 발생 조건 |
|---|---|
CHALLENGE_EXPIRED | challenge TTL 만료 |
KEY_NOT_FOUND | keyId 로 저장된 키 없음(키 등록 누락) |
SIGNATURE_INVALID | 저장된 public key 로 서명 검증 실패 |
COUNTER_REGRESSION | counter 가 증가하지 않음(replay 의심) |
Android — Play Integrity
1. POST /v2/security/android/integrity/challenge
🔗 라이브 명세 (Swagger UI): dev
Play Integrity 토큰 요청에 사용할 nonce 를 발급한다.
- Path:
POST /v2/security/android/integrity/challenge - 인증: App Token 필요 (app token 검증)
Request
| Header | Value |
|---|---|
Authorization | Bearer <app-token> |
body 없음.
Response 200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
nonce | string | base64url 인코딩된 32바이트 난수 |
expiresIn | number | 만료(초) |
{
"nonce": "bm9uY2Ux...",
"expiresIn": 300
}
challenge 발급과 verify 사이에 앱이 Play Integrity 로부터 무결성 토큰을 직접 발급받는다(nonce 포함). 이 단계는 앱↔Google 직접 통신으로 서버 API 가 아니다.
2. POST /v2/security/android/integrity/verify ⭐ (최초 1회)
🔗 라이브 명세 (Swagger UI): dev
- Path:
POST /v2/security/android/integrity/verify - 인증: App Token 필요 (app token 검증)
Request
| Header | Value |
|---|---|
Authorization | Bearer <app-token> |
Content-Type | application/json |
{
"integrityToken": "CgYI...",
"nonce": "bm9uY2Ux..."
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
integrityToken | string | Yes | Play Integrity 무결성 토큰 |
nonce | string | Yes | 1 에서 받은 값 |
Response 200 OK
| 필드 | 타입 | 설명 |
|---|---|---|
verified | boolean | 검증 성공 여부 (토큰 정통성 + nonce + 패키지 일치 기준) |
verdicts | object | null | 평가 결과: { app, device, playProtect } |
reason | string | null | 실패 사유 |
{
"verified": true,
"verdicts": {
"app": "PLAY_RECOGNIZED",
"device": "MEETS_DEVICE_INTEGRITY",
"playProtect": "NO_ISSUES"
}
}
초기 단계에서는 DEVICE_INTEGRITY_FAILED / PLAY_PROTECT_RISK 같은 위험 판정을 차단 사유로 사용하지 않는다. 해당 verdict 은 verdicts 에 담아 반환·로깅하여 모니터링만 하며, verified 는 토큰 정통성·nonce·패키지 일치 기준으로 결정된다. 데이터를 충분히 모은 뒤 단계적으로 차단 정책으로 전환한다.
Errors
무결성 판정 실패는 HTTP 200 + { verified: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.
reason | 발생 조건 |
|---|---|
NONCE_MISMATCH | 토큰의 nonce 가 발급한 nonce 와 불일치 |
TOKEN_DECODE_FAILED | 무결성 토큰 복호화/디코드 실패 |
APP_NOT_RECOGNIZED | 앱 인증서/버전 미인식 |
PACKAGE_MISMATCH | 패키지명 불일치 |
(※ DEVICE_INTEGRITY_FAILED, PLAY_PROTECT_RISK 는 현재 정책상 차단 사유 아님 — verdicts 로만 노출)
app-token 과의 관계
- 이 API 는 무결성 검증 전용이며 app-token 발급과 분리되어 있다. 검증 성공이 app-token 을 발급하거나 그 발급을 게이트하지 않는다.
- 검증 성공의 결과물은
verified: true응답뿐이다. - app-token 은 기존 메커니즘으로 별도 발급·관리된다 — 앱 인증 API 참조.
다음 단계
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2026-06-16 | jeff@weltcorp.com | 최초 작성 — App Attest/Play Integrity 무결성 검증 v2 (App Check 대체). sessionToken 폐기·app-token 분리·iOS 첫 실행 키 등록·Android 모니터링 정책 반영 |