콜백 인증
Ruby가 귀사의 서버로 보내는 모든 콜백 요청에는 3개의 인증 헤더가 포함됩니다. 요청을 처리하기 전에 모든 수신 요청에서 이 헤더들을 반드시 검증해야 합니다.
Ruby가 전송하는 헤더
X-Aggregator-Key: {brand.api_key}
X-Aggregator-Timestamp: {unix_timestamp_seconds}
X-Aggregator-Signature: {hmac_sha256_hex_digest}
| 헤더 | 값 | 설명 |
|---|---|---|
X-Aggregator-Key | String | Ruby에 설정된 귀사 브랜드의 API 키 |
X-Aggregator-Timestamp | String (integer) | 요청이 서명된 시점의 Unix 타임스탬프 (초 단위) |
X-Aggregator-Signature | String (hex) | 요청을 인증하는 HMAC-SHA256 16진수 다이제스트 |
서명 알고리즘
Ruby는 다음과 같이 서명을 계산합니다:
HMAC-SHA256(
key: api_secret encoded as UTF-8 bytes,
data: raw_body_bytes + str(timestamp).encode("utf-8")
)
쉽게 설명하면:
- 원시(Raw) HTTP 요청 본문을 바이트로 가져옵니다 (파싱하거나 재직렬화하지 마십시오)
X-Aggregator-Timestamp의 타임스탬프 문자열을 UTF-8 바이트로 인코딩합니다- 연결합니다:
body_bytes + timestamp_bytes(본문이 먼저, 타임스탬프가 끝에 추가) api_secret을 키로 사용하여 HMAC-SHA256을 계산합니다- 서명은 결과의 소문자 16진수 다이제스트(Hex Digest)입니다
서명에 사용되는 본문 바이트는 네트워크를 통해 수신된 정확한 바이트입니다. 서명을 계산하기 전에 본문을 JSON 파싱하고 재직렬화하지 마십시오 -- 필드 순서, 공백 또는 인코딩의 변경은 다른 다이제스트를 생성하여 검증 실패를 유발합니다.
Team API 서명과의 비교
콜백 서명 알고리즘은 귀사가 Ruby의 엔드포인트를 호출할 때 사용하는 Team API 서명 알고리즘과 의도적으로 다릅니다. 두 가지를 혼동하지 마십시오.
| 측면 | Team API (귀사가 Ruby를 호출) | 콜백 (Ruby가 귀사를 호출) |
|---|---|---|
| HMAC 키 | team_api_secret | brand.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_sig를 X-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초 이내인지 항상 확인하십시오. 이 검사가 없으면 유효한 서명이 무기한으로 재생될 수 있습니다.