인증 · 서명
Operator API 요청(오퍼레이터 → 플랫폼)과 지갑 콜백(플랫폼 → 오퍼레이터)은 같은 HMAC-SHA256 서명 규칙을 씁니다. 서명 함수 하나를 만들어 두면 요청 서명과 콜백 검증에 함께 쓸 수 있습니다.
헤더
| 헤더 | Operator API (오퍼레이터가 보냄) | 지갑 콜백 (플랫폼이 보냄) | 값 |
|---|---|---|---|
Content-Type | 보냄 | 보냄 | application/json; charset=utf-8 |
X-Operator | 필수 | 보냄 | 오퍼레이터 코드 (예: acme) |
X-Api-Key | 항상 보내세요 | 보내지 않음 | 발급받은 API 키 (ak_…). 값이 틀리면 401 INVALID_SIGNATURE |
X-Timestamp | 필수 | 보냄 | 요청 시각, Unix epoch 밀리초 10진 문자열 (예: 1760000000000) |
X-Nonce | 필수 | 보냄 (24자) | 요청마다 새로 만드는 임의 문자열, 1~128자 |
X-Signature | 필수 | 보냄 | 아래 서명문의 HMAC-SHA256, 소문자 hex 64자 |
헤더 이름은 대소문자를 구분하지 않습니다.
서명문
서명문은 다섯 부분을 줄바꿈(\n, LF 한 글자)으로 이은 바이트열입니다. 마지막 본문 뒤에는 줄바꿈을 붙이지 않습니다.
X-Timestamp \n X-Nonce \n METHOD \n PATH_WITH_QUERY \n BODYX-Signature = lowercase_hex( HMAC-SHA256( key = 서명 비밀키, message = 서명문 ) )| 부분 | 규칙 |
|---|---|
X-Timestamp | 헤더에 실은 값과 같은 문자열 |
X-Nonce | 헤더에 실은 값과 같은 문자열 |
METHOD | 대문자 HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
PATH_WITH_QUERY | 요청 줄에 실린 경로 + 쿼리 원문. 스킴 · 호스트는 빼고, 쿼리가 있으면 ? 부터 그대로 붙입니다. 퍼센트 인코딩도 보낸 그대로입니다. 예: /operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50 |
BODY | 실제로 보내는 본문 바이트 그대로. 본문이 없으면(GET 등) 빈 문자열 |
| 키 | 발급받은 64자리 hex 비밀키 문자열의 바이트 그대로. hex 디코딩하지 않습니다. |
Operator API 에서
- 본문은 직렬화한 바로 그 바이트로 서명하고 그 바이트를 보냅니다. 객체를 서명할 때 한 번, 보낼 때 또 한 번 직렬화하면 공백 · 키 순서가 달라져 서명이 깨질 수 있습니다.
- GET 은 본문 없이 보내고 빈 문자열로 서명합니다.
- 본문이 필요 없는
POST·DELETE는 본문 없이(빈 문자열로 서명) 보내도 되고,{}를 보내고{}로 서명해도 됩니다. - 쿼리 값은 직접 퍼센트 인코딩한 문자열을 URL 에 넣고, 같은 문자열로 서명합니다. 특히 시간대 오프셋의
+는%2B로 인코딩해야 합니다. HTTP 라이브러리가 쿼리를 다시 인코딩하거나 순서를 바꾸면 서명이 맞지 않습니다.
지갑 콜백에서
- 플랫폼은 항상
POST로 보내고,PATH_WITH_QUERY는 등록된 지갑 URL 의 경로에 엔드포인트를 붙인 값입니다. 지갑 URL 이https://api.acme.example/games-wallet이면 debit 의 서명 경로는/games-wallet/debit입니다. - 검증할 때는 JSON 을 파싱하기 전의 원문 본문 바이트로 서명을 계산하세요. 파싱한 객체를 다시 직렬화하면 바이트가 달라집니다. 플랫폼은 JSON 키를 사전순으로 직렬화하지만, 그 순서에 기대지 말고 받은 바이트를 그대로 쓰세요.
- 플랫폼은 같은 거래를 재시도할 때 본문은 처음과 같은 바이트로 다시 보내고,
X-Timestamp·X-Nonce·X-Signature만 새로 만듭니다.
타임스탬프 · nonce
| 규칙 | 값 |
|---|---|
| 허용 오차 | 수신 서버 시각 기준 ±5분. 넘으면 401 TIMESTAMP_OUT_OF_RANGE |
| nonce 길이 | 1~128자. 비었거나 길면 401 INVALID_SIGNATURE |
| nonce 재사용 | 같은 오퍼레이터의 같은 nonce 는 5분 안에 한 번만 받습니다. 두 번째부터 401 NONCE_REUSED |
- 서버 시계를 NTP 로 맞춰 두세요. 시계가 5분 넘게 어긋나면 모든 요청이 거절됩니다.
- nonce 는 요청마다 새로 만듭니다. 16바이트 이상의 암호학적 난수를 hex 로 쓰기를 권장합니다.
- 타임아웃 등으로 같은 요청을 다시 보낼 때도 타임스탬프 · nonce · 서명을 새로 만드세요. 첫 요청이 플랫폼에 닿아 nonce 가 이미 쓰였다면, 같은 nonce 로 다시 보낸 요청은
NONCE_REUSED로 거절됩니다.
플랫폼의 검증 순서 (Operator API)
플랫폼은 Operator API 요청을 아래 순서로 검사하고, 처음 실패한 단계의 오류를 돌려줍니다.
| 순서 | 검사 | 실패 시 |
|---|---|---|
| 1 | X-Operator 의 오퍼레이터가 있는지 | 401 INVALID_SIGNATURE |
| 2 | 오퍼레이터가 사용 중 상태인지 | 403 OPERATOR_DISABLED |
| 3 | 요청 IP 가 등록된 IP 허용 목록 안인지 | 403 IP_NOT_ALLOWED |
| 4 | X-Api-Key 가 발급된 키와 같은지 | 401 INVALID_SIGNATURE |
| 5 | 본문 읽기 | 400 INVALID_REQUEST |
| 6 | 타임스탬프 형식 · ±5분 | 401 TIMESTAMP_OUT_OF_RANGE |
| 7 | 서명 비교 | 401 INVALID_SIGNATURE |
| 8 | nonce 길이 | 401 INVALID_SIGNATURE |
| 9 | nonce 재사용 | 401 NONCE_REUSED |
| 10 | 속도 제한 (오퍼레이터당 초당 200건) | 429 RATE_LIMITED |
오류 응답 모양은 공통 규약을, 코드 전체는 오류 코드를 보세요.
지갑 콜백 검증 순서 (오퍼레이터 구현)
지갑 콜백을 받으면 업무 처리 전에 아래 순서로 검사하기를 권장합니다.
X-Operator가 자기 오퍼레이터 코드와 같은지 확인합니다.X-Timestamp가 정수이고 자기 서버 시각과 ±5분 안인지 확인합니다.- 원문 본문 바이트로 서명을 계산해
X-Signature와 상수 시간 비교합니다(hmac.Equal,crypto.timingSafeEqual,hmac.compare_digest). - 서명이 맞으면 nonce 를 5분 이상 보관하고, 이미 본 nonce 면 거절합니다. 서버가 여러 대이거나 재시작될 수 있으면 Redis · DB 같은 공유 저장소에 보관하세요(프로세스 메모리만 쓰면 재사용 방지가 약해집니다).
- 여기까지 통과한 뒤에 JSON 을 파싱하고
tx_id멱등 처리 · 잔액 처리로 넘어갑니다.
검증에 실패하면 HTTP 200 + {"status":"INVALID_SIGNATURE"} 로 응답합니다. 상태별로 플랫폼이 어떻게 처리하는지는 지갑 콜백 개요를 보세요.
예시 코드
서명 함수, Operator API 호출, 지갑 콜백 검증을 언어별로 보입니다. 환경변수 GAMES_API_BASE(환경별로 발급하는 {API_BASE} — 연동 절차), GAMES_OPERATOR_CODE, GAMES_API_KEY, GAMES_API_SECRET 을 쓴다고 가정합니다. 콜백 nonce 중복 검사는 저장소에 따라 다르므로 빠져 있습니다.
package games
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
// sign 은 서명문 = ts \n nonce \n METHOD \n pathWithQuery \n body 의 HMAC-SHA256 소문자 hex.
func sign(secret, ts, nonce, method, pathWithQuery string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret)) // 비밀키 문자열 바이트 그대로
mac.Write([]byte(ts + "\n" + nonce + "\n" + method + "\n" + pathWithQuery + "\n"))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// Call 은 Operator API 를 서명해 부른다.
// pathWithQuery 는 퍼센트 인코딩을 마친 문자열 (예: "/operator/v1/rounds?limit=50").
func Call(method, pathWithQuery string, body []byte) (*http.Response, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
nonce := hex.EncodeToString(b)
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
if body == nil {
body = []byte{} // GET 은 빈 본문으로 서명
}
req, err := http.NewRequest(method, os.Getenv("GAMES_API_BASE")+pathWithQuery, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("X-Operator", os.Getenv("GAMES_OPERATOR_CODE"))
req.Header.Set("X-Api-Key", os.Getenv("GAMES_API_KEY"))
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", sign(os.Getenv("GAMES_API_SECRET"), ts, nonce, method, pathWithQuery, body))
return http.DefaultClient.Do(req)
}
// VerifyCallback 은 지갑 콜백의 서명을 확인하고 원문 본문을 돌려준다 (nonce 중복 검사는 따로).
func VerifyCallback(r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || r.Header.Get("X-Operator") != os.Getenv("GAMES_OPERATOR_CODE") {
return nil, false
}
ts := r.Header.Get("X-Timestamp")
ms, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.UnixMilli(ms)).Abs() > 5*time.Minute {
return nil, false
}
// r.URL.RequestURI() = 요청 줄의 경로 + 쿼리 원문
want := sign(os.Getenv("GAMES_API_SECRET"), ts, r.Header.Get("X-Nonce"), r.Method, r.URL.RequestURI(), body)
return body, hmac.Equal([]byte(want), []byte(r.Header.Get("X-Signature")))
}import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.GAMES_API_SECRET; // 64자리 hex 문자열 그대로
// 서명문 = ts \n nonce \n METHOD \n pathWithQuery \n body
export function sign(ts, nonce, method, pathWithQuery, body) {
return createHmac('sha256', SECRET)
.update(`${ts}\n${nonce}\n${method}\n${pathWithQuery}\n`)
.update(body) // string 은 UTF-8 로, Buffer 는 바이트 그대로
.digest('hex');
}
// Operator API 호출. pathWithQuery 는 퍼센트 인코딩을 마친 문자열.
export async function callApi(method, pathWithQuery, payload) {
const ts = String(Date.now());
const nonce = randomBytes(16).toString('hex');
const raw = payload === undefined ? '' : JSON.stringify(payload); // 보낼 바이트 = 서명할 바이트
const res = await fetch(process.env.GAMES_API_BASE + pathWithQuery, {
method,
body: method === 'GET' ? undefined : raw,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Operator': process.env.GAMES_OPERATOR_CODE,
'X-Api-Key': process.env.GAMES_API_KEY,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': sign(ts, nonce, method, pathWithQuery, raw),
},
});
return res.json();
}
// 지갑 콜백 서명 확인. req 는 node:http 요청, rawBody 는 받은 본문 Buffer.
// (Express 라면 express.raw({ type: 'application/json' }) 로 원문을 받는다.) nonce 중복 검사는 따로.
export function verifyCallback(req, rawBody) {
const ts = req.headers['x-timestamp'] ?? '';
const nonce = req.headers['x-nonce'] ?? '';
const sig = req.headers['x-signature'] ?? '';
if (req.headers['x-operator'] !== process.env.GAMES_OPERATOR_CODE) return false;
if (!/^\d+$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
// req.url = 요청 줄의 경로 + 쿼리 원문 (Express 는 하위 라우터에서 바뀌므로 req.originalUrl)
const want = Buffer.from(sign(ts, nonce, req.method, req.url, rawBody));
const got = Buffer.from(String(sig));
return want.length === got.length && timingSafeEqual(want, got);
}import hashlib, hmac, json, os, secrets, time
import urllib.request
SECRET = os.environ["GAMES_API_SECRET"].encode() # 64자리 hex 문자열 그대로
def sign(ts: str, nonce: str, method: str, path_with_query: str, body: bytes) -> str:
"""서명문 = ts \\n nonce \\n METHOD \\n path_with_query \\n body"""
msg = f"{ts}\n{nonce}\n{method}\n{path_with_query}\n".encode() + body
return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()
def call_api(method: str, path_with_query: str, payload=None):
"""Operator API 호출. path_with_query 는 퍼센트 인코딩을 마친 문자열."""
ts = str(int(time.time() * 1000))
nonce = secrets.token_hex(16)
raw = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
req = urllib.request.Request(
os.environ["GAMES_API_BASE"] + path_with_query,
data=None if method == "GET" else raw,
method=method,
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Operator": os.environ["GAMES_OPERATOR_CODE"],
"X-Api-Key": os.environ["GAMES_API_KEY"],
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": sign(ts, nonce, method, path_with_query, raw),
},
)
with urllib.request.urlopen(req, timeout=10) as res:
return json.load(res)
def verify_callback(method: str, path_with_query: str, headers: dict, raw_body: bytes) -> bool:
"""지갑 콜백 서명 확인. headers 는 소문자 키, raw_body 는 받은 바이트 그대로. nonce 중복 검사는 따로."""
ts = headers.get("x-timestamp", "")
if headers.get("x-operator") != os.environ["GAMES_OPERATOR_CODE"]:
return False
if not ts.isdigit() or abs(time.time() * 1000 - int(ts)) > 5 * 60 * 1000:
return False
want = sign(ts, headers.get("x-nonce", ""), method, path_with_query, raw_body)
return hmac.compare_digest(want, headers.get("x-signature", ""))테스트 벡터
구현을 확인할 수 있는 고정 입력과 기대 서명입니다. 아래 비밀키는 예시용 가짜 값입니다. 세 언어의 위 sign 함수 모두 같은 결과를 냅니다.
비밀키: 4f1c2a9e7b3d5f8061a2c4e6b8d0f1a3c5e7092b4d6f8a1c3e5072b4d6f8a9c11. POST 요청 (게임 실행)
| 항목 | 값 |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | 9b1f0c7e2d4a6b8c0e1f2a3b |
| METHOD | POST |
| PATH_WITH_QUERY | /operator/v1/launch |
본문 (공백 없이 한 줄):
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}X-Signature = 376a774f812e82f8e63532237da598e05f229c2c8caf1cc858d12eb949c255e52. GET 요청 (인코딩된 쿼리)
| 항목 | 값 |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | 1a2b3c4d5e6f7a8b9c0d1e2f |
| METHOD | GET |
| PATH_WITH_QUERY | /operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50 |
| 본문 | (빈 문자열) |
X-Signature = 2d61631759075b2049547a65929805acf090e9088f94e4a83dbfb896b4d539143. 지갑 콜백 (debit)
지갑 URL 이 https://api.acme.example/wallet 인 경우입니다.
| 항목 | 값 |
|---|---|
X-Timestamp | 1760000000000 |
X-Nonce | c0ffee00c0ffee00c0ffee00 |
| METHOD | POST |
| PATH_WITH_QUERY | /wallet/debit |
본문 (공백 없이 한 줄):
{"amount":5000,"created_at":"2025-10-09T08:53:20.000Z","currency":"PHP","external_id":"user_1001","game_code":"live_baccarat","operator_session_id":null,"player_id":"pl_0192f3a1c4e87b2c9d0e1f2a3b4c5d6f","reason":"bet","round_id":"rnd_0192f3a1c4e87b2c9d0e1f2a3b4c5d71","table_id":"tbl_0192f3a1c4e87b2c9d0e1f2a3b4c5d70","tx_id":"tx_0192f3a1c4e87b2c9d0e1f2a3b4c5d6e"}X-Signature = 3f54f0c9c193f8234fd5bc296c48a6ed41d7221a9af0a5bf94b5d5aba43d6600서명문 직접 확인
1번의 서명문은 아래 다섯 줄입니다(마지막 줄 뒤에 줄바꿈 없음). 결과가 다르면 줄바꿈 문자(\r\n 이 아니라 \n)와 본문 바이트부터 확인하세요.
1760000000000
9b1f0c7e2d4a6b8c0e1f2a3b
POST
/operator/v1/launch
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}흔한 실수
| 증상 | 원인 |
|---|---|
모든 요청이 INVALID_SIGNATURE | 비밀키를 hex 디코딩해 키로 썼습니다. 64자리 문자열 그대로 써야 합니다. |
POST 만 INVALID_SIGNATURE | 서명한 본문과 보낸 본문이 다릅니다(두 번 직렬화, 라이브러리가 본문을 다시 직렬화). 직렬화한 바이트 하나로 서명하고 보내세요. |
쿼리가 있는 GET 만 INVALID_SIGNATURE | 서명한 경로와 실제 요청 줄이 다릅니다. 라이브러리에 쿼리를 객체로 넘기면 인코딩 · 순서가 바뀔 수 있습니다. 인코딩을 마친 문자열을 URL 과 서명에 똑같이 쓰세요. |
| 기간 조회가 조용히 기본값(최근 24시간)으로 나옴 | from=…+08:00 의 + 를 인코딩하지 않아 공백으로 읽혔습니다. %2B 로 보내세요. |
| 대문자 hex 서명이 거절됨 | 서명은 소문자 hex 로 비교합니다. |
TIMESTAMP_OUT_OF_RANGE | 초 단위 타임스탬프를 보냈거나 서버 시계가 어긋났습니다. 밀리초로 보내고 NTP 를 맞추세요. |
재시도한 요청이 NONCE_REUSED | 같은 nonce 를 다시 썼습니다. 재시도 때도 nonce · 타임스탬프 · 서명을 새로 만드세요. |
| 지갑 콜백 검증이 운영 환경에서만 실패 | 리버스 프록시가 경로 앞부분을 잘라 앱에 넘깁니다. 플랫폼은 등록된 지갑 URL 의 원래 경로로 서명하므로, 앱이 받은 경로가 다르면 원래 경로로 바꿔 검증하세요. |
| 지갑 콜백 검증이 가끔 실패 | 파싱한 JSON 을 다시 직렬화해 검증했습니다. 원문 바이트로 검증하세요. |
큰 본문만 INVALID_SIGNATURE | 본문 상한(64KB)을 넘어 잘린 채 검증됐습니다. 공통 규약 참고 |