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"
}
| 칸 | 뜻 | 받는 쪽이 할 일 |
|---|---|---|
licenseHash | sha256(앞뒤 공백을 뺀 라이센스 키) 의 16진수 64자. 키 없이 받으면 null | 내가 보낸 키로 같은 값을 만들어 견줌 — 다르면 남의 답(다른 회사의 licensed:true 를 돌려씀) |
expiresAt | 서명한 때 + 24시간 | 지났으면 버림 — 묵혀 둔 답을 다시 내미는 것을 막음 |
product | 물은 제품 코드(?product=) | 내가 물은 제품과 다르면 버림 — 다른 제품의 답(같은 서명 키)을 내미는 것을 막음 |
signature | 서명. catalog 를 정렬 키 JSON(canonical)으로 만든 바이트에 Ed25519 | 코드에 넣은 공개키로만 확인 (센터의 /api/public/modules/keys · 환경변수의 키를 그대로 믿지 않음) |
- 세 칸은 서명 안에 들어 있어 바꾸면 서명이 깨집니다.
licenseHash·expiresAt이 없는 답은 거절합니다 — 1.33.0 이전 모양이라 어느 키에도 묶이지 않고 기한도 없습니다(같은 서명 키라 서명은 맞음).- 답 머리
Cache-Control: no-store·Vary: X-License-Key— 중간 캐시가 다른 회사 답을 돌려주지 않게. - 공개키는 모듈 센터 → 설정 → 서명 키 → 「trusted-keys.json 받기」. 키를 새로 만들어도 옛 키는 목록에 남습니다(그만 쓴 키도 확인용으로 남음) — 받는 쪽은 새 키를 더하기만 하고 옛 키는 지우지 마세요.
1-2. 1.59.0 에서 더한 것 — 설치본마다 켜고 끄기
센터의 「패키지 → 연동 설정」에서 라이센스·설치본마다 패키지를 켜고 끕니다. 받는 쪽은 두 가지만 하면 됩니다.
- 카탈로그를 받을 때 주소에
&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` 가 보낸 값과 같은지도 보면 좋습니다(서명 안에 있음). - 패키지마다
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 getCatalog | 62줄 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 assertConnectable | 79-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. 확인하는 법
- 모듈 센터 「패키지」에서
nahonza-work에 외부 프로그램 하나(라이센스 조건) + 「라이센스 허용」에 시험 키를 넣음 - 나혼자워크
.env에 위 두 줄,PACKAGE_CENTER_KEYS에 공개키 → 설정 → 패키지 센터: 출처 「모듈 센터 (서명 확인)」, 그 프로그램이 「사용 가능」 LICENSE_KEY를 다른 값으로 → 같은 프로그램이 「라이센스 필요」 (센터가 그 키로 새로 서명한 답 — licensed:false)PACKAGE_CENTER_KEYS를 다른 키로 → 서버 기록[package-center] 카탈로그 거절 — 서명이 맞지 않음, 화면은 마지막으로 확인된 목록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. 지킬 것
- 공개키는 코드에 — 환경변수·센터가 준 목록으로 바꿀 수 있으면 위조 서버로 돌려 막을 수 없음
- 라이센스 키를 바꾸면 다시 받기(이전 키의 답은
licenseHash가 달라 거절됨) - 확인 실패를 「모두 허용」으로 바꾸지 않기 — 무료 항목은 써도 되지만 라이센스 항목은 잠금
- 센터 시계와 몇 분 차이는 괜찮음(기한 24시간). 받는 쪽 시계가 하루 넘게 틀리면 거절됨 — 서버 시간 동기화(NTP) 확인
- 명세: 02_모듈사이트_구축.md §10-2