# Node.js KeyManager v1 호환성 수정 지침

**대상**: VS Code (node-service/packages/common)
**우선순위**: Phase 0 테스트 블로커 — 즉시 수정 필요
**날짜**: 2026-03-19

---

## 문제 요약

ADMIN 지갑 생성 시 `"Invalid initialization vector"` 에러 발생.
HD 지갑의 `master_seed_encrypted` 데이터가 **v1 Java**로 생성되어 DB에 저장되어 있으나,
v2 Node.js `KeyManager.decrypt()`는 다른 인코딩/키유도 방식을 사용하여 복호화 불가.

---

## v1 Java vs v2 Node.js 차이점 (2곳)

### 차이 1: AES 키 유도 방식

| 항목 | v1 Java | v2 Node.js |
|------|---------|-----------|
| 마스터 키 입력 | 임의 문자열 (예: `"my-secret-key"`) | 64자 hex 문자열 (256 bits) |
| 키 유도 | **SHA-256(masterKey.getBytes())** | **직접 사용** `Buffer.from(hex, 'hex')` |
| AES 키 결과 | SHA-256 해시 = 32바이트 | hex → 32바이트 |

```java
// v1 Java — getAESKey()
private byte[] getAESKey() {
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    return digest.digest(masterKey.getBytes());  // SHA-256 해시
}
```

```typescript
// v2 Node.js — getKeyBuffer()
private getKeyBuffer(): Buffer {
    const keyHex = config.encryption.masterKey;
    return Buffer.from(keyHex, 'hex');  // 직접 사용
}
```

### 차이 2: 암호화 출력 인코딩

| 항목 | v1 Java | v2 Node.js |
|------|---------|-----------|
| 바이너리 구조 | `iv(12) + ciphertext + authTag(16)` | `iv(12) + ciphertext + authTag(16)` |
| 인코딩 | **Base64** | **Hex** |

```java
// v1 Java — encrypt()
return Base64.getEncoder().encodeToString(combined);  // Base64 출력
```

```typescript
// v2 Node.js — encrypt()
return Buffer.concat([iv, encrypted, authTag]).toString('hex');  // Hex 출력
```

**참고**: Java의 `Cipher.doFinal()`은 `ciphertext + authTag`를 합쳐서 반환하므로,
바이너리 구조는 `iv(12) + ciphertext + authTag(16)`으로 동일함.

---

## 수정 방안: v1 Legacy 모드 지원

### 전략

- **신규 암호화**(`encrypt`)는 v2 hex 방식 유지 (향후 표준)
- **복호화**(`decrypt`)는 v1 base64 + SHA-256 키도 지원 (하위 호환)
- v1 마스터 키를 별도 환경변수 `ENCRYPTION_MASTER_KEY_V1`로 주입
- 인코딩 자동 감지: hex 문자열이면 v2 방식, 아니면 v1 base64 방식

---

## 수정 대상 파일

### 1. `config/index.ts` — v1 마스터 키 환경변수 추가

**경로**: `node-service/packages/common/src/config/index.ts`

```typescript
// 변경 전
encryption: {
    masterKey: optionalEnv('ENCRYPTION_MASTER_KEY', ''),
},

// 변경 후
encryption: {
    masterKey: optionalEnv('ENCRYPTION_MASTER_KEY', ''),
    masterKeyV1: optionalEnv('ENCRYPTION_MASTER_KEY_V1', ''),  // v1 Java 원본 마스터 키 (SHA-256 해시 전)
},
```

### 2. `KeyManager.ts` — v1 호환 복호화 추가

**경로**: `node-service/packages/common/src/crypto/KeyManager.ts`

#### 전체 교체 코드:

```typescript
/**
 * 키 관리 모듈 — AES-256-GCM 로컬 암복호화
 *
 * v2: ENCRYPTION_MASTER_KEY (64-char hex) → 직접 AES 키, hex 인코딩
 * v1 호환: ENCRYPTION_MASTER_KEY_V1 (임의 문자열) → SHA-256 해시 → AES 키, base64 인코딩
 */

import crypto from 'node:crypto';
import { config } from '../config/index.js';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;

/** hex 문자열인지 판별 (0-9, a-f, A-F만 포함) */
function isHexString(str: string): boolean {
  return /^[0-9a-fA-F]+$/.test(str);
}

export class KeyManager {

  /**
   * 암호화된 데이터 복호화
   * - v2 hex 인코딩 자동 감지 → v2 키로 복호화
   * - v1 base64 인코딩 자동 감지 → v1 SHA-256 키로 복호화
   */
  async decrypt(encrypted: string): Promise<string> {
    if (isHexString(encrypted)) {
      // v2 방식: hex 인코딩 + 직접 키
      return this.decryptV2(encrypted);
    } else {
      // v1 방식: base64 인코딩 + SHA-256 키
      return this.decryptV1(encrypted);
    }
  }

  /** v2 복호화: hex 인코딩, ENCRYPTION_MASTER_KEY 직접 사용 */
  private decryptV2(encryptedHex: string): string {
    const localKey = this.getKeyBufferV2();
    const data = Buffer.from(encryptedHex, 'hex');

    const iv = data.subarray(0, IV_LENGTH);
    const authTag = data.subarray(data.length - AUTH_TAG_LENGTH);
    const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH);

    const decipher = crypto.createDecipheriv(ALGORITHM, localKey, iv);
    decipher.setAuthTag(authTag);

    const decrypted = Buffer.concat([
      decipher.update(ciphertext),
      decipher.final(),
    ]);

    return decrypted.toString('utf8');
  }

  /** v1 복호화: base64 인코딩, SHA-256(masterKey) 키 유도 */
  private decryptV1(encryptedBase64: string): string {
    const localKey = this.getKeyBufferV1();
    const data = Buffer.from(encryptedBase64, 'base64');

    // Java Cipher.doFinal()은 ciphertext + authTag(16)을 합쳐서 반환
    // 따라서 구조: iv(12) + ciphertext + authTag(16) — v2와 동일
    const iv = data.subarray(0, IV_LENGTH);
    const authTag = data.subarray(data.length - AUTH_TAG_LENGTH);
    const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH);

    const decipher = crypto.createDecipheriv(ALGORITHM, localKey, iv);
    decipher.setAuthTag(authTag);

    const decrypted = Buffer.concat([
      decipher.update(ciphertext),
      decipher.final(),
    ]);

    return decrypted.toString('utf8');
  }

  /** v2 방식 암호화: hex 출력 (신규 데이터용) */
  async encrypt(plainText: string): Promise<string> {
    const localKey = this.getKeyBufferV2();
    const iv = crypto.randomBytes(IV_LENGTH);

    const cipher = crypto.createCipheriv(ALGORITHM, localKey, iv);
    const encrypted = Buffer.concat([
      cipher.update(plainText, 'utf8'),
      cipher.final(),
    ]);
    const authTag = cipher.getAuthTag();

    // iv + ciphertext + authTag → hex
    return Buffer.concat([iv, encrypted, authTag]).toString('hex');
  }

  /** v2 키: 64-char hex → 32바이트 직접 사용 */
  private getKeyBufferV2(): Buffer {
    const keyHex = config.encryption.masterKey;
    if (!keyHex || keyHex.length !== 64) {
      throw new Error('ENCRYPTION_MASTER_KEY must be a 64-character hex string (256 bits)');
    }
    return Buffer.from(keyHex, 'hex');
  }

  /** v1 키: 임의 문자열 → SHA-256 해시 → 32바이트 */
  private getKeyBufferV1(): Buffer {
    const masterKey = config.encryption.masterKeyV1;
    if (!masterKey) {
      throw new Error(
        'ENCRYPTION_MASTER_KEY_V1 is required to decrypt v1 legacy data. ' +
        'Set the original v1 master key string in .env'
      );
    }
    // Java: MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes())
    // Java getBytes() = UTF-8 (기본), Node.js도 UTF-8 사용
    return crypto.createHash('sha256').update(masterKey, 'utf8').digest();
  }
}

// 싱글톤
export const keyManager = new KeyManager();
```

### 3. `.env` — v1 마스터 키 추가

```bash
# 기존 v2 키 (신규 암호화용)
ENCRYPTION_MASTER_KEY=<64-char-hex-string>

# v1 원본 마스터 키 (v1 데이터 복호화용, SHA-256 해시 전 원본 문자열)
ENCRYPTION_MASTER_KEY_V1=<v1에서 사용하던 원본 masterKey 문자열>
```

**중요**: `ENCRYPTION_MASTER_KEY_V1`에는 v1 Spring Boot의 `encryption.master-key` 설정값을
**SHA-256 해시 전 원본 문자열 그대로** 넣어야 합니다.

---

## 빌드 & 검증

```bash
cd node-service

# 1. 빌드
pnpm build

# 2. .env에 ENCRYPTION_MASTER_KEY_V1 추가 확인

# 3. blockchain-api 재시작
# (pm2, 수동 등 기존 방식)

# 4. ADMIN 지갑 생성 테스트
curl -s -X POST http://localhost:3001/api/admin/wallet/create-admin \
  -H "Content-Type: application/json" \
  -d '{"networkId": 2, "hdWalletId": 2}'

# 기대 결과: {"addressId": ..., "address": "0x...", "networkId": 2, "derivationPath": "..."}
```

---

## 선택적 마이그레이션 (Phase 0 이후)

v1 base64 데이터를 v2 hex로 마이그레이션하면 v1 키를 제거할 수 있습니다.
다만 Phase 0 테스트에서는 호환 모드만으로 충분합니다.

```typescript
// 마이그레이션 스크립트 (나중에 필요 시)
async function migrateToV2Encoding(keyManager: KeyManager) {
  // 1. v1 base64 데이터 복호화 (decryptV1)
  // 2. v2 hex로 재암호화 (encrypt)
  // 3. DB UPDATE
}
```

---

## 추가 수정: `0x` 접두사 처리 (확인됨)

### 문제

v1 Java에서 `master_seed_encrypted`에 저장된 seed hex는 **`0x` 접두사 포함** 형식:
```
"0xc42a48154ddf9b60364901d491c2ed8b1ec6b9..." (130자 = 0x + 128자 hex)
```

v1 Java의 `Numeric.hexStringToByteArray()`는 `0x` 접두사를 자동 제거하지만,
Node.js의 `Buffer.from(str, 'hex')`는 **`0x`를 hex로 파싱하지 못해** seed가 깨짐.

### 수정 대상

**파일**: `node-service/packages/blockchain-api/src/routes/system-admin.ts`
**위치**: 76번째 줄

```typescript
// 변경 전
const seed = Buffer.from(await keyManager.decrypt(hdWallet.master_seed_encrypted), 'hex');

// 변경 후 — 0x 접두사 제거
const decryptedSeedHex = await keyManager.decrypt(hdWallet.master_seed_encrypted);
const seed = Buffer.from(decryptedSeedHex.replace(/^0x/, ''), 'hex');
```

### 유틸리티 함수화 (선택)

여러 곳에서 사용될 수 있으므로 공통 유틸리티로 분리 가능:

```typescript
// common/src/utils/hex.ts
export function hexToBuffer(hex: string): Buffer {
  return Buffer.from(hex.replace(/^0x/, ''), 'hex');
}
```

---

## 추가 수정: HD 파생 경로 — DB `derivation_base_path` 사용 (확인됨)

### 문제

v1에서 네트워크별로 **서로 다른 BIP-44 account index**를 사용했으나,
v2 `HdDerivation.derive()`는 **account=0으로 하드코딩**하고 있음.

| network | DB derivation_base_path | v2 코드 (하드코딩) |
|---------|------------------------|-------------------|
| ETH | `m/44'/60'/0'` | `m/44'/60'/0'/0/{index}` ✅ 우연히 일치 |
| BSC | `m/44'/60'/1'` | `m/44'/60'/0'/0/{index}` ❌ account 불일치 |
| Polygon | `m/44'/60'/3'` | `m/44'/60'/0'/0/{index}` ❌ account 불일치 |
| TRON | `m/44'/195'/2'` | `m/44'/195'/0'/0/{index}` ❌ account 불일치 |

현재 코드(`HdDerivation.ts` 44번째 줄):
```typescript
const path = `m/44'/${coinType}'/0'/0/${index}`;  // account=0 하드코딩
```

이 상태로 ADMIN 지갑을 생성하면 **v1과 완전히 다른 주소가 파생**됨.

### 수정 방안

`derive()` 메서드가 DB의 `derivation_base_path`를 받아서 사용하도록 변경.

### 수정 대상 1: `HdDerivation.ts`

**경로**: `node-service/packages/common/src/crypto/HdDerivation.ts`

```typescript
// ===== 변경 전 =====

derive(seed: Buffer, networkId: number, chainSymbol: ChainSymbol, index: number): DeriveResult {
    const coinType = COIN_TYPES[chainSymbol];
    if (coinType === undefined) {
      throw new Error(`Unsupported chain: ${chainSymbol}`);
    }

    const path = `m/44'/${coinType}'/0'/0/${index}`;

    if (chainSymbol === 'TRON') {
      return this.deriveTron(seed, path);
    } else {
      return this.deriveEvm(seed, path);
    }
}


// ===== 변경 후 =====

/**
 * HD 시드에서 BIP-44 경로로 주소 파생
 *
 * @param seed        HD 마스터 시드 (64 bytes)
 * @param basePath    DB의 derivation_base_path (예: "m/44'/60'/1'")
 * @param chainSymbol 체인 심볼 (ETH, BSC, POLYGON, TRON)
 * @param index       파생 인덱스 (wallet_index_manager에서 할당)
 */
derive(seed: Buffer, basePath: string, chainSymbol: ChainSymbol, index: number): DeriveResult {
    // basePath(예: "m/44'/60'/1'") + "/0/{index}" → 전체 BIP-44 경로
    const path = `${basePath}/0/${index}`;

    if (chainSymbol === 'TRON') {
      return this.deriveTron(seed, path);
    } else {
      return this.deriveEvm(seed, path);
    }
}
```

**변경 요점**:
- `networkId` 파라미터 제거, `basePath` 파라미터 추가
- `COIN_TYPES` 맵을 이용한 하드코딩 경로 제거
- DB의 `derivation_base_path`를 그대로 사용하여 `/0/{index}` 만 뒤에 추가
- `COIN_TYPES` 상수는 다른 곳에서 사용하지 않으면 삭제 가능

### 수정 대상 2: `system-admin.ts`

**경로**: `node-service/packages/blockchain-api/src/routes/system-admin.ts`

```typescript
// ===== 변경 전 (lines 76~82) =====

// 시드 복호화
const seed = Buffer.from(await keyManager.decrypt(hdWallet.master_seed_encrypted), 'hex');

// 체인 심볼 조회
const chainSymbol = await hdDerivation.getChainSymbol(networkId);

// HD 파생
const derived = hdDerivation.derive(seed, networkId, chainSymbol, derivationIndex);


// ===== 변경 후 =====

// 시드 복호화 (v1 데이터는 0x 접두사 포함)
const decryptedSeedHex = await keyManager.decrypt(hdWallet.master_seed_encrypted);
const seed = Buffer.from(decryptedSeedHex.replace(/^0x/, ''), 'hex');

// 체인 심볼 조회
const chainSymbol = await hdDerivation.getChainSymbol(networkId);

// HD 파생 — DB의 derivation_base_path 사용
const derived = hdDerivation.derive(seed, hdWallet.derivation_base_path, chainSymbol, derivationIndex);
```

### 수정 대상 3: `HdDerivation.derive()` 호출부 전체 검색

`derive()` 시그니처가 변경되므로, 다른 곳에서도 호출하고 있다면 함께 수정 필요.

```bash
# 호출부 검색
grep -rn "hdDerivation.derive" node-service/packages/
```

`system-admin.ts` 외에도 `WalletDerivationService.ts` 등에서 호출할 수 있으므로
모든 호출부를 `basePath` 파라미터로 변경해야 함.

---

## 전체 빌드 & 검증

```bash
cd node-service

# 1. 빌드
pnpm build

# 2. blockchain-api 재시작

# 3. ADMIN 지갑 생성 테스트
curl -s -X POST http://localhost:3001/api/admin/wallet/create-admin \
  -H "Content-Type: application/json" \
  -d '{"networkId": 2, "hdWalletId": 2}'

# 기대 결과: {"addressId": ..., "address": "0x...", "networkId": 2, "derivationPath": "m/44'/60'/1'/0/44"}
# derivationPath에 account=1 이 포함되어야 함 (BSC)
```
