50 lines
3.4 KiB
JavaScript
50 lines
3.4 KiB
JavaScript
/**
|
|||
|
|
* lib/serviceauth.mjs — 서비스 간 호출 인증(포털 ↔ HAL, HAL ↔ Agent 공통).
|
||
|
|
*
|
||
|
|
* 요청 헤더
|
||
|
|
* X-Service-Key-Id : 키 식별자(예: 'portal', 'hal', 'agent:<agentId>')
|
||
|
|
* X-Service-Ts : 요청 시각(ms). ±5분 밖이면 거부(재전송 공격 완화)
|
||
|
|
* X-Service-Sign : HMAC-SHA256(secret, `${method}|${path}|${ts}|${sha256(body)}`) base64url
|
||
|
|
* 비밀은 양쪽 보관소(lib/vault)에 'service:<keyId>' 로 둔다. 검증은 상수 시간 비교.
|
||
|
|
*/
|
||
|
|
import { hmac, sha256, safeEqual } from './crypto.mjs';
|
||
|
|
|
||
|
|
const SKEW_MS = 5 * 60 * 1000;
|
||
|
|
/** 서명 문자열 */
|
||
|
|
export const signingString = (method, path, ts, body) => `${String(method).toUpperCase()}|${path}|${ts}|${sha256(body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body))}`;
|
||
|
|
/** 보낼 요청의 헤더 만들기 */
|
||
|
|
export function signHeaders(keyId, secret, method, path, body) { const ts = String(Date.now()); return { 'X-Service-Key-Id': keyId, 'X-Service-Ts': ts, 'X-Service-Sign': hmac(secret, signingString(method, path, ts, body)), 'Content-Type': 'application/json' }; }
|
||
|
|
/**
|
||
|
|
* 받은 요청 검증. secretOf(keyId) → secret | null. rawBody 는 본문 원문(문자열).
|
||
|
|
* @returns {{ok:boolean, keyId?:string, reason?:string}}
|
||
|
|
*/
|
||
|
|
export function verifyRequest({ method, path, headers, rawBody }, secretOf) {
|
||
|
|
const keyId = String(headers['x-service-key-id'] || ''); const ts = Number(headers['x-service-ts'] || 0); const sign = String(headers['x-service-sign'] || '');
|
||
|
|
if (!keyId || !ts || !sign) return { ok: false, reason: 'missing_headers' };
|
||
|
|
if (Math.abs(Date.now() - ts) > SKEW_MS) return { ok: false, reason: 'clock_skew' };
|
||
|
|
const secret = secretOf(keyId); if (!secret) return { ok: false, reason: 'unknown_key' };
|
||
|
|
return safeEqual(hmac(secret, signingString(method, path, String(ts), rawBody || '')), sign) ? { ok: true, keyId } : { ok: false, reason: 'bad_signature' };
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 서명된 fetch. 응답은 { status, json }. 네트워크 오류는 code='service_unreachable' 로 던진다(호출자가 '정보 확인 지연' 표시).
|
||
|
|
*/
|
||
|
|
export async function serviceFetch(baseUrl, keyId, secret, method, path, body, { timeoutMs = 20000, fetchImpl = fetch } = {}) {
|
||
|
|
const text = body == null ? undefined : JSON.stringify(body);
|
||
|
|
let r; try { r = await fetchImpl(baseUrl.replace(/\/$/, '') + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); }
|
||
|
|
catch (e) { const err = new Error(`서비스에 연결할 수 없습니다 (${path})`); err.code = 'service_unreachable'; err.cause = e; throw err; }
|
||
|
|
let json = null; try { json = await r.json(); } catch (_) {}
|
||
|
|
return { status: r.status, json, ok: r.ok && !(json && json.ok === false) };
|
||
|
|
}
|
||
|
|
/** lib/http ctx 용: 본문 원문을 읽어 검증한다(본문은 ctx.body() 가 다시 쓸 수 있게 캐시). */
|
||
|
|
export async function verifyCtx(ctx, secretOf) {
|
||
|
|
const raw = await readRaw(ctx);
|
||
|
|
return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: raw }, secretOf);
|
||
|
|
}
|
||
|
|
async function readRaw(ctx) {
|
||
|
|
if (ctx._raw !== undefined) return ctx._raw;
|
||
|
|
const chunks = []; for await (const c of ctx.req) chunks.push(c); ctx._raw = Buffer.concat(chunks).toString('utf8');
|
||
|
|
const parsed = ctx._raw ? JSON.parse(ctx._raw) : {};
|
||
|
|
ctx.body = async () => parsed; // 이후 핸들러는 캐시된 본문을 본다
|
||
|
|
return ctx._raw;
|
||
|
|
}
|