← 모듈 센터

05. 패키지 목록 확인 붙이기 (받는 쪽 — 나혼자워크 등)

쉬운 요약 — 모듈 센터가 내주는 패키지 목록(카탈로그) 은 서명돼 있습니다. 받는 쪽은 서명 · 라이센스 짝 · 기한 · 제품 네 가지를 확인하고, 하나라도 어긋나면 그 답을 버리고 마지막으로 확인된 목록을 씁니다. 이 문서는 나혼자워크에 붙이는 실제 고칠 곳과 코드입니다.

1. 1.33.1 에서 바뀐 것

GET /api/public/packages/catalog?product=nahonza-work (요청 머리 X-License-Key: <라이센스 키>) 의 답:

{
  "catalog": {
    "typ": "isolo.package-catalog/1",
    "product": "nahonza-work",
    "generatedAt": "2026-10-15T03:00:00.000Z",
    "expiresAt":   "2026-10-16T03:00:00.000Z",
    "licenseHash": "9f2c…(64자)",
    "packages": [
      { "key": "estimates", "name": "견적서", "tier": "free", "licenseRequired": false, "licensed": true },
      { "type": "external", "appKey": "site", "name": "현장관리", "baseUrl": "https://site.example.com", "tier": "licensed", "licenseRequired": true, "licensed": true }
    ]
  },
  "signature": "<base64 — Ed25519(canonical(catalog))>",
  "keyId": "1a2b3c4d5e6f7a8b"
}
칸뜻받는 쪽이 할 일
licenseHashsha256(앞뒤 공백을 뺀 라이센스 키) 의 16진수 64자. 키 없이 받으면 null내가 보낸 키로 같은 값을 만들어 견줌 — 다르면 남의 답(다른 회사의 licensed:true 를 돌려씀)
expiresAt서명한 때 + 24시간지났으면 버림 — 묵혀 둔 답을 다시 내미는 것을 막음
product물은 제품 코드(?product=)내가 물은 제품과 다르면 버림 — 다른 제품의 답(같은 서명 키)을 내미는 것을 막음
signature서명. catalog 를 정렬 키 JSON(canonical)으로 만든 바이트에 Ed25519코드에 넣은 공개키로만 확인 (센터의 /api/public/modules/keys · 환경변수의 키를 그대로 믿지 않음)

1-2. 1.59.0 에서 더한 것 — 설치본마다 켜고 끄기

센터의 「패키지 → 연동 설정」에서 라이센스·설치본마다 패키지를 켜고 끕니다. 받는 쪽은 두 가지만 하면 됩니다.

  1. 카탈로그를 받을 때 주소에 &scope=<설치 해시> 를 붙입니다 — 설치 현황 보고(POST /api/connect/package-installs)에 쓰는 것과 같은 값(sha256(product:회사id) 앞 32자). 안 붙이면 라이센스마다만 켜고 끕니다. `` GET /api/public/packages/catalog?product=nahonza-work&appVersion=1.9.2&scope=3f9a…(32자) ` 답의 catalog.scope` 가 보낸 값과 같은지도 보면 좋습니다(서명 안에 있음).
  2. 패키지마다 enabled 를 봅니다. false 면 이 설치에서 쓰지 않습니다(센터에서 끔 · 숨김 · 내림). 끈 것은 hidden: true 도 붙어 있어서, 지금처럼 !p.hidden && !p.withdrawn 만 보는 코드도 그대로 숨깁니다.
칸뜻
enabled이 라이센스·설치에서 써도 됨. licensed 와 따로 — 켜져 있어도 라이센스 패키지는 licensed:true 여야 씀
switchedBy센터에서 켜거나 끈 대상 — install · license (없으면 공개 상태대로)
catalog.scope?scope= 로 보낸 설치 해시 (안 보냈으면 칸 없음)

센터 화면에는 설치본마다 마지막으로 받아 간 때·앱 판·들고 온 라이센스(앞뒤 4자)가 보이고, 패키지마다 쓰는 모듈의 판 → 최신(업데이트 필요)이 보입니다.

2. 나혼자워크 — 고칠 곳

저장소 alone_boss (2026-10-15 확인, 커밋 4b01dae):

파일:줄지금문제
server/src/controllers/integration.controller.ts:56-72 getCatalog62줄 fetch(env.LICENSE_CATALOG_URL, { headers: { 'X-License-Key': env.LICENSE_KEY } }) → 63줄 j.programs 를 그대로 씀서명을 안 봄 · 센터 답 모양(catalog.packages)을 못 읽어 늘 샘플로 떨어짐
server/src/controllers/integration.controller.ts:75-87 assertConnectable79-80줄 같은 받기 → 84줄 licensed 로 연결 허락위조·남의 답·묵힌 답의 licensed:true 로 라이센스 프로그램이 열림
client/src/components/settings/PackageCenter.tsx:252출처 글 source === 'license'(선택) license-cached·none 글 더하기

.env 는 그대로 두고 주소만 센터로:

LICENSE_CATALOG_URL=https://<모듈 센터>/api/public/packages/catalog?product=nahonza-work
LICENSE_KEY=<이 설치의 라이센스 키>

2-1. 새 파일 server/src/services/package-catalog.service.ts

공개키는 코드에 넣습니다(PACKAGE_CENTER_KEYS). 빈 채로 두면 모든 답을 거절합니다(더 막는 쪽).

/**
 * 모듈 센터 패키지 카탈로그 받기 + 확인 (모듈 센터 1.33.1 의 서명 모양)
 *  · 답: { catalog: { typ, product, generatedAt, expiresAt, licenseHash, packages }, signature, keyId }
 *  · 서명: Ed25519, 정렬 키 JSON(canonical) — 코드에 넣은 공개키로만 확인 (센터가 주는 키·환경변수는 믿지 않음)
 *  · licenseHash = sha256(앞뒤 공백을 뺀 라이센스 키) — 다른 회사 답을 돌려쓰지 못하게
 *  · expiresAt — 지난 답은 거절 (묵힌 licensed:true 를 다시 내밀지 못하게)
 *  · licenseHash · expiresAt 이 없는 답(1.33.0 이전 모양)은 거절 · product 가 물은 제품과 다르면 거절
 *  · 확인에 실패하면 마지막으로 확인된 목록을 그대로 씀 (기한이 지났으면 라이센스 항목은 잠금)
 */
import { createHash, createPublicKey, verify as edVerify, timingSafeEqual } from 'crypto';

// 모듈 센터 → 설정 → 서명 키 → 「trusted-keys.json 받기」의 keys 값을 그대로 (새 키를 만들면 여기에 더함 — 옛 키는 지우지 않음)
const PACKAGE_CENTER_KEYS: readonly string[] = [
  // 'base64 공개키 (32바이트)',
];
const TYP = 'isolo.package-catalog/1';
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); // Ed25519 공개키 머리

export interface CenterProgram {
  appKey: string; name: string; description?: string; icon?: string; baseUrl: string; category?: string;
  tier: 'free' | 'licensed'; licenseRequired: boolean; licensed: boolean; link?: string;
}

// 센터와 바이트까지 같은 정렬 키 JSON (src/sign.js canonical 과 같음)
function canonical(v: unknown): string {
  if (v === null || typeof v === 'string' || typeof v === 'boolean') return JSON.stringify(v);
  if (typeof v === 'number') { if (!Number.isFinite(v)) throw new TypeError('number'); return JSON.stringify(v); }
  if (Array.isArray(v)) return '[' + v.map((x) => (x === undefined ? 'null' : canonical(x))).join(',') + ']';
  if (typeof v === 'object') {
    const o = v as Record<string, unknown>;
    return '{' + Object.keys(o).filter((k) => o[k] !== undefined).sort().map((k) => JSON.stringify(k) + ':' + canonical(o[k])).join(',') + '}';
  }
  throw new TypeError(typeof v);
}

/** 서명 · 라이센스 짝 · 기한 · 제품 — 하나라도 어긋나면 이유 글, 맞으면 null. product: 물은 제품 코드(?product=) */
export function checkCatalog(body: any, licenseKey: string, product: string, keys: readonly string[] = PACKAGE_CENTER_KEYS, nowMs = Date.now()): string | null {
  const cat = body?.catalog;
  if (!keys.length) return '내장 공개키 없음 (PACKAGE_CENTER_KEYS)';
  if (!cat || cat.typ !== TYP || typeof body.signature !== 'string') return '서명된 카탈로그 모양이 아님';
  let data: Buffer, sig: Buffer;
  try { data = Buffer.from(canonical(cat)); sig = Buffer.from(body.signature, 'base64'); } catch { return '서명 값을 읽지 못함'; }
  const ok = keys.some((b64) => {
    try { return edVerify(null, data, createPublicKey({ key: Buffer.concat([SPKI_PREFIX, Buffer.from(b64, 'base64')]), format: 'der', type: 'spki' }), sig); }
    catch { return false; }
  });
  if (!ok) return '서명이 맞지 않음';
  if (!('licenseHash' in cat) || !('expiresAt' in cat)) return '예전 모양의 답 (licenseHash·expiresAt 없음)';
  const key = String(licenseKey || '').trim();
  const want = key ? createHash('sha256').update(key).digest('hex') : null;
  if (want === null || cat.licenseHash === null) { if (want !== cat.licenseHash) return '다른 라이센스의 답'; }
  else if (typeof cat.licenseHash !== 'string' || cat.licenseHash.length !== want.length || !timingSafeEqual(Buffer.from(cat.licenseHash), Buffer.from(want))) return '다른 라이센스의 답';
  const exp = typeof cat.expiresAt === 'string' ? Date.parse(cat.expiresAt) : NaN;
  if (!Number.isFinite(exp) || nowMs > exp) return '기한이 지난 답';
  if (!product || cat.product !== product) return '다른 제품의 답';
  return null;
}

// 외부 프로그램만 (기능 패키지는 앱 안 메뉴라 연동 목록에 넣지 않음) · 숨김·내림은 뺌
function programsOf(cat: any): CenterProgram[] {
  return (Array.isArray(cat?.packages) ? cat.packages : [])
    .filter((p: any) => p && p.type === 'external' && !p.hidden && !p.withdrawn && typeof p.appKey === 'string')
    .map((p: any) => ({
      appKey: p.appKey, name: String(p.name || p.appKey), description: p.summary, icon: p.icon, baseUrl: String(p.baseUrl || ''),
      category: p.category, tier: p.tier === 'licensed' ? 'licensed' : 'free', licenseRequired: !!p.licenseRequired, licensed: p.licensed === true, link: p.link,
    }));
}

let lastGood: { programs: CenterProgram[]; expiresAt: number } | null = null;
// 짧은 기억 — 같은 주소·같은 키는 60초 동안 다시 묻지 않음 (센터 공개 주소는 IP 하나에 1분 60번까지, 화면을 열 때마다 부르면 금방 참)
const CACHE_MS = 60_000;
const recent = new Map<string, { at: number; programs: CenterProgram[] }>();

/** 센터에서 받아 확인 — 실패하면 마지막으로 확인된 목록(기한이 지났으면 라이센스 항목은 licensed:false) */
export async function loadCenterCatalog(url: string, licenseKey: string): Promise<{ programs: CenterProgram[]; source: 'license' | 'license-cached' | 'none'; error?: string }> {
  let product = '';
  try { product = new URL(url).searchParams.get('product') || ''; } catch { product = ''; }   // 주소의 ?product= 와 답의 product 가 같아야
  const ck = createHash('sha256').update(`${url}\n${String(licenseKey || '').trim()}`).digest('hex');   // 키 원문은 기억하지 않음
  const hit = recent.get(ck);
  if (hit && Date.now() - hit.at < CACHE_MS) return { programs: hit.programs, source: 'license' };
  let why = '';
  try {
    const ctl = new AbortController();
    const t = setTimeout(() => ctl.abort(), 8000);
    const r = await fetch(url, { headers: { 'X-License-Key': licenseKey }, signal: ctl.signal, redirect: 'error' }).finally(() => clearTimeout(t));
    if (!r.ok) why = `HTTP ${r.status}`;
    else {
      const body: any = await r.json();
      why = checkCatalog(body, licenseKey, product) || '';
      if (!why) {
        lastGood = { programs: programsOf(body.catalog), expiresAt: Date.parse(body.catalog.expiresAt) };
        recent.set(ck, { at: Date.now(), programs: lastGood.programs });
        if (recent.size > 50) recent.delete(recent.keys().next().value as string);
        return { programs: lastGood.programs, source: 'license' };
      }
    }
  } catch (e: any) { why = e?.name === 'AbortError' ? '시간 초과' : String(e?.message || e); }
  console.warn(`[package-center] 카탈로그 거절 — ${why}${lastGood ? ' (마지막으로 확인된 목록을 씀)' : ''}`);
  if (!lastGood) return { programs: [], source: 'none', error: why };
  const fresh = Date.now() <= lastGood.expiresAt;
  return { programs: fresh ? lastGood.programs : lastGood.programs.map((p) => (p.licenseRequired ? { ...p, licensed: false } : p)), source: 'license-cached', error: why };
}

2-2. integration.controller.ts 고치기

--- a/server/src/controllers/integration.controller.ts
+++ b/server/src/controllers/integration.controller.ts
@@ -9,6 +9,7 @@
 import { env } from '../config/env.js';
 import { AppError } from '../middleware/errorHandler.js';
 import { assertSafeUrl } from '../utils/net-guard.js';
+import { loadCenterCatalog } from '../services/package-catalog.service.js';
 
 async function orgOf(userId: string): Promise<string | null> {
   const u = await prisma.user.findUnique({ where: { id: userId }, select: { organizationId: true } });
@@ -58,10 +59,9 @@
     let catalog: any[] = SAMPLE_CATALOG;
     let source = 'sample';
     if (env.LICENSE_CATALOG_URL) {
-      try {
-        const r = await fetch(env.LICENSE_CATALOG_URL, { headers: { 'X-License-Key': env.LICENSE_KEY } });
-        if (r.ok) { const j: any = await r.json(); catalog = Array.isArray(j?.programs) ? j.programs : (Array.isArray(j) ? j : SAMPLE_CATALOG); source = 'license'; }
-      } catch { /* 실패 시 샘플 */ }
+      // 모듈 센터 패키지 카탈로그 — 서명·라이센스 짝·기한을 확인한 것만. 실패하면 마지막으로 확인된 목록(없으면 빈 목록, 샘플로 돌아가지 않음)
+      const c = await loadCenterCatalog(env.LICENSE_CATALOG_URL, env.LICENSE_KEY);
+      catalog = c.programs; source = c.source;
     }
     catalog = normalizeCatalog(catalog);
     const orgId = await orgOf(req.user!.userId);
@@ -75,10 +75,8 @@
 async function assertConnectable(appKey: string): Promise<void> {
   let catalog: any[] = SAMPLE_CATALOG;
   if (env.LICENSE_CATALOG_URL) {
-    try {
-      const r = await fetch(env.LICENSE_CATALOG_URL, { headers: { 'X-License-Key': env.LICENSE_KEY } });
-      if (r.ok) { const j: any = await r.json(); catalog = Array.isArray(j?.programs) ? j.programs : (Array.isArray(j) ? j : SAMPLE_CATALOG); }
-    } catch { /* 샘플 사용 */ }
+    // 확인된 목록으로만 판단 — 위조·남의 라이센스·묵힌 답의 licensed:true 로는 연결되지 않음
+    catalog = (await loadCenterCatalog(env.LICENSE_CATALOG_URL, env.LICENSE_KEY)).programs;
   }
   const prog = normalizeCatalog(catalog).find((p) => p.appKey === appKey);
   if (prog && prog.licenseRequired && !prog.licensed) {

2-3. (선택) 화면의 출처 글 — PackageCenter.tsx:252

카탈로그 출처: <span className="font-mono">{source === 'license' ? '모듈 센터 (서명 확인)' : source === 'license-cached' ? '모듈 센터 (마지막으로 확인된 목록)' : source === 'none' ? '모듈 센터 (확인 실패 — 비어 있음)' : '샘플(라이센스 미설정)'}</span>

2-4. 확인하는 법

  1. 모듈 센터 「패키지」에서 nahonza-work 에 외부 프로그램 하나(라이센스 조건) + 「라이센스 허용」에 시험 키를 넣음
  2. 나혼자워크 .env 에 위 두 줄, PACKAGE_CENTER_KEYS 에 공개키 → 설정 → 패키지 센터: 출처 「모듈 센터 (서명 확인)」, 그 프로그램이 「사용 가능」
  3. LICENSE_KEY 를 다른 값으로 → 같은 프로그램이 「라이센스 필요」 (센터가 그 키로 새로 서명한 답 — licensed:false)
  4. PACKAGE_CENTER_KEYS 를 다른 키로 → 서버 기록 [package-center] 카탈로그 거절 — 서명이 맞지 않음, 화면은 마지막으로 확인된 목록
  5. LICENSE_CATALOG_URL 의 ?product= 를 다른 제품으로 → 그 제품의 답이라도 다른 제품의 답 으로 거절(주소의 제품과 답의 product 가 같아야)

3. 다른 프로그램 — 연동 라이브러리로

모듈 센터 /clients/isolo-modules.js · /clients/isolo_modules.py 를 받아 복사합니다. 네 가지 확인이 다 들어 있습니다 — 제품은 product 를 넘겨야 봅니다(꼭 넘기세요).

Node

const { ModuleCenter } = require('./isolo-modules');   // "type": "module" 이면 .cjs 로 저장
const TRUSTED = ['<trusted-keys.json 의 keys 값>'];      // 코드에 넣음 (환경변수 금지)
let lastGood = null;

async function loadCatalog(licenseKey) {
  try {
    const r = await fetch(`${CENTER}/api/public/packages/catalog?product=nahonza-work`, { headers: { 'X-License-Key': licenseKey } });
    const body = await r.json();
    if (r.ok && ModuleCenter.verifyPackageCatalog(body, TRUSTED, { licenseKey, product: 'nahonza-work' })) return (lastGood = body.catalog);
    console.warn('[package-center] 카탈로그 거절 — 서명·라이센스·기한 중 하나가 맞지 않음');
  } catch (e) { console.warn('[package-center] 받기 실패', e.message); }
  return lastGood;   // 마지막으로 확인된 목록 (없으면 null — 라이센스 항목은 잠금)
}

Python (pip install cryptography)

import json, urllib.request
from isolo_modules import ModuleCenter

TRUSTED = ["<trusted-keys.json 의 keys 값>"]   # 코드에 넣음
last_good = None

def load_catalog(license_key: str):
    global last_good
    req = urllib.request.Request(f"{CENTER}/api/public/packages/catalog?product=isolo-barcode", headers={"X-License-Key": license_key})
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            body = json.load(r)
        if ModuleCenter.verify_package_catalog(body, TRUSTED, license_key=license_key, product="isolo-barcode"):
            last_good = body["catalog"]
            return last_good
        print("[package-center] 카탈로그 거절 — 서명·라이센스·기한 중 하나가 맞지 않음")
    except Exception as e:  # noqa: BLE001
        print("[package-center] 받기 실패", e)
    return last_good

4. 지킬 것