/** * lib/serviceauth.mjs — 서비스 간 호출 인증(포털 ↔ HAL, HAL ↔ Agent 공통). * * 요청 헤더 * X-Service-Key-Id : 키 식별자(예: 'portal', 'hal', 'agent:') * X-Service-Ts : 요청 시각(ms). ±5분 밖이면 거부(재전송 공격 완화) * X-Service-Sign : HMAC-SHA256(secret, `${method}|${path}|${ts}|${sha256(body)}`) base64url * 비밀은 양쪽 보관소(lib/vault)에 'service:' 로 둔다. 검증은 상수 시간 비교. */ 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; }