@rscc/common-core 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -23
- package/dist/crypto.cjs +116 -0
- package/dist/crypto.d.cts +26 -0
- package/dist/crypto.d.ts +26 -0
- package/dist/crypto.js +89 -0
- package/dist/index.cjs +997 -13
- package/dist/index.d.cts +620 -29
- package/dist/index.d.ts +620 -29
- package/dist/index.js +961 -12
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @rscc/common-core
|
|
2
2
|
|
|
3
|
-
RSCC 공통 코어 — `CommonResponse` 봉투
|
|
3
|
+
RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키), SSE 파서,
|
|
4
|
+
회복탄력성 유틸(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안 유틸(마스킹·로그 리댁션·
|
|
5
|
+
웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 한국 도메인 유틸.
|
|
6
|
+
**프레임워크 무관, 런타임 의존성 0.** 같은 라이브러리의 Java·Python 구현과 동일한 와이어 계약·공유
|
|
7
|
+
테스트 벡터로 시맨틱을 맞춘다.
|
|
4
8
|
|
|
5
9
|
## 설치
|
|
6
10
|
|
|
@@ -8,43 +12,107 @@ RSCC 공통 코어 — `CommonResponse` 봉투 타입, API 클라이언트 팩
|
|
|
8
12
|
npm i @rscc/common-core
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
- **Node >= 18** (전역 `fetch` / `Headers` / `ReadableStream` 전제)
|
|
15
|
+
- **Node >= 18** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
|
|
12
16
|
- ESM + CJS 듀얼 빌드, 타입 선언(`.d.ts` / `.d.cts`) 동봉, `sideEffects: false`
|
|
17
|
+
- 메인 엔트리 `@rscc/common-core` 는 브라우저 안전. Node 전용 기능은 서브패스 `@rscc/common-core/crypto` 로만 제공
|
|
13
18
|
|
|
14
|
-
##
|
|
19
|
+
## 모듈 한눈에
|
|
15
20
|
|
|
16
21
|
| 모듈 | 공개 API | 설명 |
|
|
17
22
|
|---|---|---|
|
|
18
|
-
| types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` | 응답
|
|
19
|
-
| apiClient | `createApiClient
|
|
20
|
-
| sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 —
|
|
23
|
+
| types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` · `FieldErrorDetail` · `ValidationErrorData` · `isValidationErrorData` | 응답 봉투 · 페이지(page 0 시작) · 결과 코드(값은 문자열) · 검증 오류 봉투 타입 가드 |
|
|
24
|
+
| apiClient | `createApiClient` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 |
|
|
25
|
+
| sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크·멀티바이트 경계 안전, 모르는 키는 skip |
|
|
21
26
|
| jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
|
|
22
|
-
| masking | `maskSecret`
|
|
27
|
+
| masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
|
|
28
|
+
| datetime | `toWireDateTime` · `toWireDate` · `parseWireDateTime` · `stripZone` | 오프셋 없는 LocalDateTime 와이어 형식 — `toISOString()`(Z) 의 9시간 스큐 차단 |
|
|
29
|
+
| chosung | `toChosung` · `isChosungQuery` | 한글 초성 변환·초성 질의 판별 (자동완성) |
|
|
30
|
+
| retry | `retry` · `isRetryableStatus` · `parseRetryAfterMs` · `RetryOptions` | 지수 백오프 + full jitter, 시간 예산, `Retry-After` 파싱 |
|
|
31
|
+
| cache | `createTtlCache` · `TtlCache<K,V>` · `TtlCacheOptions` | TTL 캐시 + single-flight(동일 키 로더 1회 공유), lazy expiry |
|
|
32
|
+
| circuitBreaker | `createCircuitBreaker` · `CircuitOpenError` · `CircuitBreaker` · `CircuitBreakerOptions` · `CircuitState` | 연속 실패 서킷 브레이커 — `execute(fn)` 권장, 취소는 무집계(`onIgnore`) |
|
|
33
|
+
| tokenBucket | `createTokenBucket` · `TokenBucket` · `TokenBucketOptions` | 토큰버킷 레이트리미터 (연속 리필, 대기 없이 즉시 판정) |
|
|
34
|
+
| bulkhead | `createBulkhead` · `BulkheadFullError` · `Bulkhead` · `BulkheadOptions` | 동시 실행 + 대기 슬롯 격벽, 만석 즉시 거부 |
|
|
35
|
+
| logSanitize | `sanitizeLogValue` | 로그 인젝션 방지 — 제어 문자 공백 치환 + 길이 절단 |
|
|
36
|
+
| bizno | `normalizeBusinessNumber` · `isValidBusinessNumber` · `isValidCorporateNumber` | 사업자(10)·법인(13)번호 체크섬 검증 |
|
|
37
|
+
| idempotency | `generateIdempotencyKey` | 멱등성 키 생성 (apiClient `idempotency` 옵션으로 자동 부착 가능) |
|
|
38
|
+
| webhook | `signWebhook` · `verifyWebhook` · `WEBHOOK_SIGNATURE_HEADER` | HMAC-SHA256 웹훅 서명/검증 (WebCrypto — async), 시크릿 로테이션 |
|
|
39
|
+
| bulk | `isBulkResult` · `bulkFailures` · `createBulkResultBuilder` · `BulkResult` · `BulkResultItem` · `BulkResultBuilder` | 벌크/부분 실패 봉투 판별·생성 |
|
|
40
|
+
| upload | `sniffFile` · `kindsForExtension` · `validateUpload` · `FileKind` · `UploadValidationResult` | 업로드 프리검증 — 크기·확장자·매직바이트 대조 |
|
|
41
|
+
| query | `buildListQuery` · `ListQueryOptions` · `SortParam` | 목록 조회 쿼리(page/size/sort/필터) 빌더 |
|
|
42
|
+
| featureFlags | `parseFlag` · `createFeatureFlags` · `FeatureFlagReader` | 피처 플래그 파싱 (참 집합 `true`/`1`/`on`/`yes`) |
|
|
43
|
+
| rrn | `normalizeRrn` · `isValidRrn` · `isForeignerRrn` · `rrnChecksumOkLegacy` · `rrnBirthDate` | 주민등록번호 형식·생년월일 검증 (기본 검증은 **체크섬 미포함**) |
|
|
44
|
+
| josa | `pickJosa` · `attachJosa` · `JosaPair` | 은/는·이/가 등 조사 자동 선택 |
|
|
45
|
+
| age | `ageMan` · `ageByYear` · `ageInsurance` | 만 나이·연 나이·보험 나이 (입력 `"YYYY-MM-DD"` 문자열) |
|
|
46
|
+
| phone | `normalizePhoneNumber` · `classifyPhoneNumber` · `formatPhoneNumber` · `toE164` · `PhoneType` | 전화번호 정규화·분류·포맷·E.164 변환 |
|
|
47
|
+
| money | `toKoreanWords` · `toFormalNotation` · `abbreviateAmount` | 금액 한글 수사 · 공문서 표기(`금…원整`) · UI 약식(`1.2억`) |
|
|
48
|
+
| businessDays | `createBusinessDays` · `BusinessDays` · `BusinessDaysOptions` | 영업일 계산 (공휴일 주입형 — 내장 공휴일 없음) |
|
|
49
|
+
| jamo | `decomposeHangul` · `composeHangul` · `matchesHangul` | 두벌식 자모 분해·조합·혼합 질의 매칭 |
|
|
50
|
+
| **crypto (서브패스)** | `encryptAesGcm` · `decryptAesGcm` · `CryptoError` — `@rscc/common-core/crypto` | AES-256/GCM (Java/Python 와이어 호환). **Node 전용** (`node:crypto`) |
|
|
23
51
|
|
|
24
52
|
## 사용 예시
|
|
25
53
|
|
|
26
54
|
### createApiClient — CommonResponse 언랩 + traceId
|
|
27
55
|
|
|
28
56
|
```ts
|
|
29
|
-
import { createApiClient, ApiError } from "@rscc/common-core";
|
|
57
|
+
import { createApiClient, ApiError, isValidationErrorData } from "@rscc/common-core";
|
|
30
58
|
|
|
31
59
|
const api = createApiClient({
|
|
32
|
-
baseUrl: "https://api.example.com",
|
|
33
|
-
getToken: () =>
|
|
60
|
+
baseUrl: "https://api.example.com", // 끝 슬래시 유무 무관 — 경계 슬래시 자동 단일화
|
|
61
|
+
getToken: () => sessionStorage.getItem("token"), // 선택 — Authorization: Bearer 부착
|
|
34
62
|
onUnauthorized: () => { /* 로그아웃/리다이렉트 정책은 소비자가 결정 */ },
|
|
63
|
+
// 선택: retry: { retries: 3 }, idempotency: {}, strictJson: true, onRequest/onResponse/onError
|
|
35
64
|
});
|
|
36
65
|
|
|
37
|
-
// 성공 봉투 → data 언랩
|
|
66
|
+
// 성공 봉투 → data 언랩 (비봉투 JSON 은 그대로)
|
|
38
67
|
const user = await api.request<UserInfo>("/api/v1/users/me");
|
|
39
68
|
|
|
40
|
-
//
|
|
69
|
+
// 문자열 body → Content-Type: application/json 자동 부착
|
|
70
|
+
await api.request("/api/v1/items", { method: "POST", body: JSON.stringify(item) });
|
|
71
|
+
|
|
72
|
+
// 실패 봉투/HTTP 에러 → ApiError { code, message, status, traceId, data, retryAfterMs }
|
|
41
73
|
try {
|
|
42
74
|
await api.request("/api/v1/things/999");
|
|
43
75
|
} catch (e) {
|
|
44
|
-
if (e instanceof ApiError)
|
|
76
|
+
if (e instanceof ApiError) {
|
|
77
|
+
console.error(`[${e.traceId}] ${e.code}: ${e.message}`);
|
|
78
|
+
if (isValidationErrorData(e.data)) { /* e.data.errors — 필드별 검증 오류 */ }
|
|
79
|
+
}
|
|
45
80
|
}
|
|
81
|
+
|
|
82
|
+
// traceId·status·원본 Response 가 필요하면
|
|
83
|
+
const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1/users/me");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **Content-Type**: 호출자가 지정하지 않았고 body 가 **문자열**일 때만 `application/json` 을 붙인다.
|
|
87
|
+
body 없는 요청(GET 등)엔 붙이지 않으므로 교차 출처 GET 이 CORS preflight 를 유발하지 않는다.
|
|
88
|
+
`URLSearchParams`/`Blob`/`FormData`/`ArrayBuffer`/`ReadableStream` 은 fetch 기본값을 따른다.
|
|
89
|
+
- **traceId**: 논리 호출당 1회 생성해 `X-Trace-Id` 로 부착(재시도 전 시도 동일), 응답 에코를 우선 노출.
|
|
90
|
+
- **retry** (opt-in): 멱등 메서드(GET/HEAD/OPTIONS/PUT/DELETE)만 기본 재시도, 408/429/5xx 일부·네트워크
|
|
91
|
+
오류 대상, `Retry-After` 를 대기 하한으로 존중. POST/PATCH 는 `methods` 로 명시 옵트인.
|
|
92
|
+
- **idempotency** (opt-in): POST/PATCH 에 `Idempotency-Key` 를 논리 호출당 1회 생성해 부착(호출자 헤더 우선).
|
|
93
|
+
- **strictJson** (opt-in): 2xx 의 비-JSON 본문을 null 대신 `INVALID_JSON` ApiError 로 실패(`rawText` 앞 2048자
|
|
94
|
+
보존). 빈 본문(204 등)은 계속 null.
|
|
95
|
+
- `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
|
|
96
|
+
재호출이 가능하다.
|
|
97
|
+
|
|
98
|
+
### 서킷 브레이커 — execute 권장
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { createCircuitBreaker, CircuitOpenError, retry } from "@rscc/common-core";
|
|
102
|
+
|
|
103
|
+
const cb = createCircuitBreaker({ failureThreshold: 5, openDurationMs: 30_000 });
|
|
104
|
+
|
|
105
|
+
// 게이트 → 실행 → 결과 보고를 정확히 1회 보장. OPEN 이면 fn 미호출 + CircuitOpenError
|
|
106
|
+
await retry(() => cb.execute(() => api.request("/api/v1/downstream")), {
|
|
107
|
+
shouldRetry: (e) => !(e instanceof CircuitOpenError), // OPEN 차단은 즉시 실패
|
|
108
|
+
});
|
|
46
109
|
```
|
|
47
110
|
|
|
111
|
+
- resolve → 성공, `name === "AbortError"` → 무집계(`onIgnore`), 그 외 throw → 실패로 집계 후 **같은 오류를 그대로** rethrow.
|
|
112
|
+
- 수동 게이트(`allowRequest()` + `onSuccess`/`onFailure`/`onIgnore` 중 정확히 1회)도 지원한다.
|
|
113
|
+
- 보고가 끝내 오지 않은 그랜트는 OPEN 진입 후 `2 × openDurationMs` 에 유실로 용서되어 OPEN 영구 고착을 막는다.
|
|
114
|
+
HALF_OPEN 프로브도 `openDurationMs` 동안 미보고면 유실로 간주해 새 프로브를 넘긴다(HALF_OPEN 고착 방지).
|
|
115
|
+
|
|
48
116
|
### SSE 스트림 소비
|
|
49
117
|
|
|
50
118
|
```ts
|
|
@@ -52,24 +120,38 @@ import { readSseStream } from "@rscc/common-core";
|
|
|
52
120
|
|
|
53
121
|
const res = await fetch(streamUrl, { method: "POST", body, signal });
|
|
54
122
|
await readSseStream(res, {
|
|
55
|
-
|
|
123
|
+
onConversationId: (id) => { /* 첫 프레임 */ },
|
|
56
124
|
onSources: (sources) => { /* RAG 근거 */ },
|
|
125
|
+
onDelta: (chunk) => { /* 텍스트 증분 */ },
|
|
57
126
|
onError: (message) => { /* in-band 오류 */ },
|
|
58
127
|
onDone: () => { /* data: [DONE] */ },
|
|
59
128
|
});
|
|
60
129
|
```
|
|
61
130
|
|
|
131
|
+
### AES-256/GCM — Node 전용 서브패스
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { encryptAesGcm, decryptAesGcm, CryptoError } from "@rscc/common-core/crypto";
|
|
135
|
+
|
|
136
|
+
const enc = encryptAesGcm("민감값", key); // Base64(IV(12B)∥암호문∥태그(16B)) — 매번 다름
|
|
137
|
+
const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
|
|
138
|
+
```
|
|
139
|
+
|
|
62
140
|
## 알려진 제약
|
|
63
141
|
|
|
64
|
-
- SSE 파서는 프레임 구분자
|
|
65
|
-
- `
|
|
66
|
-
-
|
|
67
|
-
-
|
|
142
|
+
- **SSE 파서는 프레임 구분자 LF(`\n\n`) 고정** — 개행을 CRLF 로 정규화하는 프록시 뒤에서는 프레임이 분리되지 않는다.
|
|
143
|
+
- 2xx 응답인데 본문이 JSON 이 아니면 `data` 는 기본적으로 조용히 null 이 된다 — 명시 실패가 필요하면 `strictJson: true`.
|
|
144
|
+
- 문자열 body 는 `application/json` 으로 간주된다 — JSON 이 아닌 문자열 본문은 Content-Type 을 직접 지정할 것.
|
|
145
|
+
- 교차 출처에서 traceId 에코를 읽으려면 서버가 `Access-Control-Expose-Headers: X-Trace-Id` 를 내려야 한다
|
|
146
|
+
(못 읽으면 발신 값을 노출 — 서버가 수신 traceId 를 재사용하므로 동일 값).
|
|
147
|
+
- 1회성 body(`ReadableStream`/`FormData`)는 재전송이 불가해 `retry` 를 지정해도 1회만 실행된다.
|
|
68
148
|
- JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
|
|
149
|
+
- `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
|
|
150
|
+
- `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
|
|
151
|
+
- 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
|
|
152
|
+
- `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
|
|
69
153
|
|
|
70
|
-
##
|
|
154
|
+
## 관련 패키지
|
|
71
155
|
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
- React 훅: [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
75
|
-
- 저장소: [Jeonghyeon-Ryu/r-common](https://github.com/Jeonghyeon-Ryu/r-common) · MIT
|
|
156
|
+
- React 훅(`useDebounce`, `useSse`): [@rscc/common-react](https://www.npmjs.com/package/@rscc/common-react)
|
|
157
|
+
- 라이선스: MIT
|
package/dist/crypto.cjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/crypto.ts
|
|
21
|
+
var crypto_exports = {};
|
|
22
|
+
__export(crypto_exports, {
|
|
23
|
+
CryptoError: () => CryptoError,
|
|
24
|
+
decryptAesGcm: () => decryptAesGcm,
|
|
25
|
+
encryptAesGcm: () => encryptAesGcm
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(crypto_exports);
|
|
28
|
+
var import_node_crypto = require("crypto");
|
|
29
|
+
var ALGORITHM = "aes-256-gcm";
|
|
30
|
+
var IV_LENGTH_BYTES = 12;
|
|
31
|
+
var TAG_LENGTH_BYTES = 16;
|
|
32
|
+
var KEY_LENGTH_BYTES = 32;
|
|
33
|
+
var JAVA_BLANK_PATTERN = /^[\t-\r\u001C-\u001F \u1680\u2000-\u2006\u2008-\u200A\u2028\u2029\u205F\u3000]*$/;
|
|
34
|
+
var BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
35
|
+
var utf8Encoder = new TextEncoder();
|
|
36
|
+
var utf8Decoder = new TextDecoder();
|
|
37
|
+
var CryptoError = class extends Error {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "CryptoError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function concatBytes(...parts) {
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (const part of parts) total += part.length;
|
|
46
|
+
const out = new Uint8Array(total);
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const part of parts) {
|
|
49
|
+
out.set(part, offset);
|
|
50
|
+
offset += part.length;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
function toBase64(bytes) {
|
|
55
|
+
let binary = "";
|
|
56
|
+
const CHUNK = 32768;
|
|
57
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
58
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
59
|
+
}
|
|
60
|
+
return btoa(binary);
|
|
61
|
+
}
|
|
62
|
+
function fromBase64(base64) {
|
|
63
|
+
if (base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
|
|
64
|
+
throw new TypeError("\uC554\uD638\uBB38\uC774 \uC62C\uBC14\uB978 Base64 \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
65
|
+
}
|
|
66
|
+
const binary = atob(base64);
|
|
67
|
+
const bytes = new Uint8Array(binary.length);
|
|
68
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
function deriveKey(key) {
|
|
72
|
+
if (key == null || JAVA_BLANK_PATTERN.test(key)) {
|
|
73
|
+
throw new TypeError("\uC554\uD638\uD654 \uD0A4\uB294 null \uB610\uB294 \uACF5\uBC31\uC77C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
74
|
+
}
|
|
75
|
+
const keyBytes = utf8Encoder.encode(key);
|
|
76
|
+
if (keyBytes.length !== KEY_LENGTH_BYTES) {
|
|
77
|
+
return (0, import_node_crypto.createHash)("sha256").update(keyBytes).digest();
|
|
78
|
+
}
|
|
79
|
+
return keyBytes;
|
|
80
|
+
}
|
|
81
|
+
function encryptAesGcm(plainText, key) {
|
|
82
|
+
if (plainText == null || JAVA_BLANK_PATTERN.test(plainText)) return plainText;
|
|
83
|
+
const keyBytes = deriveKey(key);
|
|
84
|
+
const iv = (0, import_node_crypto.randomBytes)(IV_LENGTH_BYTES);
|
|
85
|
+
const cipher = (0, import_node_crypto.createCipheriv)(ALGORITHM, keyBytes, iv);
|
|
86
|
+
const encrypted = concatBytes(cipher.update(utf8Encoder.encode(plainText)), cipher.final());
|
|
87
|
+
return toBase64(concatBytes(iv, encrypted, cipher.getAuthTag()));
|
|
88
|
+
}
|
|
89
|
+
function decryptAesGcm(cipherText, key) {
|
|
90
|
+
if (cipherText == null || JAVA_BLANK_PATTERN.test(cipherText)) return cipherText;
|
|
91
|
+
const keyBytes = deriveKey(key);
|
|
92
|
+
const combined = fromBase64(cipherText);
|
|
93
|
+
if (combined.length <= IV_LENGTH_BYTES) {
|
|
94
|
+
throw new TypeError("\uC554\uD638\uBB38 \uAE38\uC774\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. (IV \uD3EC\uD568 \uCD5C\uC18C \uAE38\uC774 \uBBF8\uB2EC)");
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const iv = combined.subarray(0, IV_LENGTH_BYTES);
|
|
98
|
+
const body = combined.subarray(IV_LENGTH_BYTES);
|
|
99
|
+
const data = body.subarray(0, Math.max(0, body.length - TAG_LENGTH_BYTES));
|
|
100
|
+
const tag = body.subarray(Math.max(0, body.length - TAG_LENGTH_BYTES));
|
|
101
|
+
const decipher = (0, import_node_crypto.createDecipheriv)(ALGORITHM, keyBytes, iv, {
|
|
102
|
+
authTagLength: TAG_LENGTH_BYTES
|
|
103
|
+
});
|
|
104
|
+
decipher.setAuthTag(tag);
|
|
105
|
+
const decrypted = concatBytes(decipher.update(data), decipher.final());
|
|
106
|
+
return utf8Decoder.decode(decrypted);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new CryptoError("\uB370\uC774\uD130 \uBCF5\uD638\uD654\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. (\uD0A4\uAC00 \uB2E4\uB974\uAC70\uB098 \uB370\uC774\uD130\uAC00 \uBCC0\uC870\uB418\uC5C8\uC2B5\uB2C8\uB2E4)");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
112
|
+
0 && (module.exports = {
|
|
113
|
+
CryptoError,
|
|
114
|
+
decryptAesGcm,
|
|
115
|
+
encryptAesGcm
|
|
116
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복호화 실패(키 상이·데이터 변조 — GCM 태그 불일치) 오류.
|
|
3
|
+
* Java `IllegalStateException` / Python `CryptoError(ValueError)` 에 대응.
|
|
4
|
+
*/
|
|
5
|
+
declare class CryptoError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 평문을 AES-256/GCM 으로 암호화한다 — contracts/crypto.md.
|
|
10
|
+
*
|
|
11
|
+
* @param plainText 암호화할 평문 (blank 면 그대로 반환 — Java 파리티).
|
|
12
|
+
* @param key 암호화 키 (blank 면 TypeError. 32바이트 정확 일치 시 raw, 그 외 SHA-256 유도).
|
|
13
|
+
* @returns Base64( IV(12B) ∥ 암호문 ∥ 태그(16B) ) — IV 가 랜덤이라 매번 다르다.
|
|
14
|
+
*/
|
|
15
|
+
declare function encryptAesGcm(plainText: string, key: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* `encryptAesGcm`(또는 Java `CryptoUtils.encrypt`) 산출물을 복호화한다.
|
|
18
|
+
*
|
|
19
|
+
* @param cipherText Base64 암호문 (blank 면 그대로 반환).
|
|
20
|
+
* @param key 암호화에 사용했던 키.
|
|
21
|
+
* @throws TypeError 키 blank / Base64 형식 오류 / 길이 미달(디코드 후 ≤ 12바이트).
|
|
22
|
+
* @throws CryptoError 키가 다르거나 데이터가 변조된 경우 (GCM 태그 불일치).
|
|
23
|
+
*/
|
|
24
|
+
declare function decryptAesGcm(cipherText: string, key: string): string;
|
|
25
|
+
|
|
26
|
+
export { CryptoError, decryptAesGcm, encryptAesGcm };
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복호화 실패(키 상이·데이터 변조 — GCM 태그 불일치) 오류.
|
|
3
|
+
* Java `IllegalStateException` / Python `CryptoError(ValueError)` 에 대응.
|
|
4
|
+
*/
|
|
5
|
+
declare class CryptoError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 평문을 AES-256/GCM 으로 암호화한다 — contracts/crypto.md.
|
|
10
|
+
*
|
|
11
|
+
* @param plainText 암호화할 평문 (blank 면 그대로 반환 — Java 파리티).
|
|
12
|
+
* @param key 암호화 키 (blank 면 TypeError. 32바이트 정확 일치 시 raw, 그 외 SHA-256 유도).
|
|
13
|
+
* @returns Base64( IV(12B) ∥ 암호문 ∥ 태그(16B) ) — IV 가 랜덤이라 매번 다르다.
|
|
14
|
+
*/
|
|
15
|
+
declare function encryptAesGcm(plainText: string, key: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* `encryptAesGcm`(또는 Java `CryptoUtils.encrypt`) 산출물을 복호화한다.
|
|
18
|
+
*
|
|
19
|
+
* @param cipherText Base64 암호문 (blank 면 그대로 반환).
|
|
20
|
+
* @param key 암호화에 사용했던 키.
|
|
21
|
+
* @throws TypeError 키 blank / Base64 형식 오류 / 길이 미달(디코드 후 ≤ 12바이트).
|
|
22
|
+
* @throws CryptoError 키가 다르거나 데이터가 변조된 경우 (GCM 태그 불일치).
|
|
23
|
+
*/
|
|
24
|
+
declare function decryptAesGcm(cipherText: string, key: string): string;
|
|
25
|
+
|
|
26
|
+
export { CryptoError, decryptAesGcm, encryptAesGcm };
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/crypto.ts
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
|
3
|
+
var ALGORITHM = "aes-256-gcm";
|
|
4
|
+
var IV_LENGTH_BYTES = 12;
|
|
5
|
+
var TAG_LENGTH_BYTES = 16;
|
|
6
|
+
var KEY_LENGTH_BYTES = 32;
|
|
7
|
+
var JAVA_BLANK_PATTERN = /^[\t-\r\u001C-\u001F \u1680\u2000-\u2006\u2008-\u200A\u2028\u2029\u205F\u3000]*$/;
|
|
8
|
+
var BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
9
|
+
var utf8Encoder = new TextEncoder();
|
|
10
|
+
var utf8Decoder = new TextDecoder();
|
|
11
|
+
var CryptoError = class extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CryptoError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function concatBytes(...parts) {
|
|
18
|
+
let total = 0;
|
|
19
|
+
for (const part of parts) total += part.length;
|
|
20
|
+
const out = new Uint8Array(total);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
out.set(part, offset);
|
|
24
|
+
offset += part.length;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function toBase64(bytes) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
const CHUNK = 32768;
|
|
31
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
32
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
33
|
+
}
|
|
34
|
+
return btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
function fromBase64(base64) {
|
|
37
|
+
if (base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
|
|
38
|
+
throw new TypeError("\uC554\uD638\uBB38\uC774 \uC62C\uBC14\uB978 Base64 \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
39
|
+
}
|
|
40
|
+
const binary = atob(base64);
|
|
41
|
+
const bytes = new Uint8Array(binary.length);
|
|
42
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
function deriveKey(key) {
|
|
46
|
+
if (key == null || JAVA_BLANK_PATTERN.test(key)) {
|
|
47
|
+
throw new TypeError("\uC554\uD638\uD654 \uD0A4\uB294 null \uB610\uB294 \uACF5\uBC31\uC77C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
48
|
+
}
|
|
49
|
+
const keyBytes = utf8Encoder.encode(key);
|
|
50
|
+
if (keyBytes.length !== KEY_LENGTH_BYTES) {
|
|
51
|
+
return createHash("sha256").update(keyBytes).digest();
|
|
52
|
+
}
|
|
53
|
+
return keyBytes;
|
|
54
|
+
}
|
|
55
|
+
function encryptAesGcm(plainText, key) {
|
|
56
|
+
if (plainText == null || JAVA_BLANK_PATTERN.test(plainText)) return plainText;
|
|
57
|
+
const keyBytes = deriveKey(key);
|
|
58
|
+
const iv = randomBytes(IV_LENGTH_BYTES);
|
|
59
|
+
const cipher = createCipheriv(ALGORITHM, keyBytes, iv);
|
|
60
|
+
const encrypted = concatBytes(cipher.update(utf8Encoder.encode(plainText)), cipher.final());
|
|
61
|
+
return toBase64(concatBytes(iv, encrypted, cipher.getAuthTag()));
|
|
62
|
+
}
|
|
63
|
+
function decryptAesGcm(cipherText, key) {
|
|
64
|
+
if (cipherText == null || JAVA_BLANK_PATTERN.test(cipherText)) return cipherText;
|
|
65
|
+
const keyBytes = deriveKey(key);
|
|
66
|
+
const combined = fromBase64(cipherText);
|
|
67
|
+
if (combined.length <= IV_LENGTH_BYTES) {
|
|
68
|
+
throw new TypeError("\uC554\uD638\uBB38 \uAE38\uC774\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. (IV \uD3EC\uD568 \uCD5C\uC18C \uAE38\uC774 \uBBF8\uB2EC)");
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const iv = combined.subarray(0, IV_LENGTH_BYTES);
|
|
72
|
+
const body = combined.subarray(IV_LENGTH_BYTES);
|
|
73
|
+
const data = body.subarray(0, Math.max(0, body.length - TAG_LENGTH_BYTES));
|
|
74
|
+
const tag = body.subarray(Math.max(0, body.length - TAG_LENGTH_BYTES));
|
|
75
|
+
const decipher = createDecipheriv(ALGORITHM, keyBytes, iv, {
|
|
76
|
+
authTagLength: TAG_LENGTH_BYTES
|
|
77
|
+
});
|
|
78
|
+
decipher.setAuthTag(tag);
|
|
79
|
+
const decrypted = concatBytes(decipher.update(data), decipher.final());
|
|
80
|
+
return utf8Decoder.decode(decrypted);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new CryptoError("\uB370\uC774\uD130 \uBCF5\uD638\uD654\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. (\uD0A4\uAC00 \uB2E4\uB974\uAC70\uB098 \uB370\uC774\uD130\uAC00 \uBCC0\uC870\uB418\uC5C8\uC2B5\uB2C8\uB2E4)");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
CryptoError,
|
|
87
|
+
decryptAesGcm,
|
|
88
|
+
encryptAesGcm
|
|
89
|
+
};
|