필수 요청 헤더
/v1/health를 제외한 모든 요청은 다음 네 가지 헤더를 포함해야 해요. 하나라도 누락되거나 유효하지 않으면 서버가 HTTP 401로 거부해요.
| Header | Format | 의미 |
|---|---|---|
KH-Key | kh_live_[A-Z0-9]{32} | 공개 키 식별자. 시크릿이 아니므로 로그에 노출돼도 무방해요. |
KH-Timestamp | Unix 초 단위 (10자리) | 현재 타임스탬프. 허용 범위 +-300초. |
KH-Nonce | base64url 22-44자 | 요청마다 한 번만 사용되는 값. 600초간 캐시된 뒤 다시 사용하실 수 있어요. |
KH-Signature | 64 hex | HMAC-SHA256(secret, signing_string), 16진수 인코딩. |
서명 문자열 구성
서명 대상 문자열은 다섯 개 구성 요소를 줄바꿈(\n)으로 결합해 만들어요.
signing_string = METHOD + "\n"
+ PATH + "\n"
+ TIMESTAMP + "\n"
+ NONCE + "\n"
+ SHA256_HEX(BODY)
signature = HEX( HMAC_SHA256(secret, signing_string) )
PATH는 /v1부터 시작하는 경로로, 예를 들어 /cp/kernelhost_api/v1/orders가 아니라 /v1/orders입니다. 요청에 쿼리 문자열이 있으면 쿼리 문자열도 PATH에 포함됩니다(예: /v1/services?limit=10&offset=0). BODY는 원시 요청 본문이며, 본문이 없는 요청에서는 BODY가 빈 문자열이고 그 SHA256 값은 잘 알려진 상수 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855입니다.
레퍼런스 구현
PHP:
$method = 'POST';
$path = '/v1/orders';
$body = json_encode(['product_id' => 42, 'billing_cycle' => 'monthly', 'hostname' => 'web01.example.com']);
$idem = 'order-web01-' . date('Ymd');
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$signing = "{$method}\n{$path}\n{$ts}\n{$nonce}\n" . hash('sha256', $body);
$sig = hash_hmac('sha256', $signing, getenv('KH_SECRET'));
$ch = curl_init('https://www.kernelhost.com/cp/kernelhost_api/v1/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'KH-Key: ' . getenv('KH_KEY'),
"KH-Timestamp: {$ts}",
"KH-Nonce: {$nonce}",
"KH-Signature: {$sig}",
"Idempotency-Key: {$idem}",
],
]);
$res = curl_exec($ch);
Node.js:
import crypto from 'node:crypto';
const method = 'POST';
const path = '/v1/orders';
const body = JSON.stringify({ product_id: 42, billing_cycle: 'monthly', hostname: 'web01.example.com' });
const idem = 'order-web01-' + new Date().toISOString().slice(0, 10).replaceAll('-', '');
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(16).toString('hex');
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const signing = `${method}\n${path}\n${ts}\n${nonce}\n${bodyHash}`;
const sig = crypto.createHmac('sha256', process.env.KH_SECRET).update(signing).digest('hex');
const res = await fetch('https://www.kernelhost.com/cp/kernelhost_api/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'KH-Key': process.env.KH_KEY,
'KH-Timestamp': ts,
'KH-Nonce': nonce,
'KH-Signature': sig,
'Idempotency-Key': idem,
},
body,
});
재전송 방어
성공적으로 서명된 요청은 절대 재사용하실 수 없어요. 서버는 모든 논스를 600초간 데이터베이스에 저장하며, 동일한 논스로 들어온 두 번째 요청은 replay_detected 사유로 거부돼요. 마찬가지로 타임스탬프가 서버 시간에서 300초 이상 벗어난 요청도 거부돼요.
권한 범위 모델
키마다 명시적인 권한 범위 목록을 가져요. 각 라우트는 필요한 권한 범위를 검사하며, 부족하면 HTTP 403 forbidden_scope가 반환돼요. 쓰기 및 민감한 권한 범위는 키 생성 시 명시적으로 활성화해야 해요.
read:products,read:orders,read:services,read:billing,read:webhooksread:credentials(서비스 자격증명 조회(호스트 이름, IP 주소, 사용자 이름, 비밀번호). 호출마다 credentials.read 감사 항목이 추가로 생성됩니다.)write:orders(주문 처리 및 결제.)write:services(서비스 액션 실행(시작, 정지, 재부팅, 결제 기간 종료 시점의 해지와 해지 철회).)write:webhooks(웹훅 URL 설정.)

