본문으로 건너뛰기
한국어 번역 진행 중한국어 문서를 완성하는 동안 일부 가이드와 API 설명은 아직 영어로 표시될 수 있습니다.

콜백 인증

Ruby가 귀사의 서버로 보내는 모든 콜백 요청에는 3개의 인증 헤더가 포함됩니다. 요청을 처리하기 전에 모든 수신 요청에서 이 헤더들을 반드시 검증해야 합니다.


Ruby가 전송하는 헤더

X-Aggregator-Key:       {brand.api_key}
X-Aggregator-Timestamp: {unix_timestamp_seconds}
X-Aggregator-Signature: {hmac_sha256_hex_digest}
헤더설명
X-Aggregator-KeyStringRuby에 설정된 귀사 브랜드의 API 키
X-Aggregator-TimestampString (integer)요청이 서명된 시점의 Unix 타임스탬프 (초 단위)
X-Aggregator-SignatureString (hex)요청을 인증하는 HMAC-SHA256 16진수 다이제스트

서명 알고리즘

Ruby는 다음과 같이 서명을 계산합니다:

HMAC-SHA256(
key: api_secret encoded as UTF-8 bytes,
data: raw_body_bytes + str(timestamp).encode("utf-8")
)

쉽게 설명하면:

  1. 원시(Raw) HTTP 요청 본문을 바이트로 가져옵니다 (파싱하거나 재직렬화하지 마십시오)
  2. X-Aggregator-Timestamp의 타임스탬프 문자열을 UTF-8 바이트로 인코딩합니다
  3. 연결합니다: body_bytes + timestamp_bytes (본문이 먼저, 타임스탬프가 끝에 추가)
  4. api_secret을 키로 사용하여 HMAC-SHA256을 계산합니다
  5. 서명은 결과의 소문자 16진수 다이제스트(Hex Digest)입니다
중요

서명에 사용되는 본문 바이트는 네트워크를 통해 수신된 정확한 바이트입니다. 서명을 계산하기 전에 본문을 JSON 파싱하고 재직렬화하지 마십시오 -- 필드 순서, 공백 또는 인코딩의 변경은 다른 다이제스트를 생성하여 검증 실패를 유발합니다.


Team API 서명과의 비교

콜백 서명 알고리즘은 귀사가 Ruby의 엔드포인트를 호출할 때 사용하는 Team API 서명 알고리즘과 의도적으로 다릅니다. 두 가지를 혼동하지 마십시오.

측면Team API (귀사가 Ruby를 호출)콜백 (Ruby가 귀사를 호출)
HMAC 키team_api_secretbrand.api_secret
입력 형식문자열: {ts}{METHOD}{path}{body}바이트: body_bytes + ts_string_bytes
타임스탬프 위치처음마지막
HTTP 메서드 포함아니오
URL 경로 포함아니오
헤더 접두사X-Team-*X-Aggregator-*

검증 단계

수신 콜백 요청을 검증하려면 다음 7단계를 순서대로 따르십시오:

1단계 -- 원시 본문 바이트 읽기

파싱 전에 원시 HTTP 요청 본문을 캡처합니다. 서명 계산에 나중에 사용할 수 있도록 이 정확한 바이트를 저장하십시오.

2단계 -- 헤더 추출

3개의 헤더를 읽습니다: X-Aggregator-Key, X-Aggregator-Timestamp, X-Aggregator-Signature.

3단계 -- API 키 검증

X-Aggregator-Key가 귀사의 Ruby 브랜드 설정에 보관된 api_key와 일치하는지 확인합니다. 일치하지 않으면 401로 거부하십시오.

4단계 -- 타임스탬프 검증

X-Aggregator-Timestamp를 정수로 파싱합니다. 해당 타임스탬프와 귀사 서버의 현재 시간(Unix 초) 사이의 절대 차이를 계산합니다. 차이가 **300초(5분)**를 초과하면 401로 요청을 거부하십시오.

이는 재생 공격(Replay Attack)을 방지합니다. 서버 시계가 동기화되어 있는지 확인하십시오 (NTP 사용을 권장합니다).

5단계 -- 예상 서명 계산

data = raw_body_bytes + str(timestamp).encode("utf-8")
expected_sig = HMAC-SHA256(api_secret.encode("utf-8"), data).hexdigest()

6단계 -- 서명 비교

expected_sigX-Aggregator-Signature의 값과 상수 시간 비교(Constant-time Comparison) 함수를 사용하여 비교합니다. 일반 문자열 동등성 검사를 사용하지 마십시오 -- 이는 타이밍 공격(Timing Attack)을 방지합니다.

7단계 -- 본문 파싱

서명이 검증된 후에만 요청 본문을 JSON 파싱하여 비즈니스 로직을 처리하십시오.


실습 예제

수신 요청:

POST /ruby/debit
X-Aggregator-Key: key_brandabc
X-Aggregator-Timestamp: 1711500000
X-Aggregator-Signature: <see below>
Content-Type: application/json

{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}

예상 서명 계산:

api_secret      = "my_brand_secret"
raw_body_bytes = b'{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}'
timestamp_bytes = b'1711500000'

hmac_input = raw_body_bytes + timestamp_bytes
= b'{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}1711500000'

signature = HMAC-SHA256(key=b"my_brand_secret", data=hmac_input).hexdigest()

결과 16진수 다이제스트가 X-Aggregator-Signature와 비교할 값입니다.


코드 예제

Python

import hashlib
import hmac
import time
from typing import Optional

def verify_callback_signature(
raw_body: bytes,
api_key_header: str,
timestamp_header: str,
signature_header: str,
expected_api_key: str,
api_secret: str,
max_age_seconds: int = 300,
) -> bool:
"""
Verify the HMAC-SHA256 signature on an incoming Ruby callback request.

Returns True if the request is authentic, False otherwise.
"""
# Step 3: Validate API key
if api_key_header != expected_api_key:
return False

# Step 4: Validate timestamp
try:
timestamp = int(timestamp_header)
except ValueError:
return False

if abs(int(time.time()) - timestamp) > max_age_seconds:
return False

# Step 5: Compute expected signature
hmac_input = raw_body + timestamp_header.encode("utf-8")
expected_sig = hmac.new(
api_secret.encode("utf-8"),
hmac_input,
hashlib.sha256,
).hexdigest()

# Step 6: Constant-time comparison
return hmac.compare_digest(expected_sig, signature_header)


# Flask example
from flask import Flask, request, abort

app = Flask(__name__)

API_KEY = "key_brandabc"
API_SECRET = "my_brand_secret"


@app.route("/ruby/debit", methods=["POST"])
def handle_debit():
raw_body = request.get_data() # raw bytes, before any parsing

valid = verify_callback_signature(
raw_body=raw_body,
api_key_header=request.headers.get("X-Aggregator-Key", ""),
timestamp_header=request.headers.get("X-Aggregator-Timestamp", ""),
signature_header=request.headers.get("X-Aggregator-Signature", ""),
expected_api_key=API_KEY,
api_secret=API_SECRET,
)

if not valid:
abort(401)

data = request.get_json()
# ... process debit ...
return {"balance": "1149.50", "balance_before": "1250.00"}

Node.js

const crypto = require("crypto");

/**
* Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
*
* @param {Buffer} rawBody - Raw request body bytes (before any parsing)
* @param {string} apiKeyHeader
* @param {string} timestampHeader
* @param {string} signatureHeader
* @param {string} expectedApiKey
* @param {string} apiSecret
* @param {number} maxAgeSeconds - Default 300 (5 minutes)
* @returns {boolean}
*/
function verifyCallbackSignature(
rawBody,
apiKeyHeader,
timestampHeader,
signatureHeader,
expectedApiKey,
apiSecret,
maxAgeSeconds = 300
) {
// Step 3: Validate API key
if (apiKeyHeader !== expectedApiKey) return false;

// Step 4: Validate timestamp
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) return false;

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > maxAgeSeconds) return false;

// Step 5: Compute expected signature
const hmacInput = Buffer.concat([
rawBody,
Buffer.from(timestampHeader, "utf8"),
]);
const expectedSig = crypto
.createHmac("sha256", apiSecret)
.update(hmacInput)
.digest("hex");

// Step 6: Constant-time comparison
if (expectedSig.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expectedSig, "utf8"),
Buffer.from(signatureHeader, "utf8")
);
}


// Express example
const express = require("express");
const app = express();

const API_KEY = "key_brandabc";
const API_SECRET = "my_brand_secret";

// IMPORTANT: Use raw body middleware so we can read bytes before parsing
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);

app.post("/ruby/debit", (req, res) => {
const valid = verifyCallbackSignature(
req.rawBody,
req.headers["x-aggregator-key"] ?? "",
req.headers["x-aggregator-timestamp"] ?? "",
req.headers["x-aggregator-signature"] ?? "",
API_KEY,
API_SECRET
);

if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}

const { player_id, amount, transaction_id } = req.body;
// ... process debit ...
res.json({ balance: "1149.50", balance_before: "1250.00" });
});

Java

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;

public class CallbackAuthVerifier {

private final String expectedApiKey;
private final String apiSecret;
private final int maxAgeSeconds;

public CallbackAuthVerifier(String expectedApiKey, String apiSecret) {
this(expectedApiKey, apiSecret, 300);
}

public CallbackAuthVerifier(String expectedApiKey, String apiSecret, int maxAgeSeconds) {
this.expectedApiKey = expectedApiKey;
this.apiSecret = apiSecret;
this.maxAgeSeconds = maxAgeSeconds;
}

/**
* Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
*
* @param rawBody Raw request body bytes (do NOT parse before calling this)
* @param apiKeyHeader Value of X-Aggregator-Key header
* @param timestampHeader Value of X-Aggregator-Timestamp header
* @param signatureHeader Value of X-Aggregator-Signature header
* @return true if the request is authentic
*/
public boolean verify(
byte[] rawBody,
String apiKeyHeader,
String timestampHeader,
String signatureHeader
) {
// Step 3: Validate API key
if (!expectedApiKey.equals(apiKeyHeader)) {
return false;
}

// Step 4: Validate timestamp
long timestamp;
try {
timestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
return false;
}

long now = Instant.now().getEpochSecond();
if (Math.abs(now - timestamp) > maxAgeSeconds) {
return false;
}

// Step 5: Compute expected signature
try {
byte[] timestampBytes = timestampHeader.getBytes(StandardCharsets.UTF_8);
byte[] hmacInput = new byte[rawBody.length + timestampBytes.length];
System.arraycopy(rawBody, 0, hmacInput, 0, rawBody.length);
System.arraycopy(timestampBytes, 0, hmacInput, rawBody.length, timestampBytes.length);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(hmacInput);
String expectedSig = HexFormat.of().formatHex(digest);

// Step 6: Constant-time comparison
return MessageDigest.isEqual(
expectedSig.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8)
);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("HMAC-SHA256 not available", e);
}
}
}


// Spring Boot controller example
// @RestController
// public class WalletCallbackController {
//
// private final CallbackAuthVerifier verifier =
// new CallbackAuthVerifier("key_brandabc", "my_brand_secret");
//
// @PostMapping("/ruby/debit")
// public ResponseEntity<?> handleDebit(
// HttpServletRequest httpRequest,
// @RequestHeader("X-Aggregator-Key") String apiKey,
// @RequestHeader("X-Aggregator-Timestamp") String timestamp,
// @RequestHeader("X-Aggregator-Signature") String signature,
// @RequestBody byte[] rawBody // Use byte[] to get raw bytes
// ) {
// if (!verifier.verify(rawBody, apiKey, timestamp, signature)) {
// return ResponseEntity.status(401).body("Invalid signature");
// }
// // Parse and process ...
// return ResponseEntity.ok(Map.of("balance", "1149.50", "balance_before", "1250.00"));
// }
// }

흔한 실수

본문을 재직렬화한 후 서명

서명은 네트워크를 통해 수신된 원시 바이트를 기반으로 계산됩니다. 본문을 JSON 파싱한 후 HMAC을 계산하기 전에 재직렬화하면, 필드 순서나 공백이 달라져 불일치가 발생할 수 있습니다.

항상 JSON 파싱 전에 원시 본문 바이트를 캡처하십시오.

잘못된 시크릿 사용

콜백 검증에는 **brand.api_secret**을 사용합니다. Ruby의 Team API를 호출할 때 사용하는 team_api_secret이 아닙니다. 이 두 자격 증명은 서로 다릅니다.

잘못된 연결 순서

HMAC 입력은 body_bytes + timestamp_bytes입니다 -- 본문이 먼저이고 타임스탬프가 끝에 추가됩니다. Team API 서명은 타임스탬프를 먼저 배치합니다. 두 가지를 혼동하지 마십시오.

상수 시간 비교 대신 문자열 비교 사용

최종 비교에는 hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), 또는 MessageDigest.isEqual (Java)을 사용하십시오. 일반 == 검사는 타이밍 공격에 취약합니다.

타임스탬프 검증 누락

타임스탬프가 현재 서버 시간 기준 300초 이내인지 항상 확인하십시오. 이 검사가 없으면 유효한 서명이 무기한으로 재생될 수 있습니다.