webSecurity:false가 전역이라는 점 등 비판적으로 읽어야 할 부분도 뚜렷하다. 이 문서는 둘 다 다룬다.
(저장소: ILoveBingLu/CipherTalk · TypeScript · CC BY-NC-SA 4.0 · 별 1,244 · 포크 325 · 버전 2026.7.16)
메신저 앱은 대화 기록을 내 PC에 저장한다. 그런데 그 파일은 SQLCipher로 암호화돼 있고, 키는 앱만 안다. 즉 물리적으로는 내 디스크에 있지만 실질적으로는 접근할 수 없다. CipherTalk가 하는 일은 정확히 이 간극을 메우는 것이다.
동작 순서는 네 단계다. ① 위챗 데이터 폴더를 자동으로 찾고 → ② 실행 중인 Weixin.exe의 메모리를 스캔해 64자리 hex 키를 추출하고 → ③ 텐센트가 오픈소스로 공개한 DB 엔진 WCDB를 FFI로 불러 그 키로 DB를 열고 → ④ 파싱한 메시지를 React 화면·전문검색·AI 에이전트에 흘려보낸다.
.dll 안의 함수를 부른다. CipherTalk는 koffi라는 라이브러리로 이 다리를 놓는다.| 영역 | 내용 |
|---|---|
| 열람 | 세션 목록, 가상 스크롤 채팅 뷰, 날짜 점프, 이미지·영상·음성 재생 |
| 검색 | SQLite FTS5 키워드 검색 + 임베딩 벡터 검색 + RRF 하이브리드 |
| 내보내기 | 메시지 7종(html/json/jsonl/txt/xlsx/sql/chatlab), 연락처 3종(json/csv/vcf), 모먼츠 3종 |
| AI 에이전트 | 27종 도구(통계·검색·타임라인·미디어·기억·이미지생성 등)를 쓰는 대화형 에이전트 |
| 음성 | SILK 코덱 디코딩, Sherpa-ONNX SenseVoiceSmall 로컬 STT, TTS, 실시간 음성통화 |
| 확장 | MCP 서버/클라이언트 양방향, 플러그인 시스템, 스킬 시스템, 데스크톱 펫 |
| CLI | miyu 명령 11종 — Electron 없이 터미널에서 같은 데이터에 접근 |
CipherTalk는 자기 계정의 로컬 기록을 여는 용도를 전제한 도구이고, README도 학습·연구 목적임과 법규 준수를 명시한다. 남의 기기·남의 계정 데이터에 적용하면 통신비밀 침해가 되고, 나라에 따라 명백한 범죄다.
또한 위챗의 이용약관과 충돌할 소지가 있고, 프로세스 메모리를 읽는 동작은 백신이 차단할 수 있다. 이 문서의 목적은 거기 쓰인 소프트웨어 공학 기법을 배우는 것이다.
README에서 기능 목록보다 먼저 나오는 건 개발자의 비전 세 문단이다. 요약하면 이렇다.
1. 그리움에 온도를 남긴다 — 가족이 세상을 떠나면 그 사람의 흔적은 휴대폰 속 기록에만 남는다. 그걸 가족의 디지털 자산으로 정리해 준다. (README는 영화 《코코》의 "죽음이 아니라 잊히는 것이 끝"이라는 대사를 인용한다.)
2. 불의에 증거를 남긴다 — 괴롭힘·협박·모욕을 당했을 때 방대한 기록에서 핵심을 찾아 추적 가능한 사실의 사슬로 정리한다. 실제로 코드에도 evidenceService.ts가 있다.
3. 기록을 사용자 손으로 — 기기 교체·계정 이상·시간 경과로 사라지는 대신, 기록이 진짜 사용자 것이 되게 한다.
기술 저장소가 이런 식으로 감정적 서사를 전면에 세우는 건 드물다. 그리고 이게 별 1,200개를 모은 실질적 동력이다. 배울 점은 여기 있다 — 오픈소스에서 "왜 만들었는가"는 "무엇을 만들었는가"만큼 배포력이 있다.
| 비교축 | 일반적인 채팅 백업 도구 | CipherTalk |
|---|---|---|
| 범위 | DB 덤프 → HTML 출력 | 열람 + 검색 + 분석 + 에이전트 + 확장 |
| 검색 | LIKE 쿼리 | FTS5 + 벡터 임베딩 + RRF 융합 + 리랭킹 |
| 미디어 | 이미지 정도 | 이미지 dat 복호화, SILK 음성 디코딩+STT, 영상, 이모티콘 |
| AI | 없음 | AI SDK v7, 27종 도구, 로컬 임베딩, MCP 양방향 |
| 인터페이스 | GUI 단일 | GUI + 독립 CLI(miyu) + MCP 서버 |
| 안정성 설계 | 단일 프로세스 | utilityProcess 격리 + 워커풀 + 네이티브/TS 이중 구현 |
특히 MCP 서버를 노출한다는 점이 요즘 맥락에서 중요하다. Claude Code 같은 외부 에이전트가 miyu mcp로 이 데이터에 붙을 수 있다는 뜻이고, 앱이 스스로를 "최종 UI"가 아니라 데이터 액세스 레이어로 포지셔닝했다는 신호다.
같은 데이터 계층 위에 GUI·CLI·MCP 서버 세 얼굴을 올렸다. 사용자가 어떤 방식으로 접근하든 아래는 하나다. 개인 프로젝트에서도 핵심 로직을 UI에서 떼어내면 나중에 CLI나 API를 붙이는 비용이 극적으로 줄어든다.
저장소는 "오픈소스"지만, 가장 중요한 세 가지 — DB 복호화(WCDB.dll, wcdb_api.dll), 키 추출(wechat_key_tool.dll), 이미지 네이티브 복호화(.node) — 는 컴파일된 바이너리로만 들어 있고 소스가 없다. package.json이 native/image-decrypt/Cargo.toml을 참조하지만 그 Rust 소스는 저장소에 없다.
즉 이 앱을 소스만 보고 처음부터 다시 만들 수는 없다. 그리고 사용자 입장에서는 "내 위챗 프로세스 메모리를 읽는 검증 불가능한 바이너리"를 신뢰해야 한다는 뜻이기도 하다. 오픈소스 배지를 볼 때 무엇이 열려 있고 무엇이 닫혀 있는지 구분하는 훈련을 하기에 좋은 사례다.
모든 BrowserWindow가 webSecurity:false로 뜬다. 로컬 파일·미디어를 편하게 로딩하려는 선택이지만, 동일 출처 정책(Same-Origin Policy)을 통째로 끄는 것이라 렌더러에 임의 콘텐츠가 들어오는 순간 방어선이 사라진다. 플러그인 시스템까지 있는 앱에서는 특히 위험하다.
올바른 대안은 이 앱이 이미 부분적으로 쓰고 있는 커스텀 프로토콜(local-image:// 등)로 미디어를 서빙하고 webSecurity는 켜 두는 것이다. "편의를 위해 보안 스위치를 끄는" 실제 사례로 기억해 두자.
키 추출 DLL은 Ed25519 서명으로 호출자를 인증한다. 그런데 그 개인키가 XOR 0x5a로 난독화돼 소스에 하드코딩돼 있다. 코드를 읽을 수 있는 사람은 누구나 5분이면 복원한다.
이건 보안이 아니라 과속방지턱이다 — 그리고 그렇게 이해하면 정당한 설계일 수도 있다. 문제는 이걸 진짜 보안으로 착각하는 것. 클라이언트에 있는 비밀은 비밀이 아니다는 원칙의 교과서적 예시다.
| 기술 | 버전 | 역할과 선택 이유 |
|---|---|---|
| Electron | ^39.6.0 | 런타임. 로컬 파일 접근 + 네이티브 FFI + 웹 UI를 한 번에 필요로 하므로 사실상 유일한 선택지 |
| koffi | ^2.9.0 | FFI. C 함수 시그니처를 문자열로 선언해 DLL 호출. node-gyp 빌드가 필요 없어 배포가 쉽다 |
| better-sqlite3 | ^12.5.0 | 앱 자체 DB에만 사용(설정·기억·FTS 인덱스). 위챗 DB에는 안 쓴다. 동기 API라 코드가 단순 |
| fzstd | ^0.1.1 | zstd 압축 해제. 위챗이 메시지 본문을 zstd로 눌러 저장하기 때문 |
| silk-wasm | ^3.7.1 | SILK 음성 코덱 디코딩(WASM). 위챗 음성메시지가 SILK 포맷 |
| sherpa-onnx-node | ^1.12.23 | 로컬 음성인식(STT). SenseVoiceSmall 모델. 서버 전송 없이 기기에서 처리 |
| sharp / ffmpeg-static | ^0.34.5 / ^5.3.0 | 이미지 변환 / 영상·오디오 트랜스코딩 |
| electron-store | ^10.0.0 | 설정 영속화 |
| electron-updater | ^6.3.9 | 자동 업데이트 |
| 기술 | 버전 | 역할 |
|---|---|---|
| ai (Vercel AI SDK) | ^7.0.19 | LLM 호출 추상화. 스트리밍·도구호출 표준화 |
| @ai-sdk/anthropic / openai / google / openai-compatible | ^4.x / ^3.0.7 | 프로바이더 어댑터. openai-compatible로 DeepSeek·Ollama 등 흡수 |
| @modelcontextprotocol/sdk | ^1.27.1 | MCP 서버·클라이언트 양방향 |
| SQLite FTS5 | (better-sqlite3 내장) | 전문검색 가상테이블 message_index_fts |
| OpenTelemetry | sdk-trace-node ^2.9.0 | AI 호출 트레이싱 — 개인 앱치고 이례적으로 진지한 관측성 |
| zod | ^4.1.12 | 도구 파라미터 스키마 + 런타임 검증 |
| 기술 | 버전 | 역할과 특기사항 |
|---|---|---|
| React | ^19.2.3 | UI. StrictMode 사용 |
| react-router-dom | ^7.1.1 | HashRouter — Electron의 file:// 환경에서 BrowserRouter는 새로고침 시 깨지므로 |
| zustand | ^5.0.2 | 상태관리. 스토어 10개. Redux 없음, 전역 Context 없음 |
| HeroUI | ^3.1.0 | 실사용 UI 라이브러리(76개 파일이 import) |
| Tailwind CSS | ^4.3.0 | @tailwindcss/vite 플러그인 방식. PostCSS 설정 파일 없음 |
| virtua | ^0.49.1 | 가상 스크롤. 수십만 건 메시지를 렌더링하는 핵심 |
| ECharts | ^6.1.0 | 차트 — 단 2개 파일에서만 사용. AI가 만든 차트 스펙 렌더링 전용 |
| streamdown + shiki | ^2.5.0 / ^4.2.0 | AI 스트리밍 마크다운 + 코드 하이라이팅 |
| framer-motion / GSAP / Lottie | ^12.40 / ^3.15 / — | 애니메이션 3종 병행 (과하다고 볼 수도) |
이 저장소에는 antd@6.4.3이 설치돼 있지만 from 'antd' import는 0건이다. react-window도 설치돼 있지만 채팅 리스트는 virtua를 쓴다. 과거에 쓰다 갈아탄 흔적이다.
남의 프로젝트 스택을 파악할 때 package.json만 읽으면 이렇게 틀린다. 반드시 grep -r "from 'antd'" src/로 실제 import를 세어 확인하는 습관을 들이자. 이건 이 문서를 쓰면서도 실제로 했던 검증이다.
보통 React는 dependencies에 둔다. 여기서 devDependencies에 있는 이유는 번들러가 렌더러 코드를 전부 하나로 묶어버리기 때문이다 — 최종 산출물에 node_modules/react가 들어갈 필요가 없다. Electron 앱에서는 합리적이지만, 라이브러리 프로젝트에서 이러면 사용자가 설치할 때 의존성이 빠진다. 배포 형태에 따라 정답이 다르다는 예시.
| 항목 | 내용 |
|---|---|
| 번들러 | Vite 6 + vite-plugin-electron (electron-vite 아님). 엔트리 9개를 각각 번들 |
| 패키징 | electron-builder 25.1.8, compression: maximum |
| Windows | NSIS 설치본. requestedExecutionLevel: asInvoker(관리자 강제 안 함), oneClick:false |
| macOS | dmg, hardenedRuntime:true, entitlements로 메모리 스캔 권한 요청. notarize 설정은 build 블록에 없음 |
| 네이티브 리빌드 | npmRebuild:false + 루트 postinstall: electron-rebuild로 직접 제어 |
| asarUnpack | better-sqlite3, sharp, koffi, ffmpeg-static, silk-wasm, sherpa-onnx-node, resources/** — 네이티브는 asar 밖으로 빼야 로드된다 |
| CI | GitHub Actions, windows-latest, Node 22.12.0. 태그와 package.json version 불일치 시 빌드 실패 처리 |
| 릴리스 노트 | AI로 자동 생성(AI_API_URL/AI_MODEL 변수) |
경로를 하드코딩하지 않는다. 후보를 모아 점수를 매기고 정렬한다. 사용자가 데이터 폴더를 옮겼거나 계정이 여러 개여도 견디는 방식이다.
// dbPathService.ts — 계정 디렉토리인지 판정
private isAccountDir(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) || // 위챗 v4
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2')) ||
existsSync(join(entryPath, 'msg', 'attach')) // 위챗 v3
)
}
// 점수 = 루트 보너스 + 계정수*10000 + 최근수정시각
// xwechat_files(v4) = 30000 / WeChat Files(v3) = 20000 / mac 버전디렉 = 50000
// all·applet·backup·wmpf·app_data 로 시작하는 디렉토리는 제외
"경로가 하나로 정해지지 않는" 모든 상황에 쓰는 일반 패턴이다. 확정하지 말고, 후보를 모으고, 점수를 매기고, 순서대로 시도하고, 실패 이유를 모두 기록한다. 설정 파일 탐색, 실행 파일 탐색, 백업 위치 탐색 등에 그대로 재사용된다.
여기가 이 프로젝트에서 기술적으로 가장 흥미로운 지점이다. SQLCipher 키 유도(PBKDF2)를 JS로 구현하지 않는다. 대신 실행 중인 위챗 프로세스의 메모리에서 이미 풀려 있는 키를 찾아낸다.
금고 비밀번호를 수학적으로 푸는 것과, 지금 금고를 열어놓고 일하는 주인의 책상 위에서 메모지를 찾는 것의 차이다. 앞은 사실상 불가능(AES는 안 뚫린다)하고, 뒤는 가능하다 — 단, 주인이 자리에 있어야만.
그래서 CipherTalk는 위챗이 실행 중이고 로그인된 상태를 요구한다. 위챗을 끄면 키를 못 얻는다. 이게 "복호화 도구"들이 공통으로 갖는 제약이다.
그리고 이 DLL은 아무나 부르지 못하게 Ed25519 챌린지-응답을 건다. DLL이 난수를 주면, 호출자가 개인키로 서명해 돌려줘야 진짜 작업을 해 준다.
// wxKeyService.ts — 내장 개인키 복원 (XOR 0x5a 난독화)
private getScanPrivateKey(): crypto.KeyObject {
const obf = '6a74585b5a6a5f5c59713f2a5e785e7a...'
const der = Buffer.from(Buffer.from(obf, 'hex').map(v => v ^ 0x5a))
return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })
}
scanDbKeyDiag(contactDbPath: string): WxScanDiag | null {
const wktChallenge = this.scanLib.func('int wkt_challenge(uint8_t*, size_t)')
const wktDiag = this.scanLib.func('void* wkt_scan_diag_auth(uint8_t*, size_t, str)')
const wktFree = this.scanLib.func('void wkt_free(void*)')
const nonce = Buffer.alloc(32)
if (wktChallenge(nonce, 32) !== 32) return null // ① DLL이 난수 발급
const sig = crypto.sign(null, nonce, this.getScanPrivateKey()) // ② Ed25519 서명
const ptr = wktDiag(sig, sig.length, contactDbPath) // ③ 인증+스캔
const d = JSON.parse(String(koffi.decode(ptr, 'char', -1)).replace(/\0/g, ''))
wktFree(ptr) // ④ 네이티브 메모리 해제 필수
return { key: d.key?.length === 64 ? d.key : null, auth: d.auth !== false,
dbOk: d.db_ok !== false, pids: +d.pids||0, opened: +d.opened||0,
bytes: +d.bytes||0, markers: +d.markers||0, candidates: +d.candidates||0 }
}
리턴값에 pids / opened / bytes / markers / candidates가 왜 들어 있을까? 실패 원인을 구분하기 위해서다.
pids:0 = 위챗이 안 켜져 있음 · opened:0 = 프로세스는 있는데 열 권한이 없음(관리자 필요) · bytes:0 = 열었는데 못 읽음 · candidates:0 = 다 읽었는데 키 패턴이 없음(위챗 버전 불일치).
불리언 하나로 "실패"만 돌려주면 사용자는 뭘 고쳐야 할지 모른다. 실패 경로에 계측을 심는 것은 사용자 지원 비용을 극적으로 줄인다. 개인 프로젝트에서도 따라 할 만한 습관이다.
koffi로 C 함수 시그니처를 문자열로 선언하면 그대로 호출 가능한 JS 함수가 된다. 컴파일 단계가 없어서 배포가 쉽다.
// wcdbCore.ts — FFI 바인딩
this.koffi = require('koffi')
this.lib = this.koffi.load(libraryPath) // resources/wcdb_api.dll
this.wcdbInit = this.lib.func('int32 wcdb_init()')
this.wcdbOpenAccount = this.lib.func(
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)')
this.wcdbExecQuery = this.lib.func(
'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path,' +
' const char* sql, _Out_ void** outJson)')
// ★ 심볼 능력 탐지 — DLL에 없으면 null로 두고 기능만 degrade
const tryBind = (decl: string): any => {
try { return this.lib.func(decl) } catch { return null }
}
this.wcdbExecQueryWithParams = tryBind('int32 wcdb_exec_query_with_params(...)')
this.wcdbExportMessageChunk = tryBind('int32 wcdb_export_message_chunk(...)')
앱과 DLL이 따로 업데이트되면 버전이 어긋난다. 구버전 DLL에 없는 함수를 부르면 크래시. tryBind()는 있으면 쓰고 없으면 null로 두어, 앱이 죽는 대신 그 기능만 비활성화되게 한다.
웹에서 if ('IntersectionObserver' in window)로 브라우저 기능을 확인하는 것과 정확히 같은 사고방식이다. "버전을 확인"하지 말고 "능력을 확인"하라는 원칙.
그리고 DB 파일을 열 때도 역시 단정하지 않는다. session.db 후보를 점수순으로 전부 시도하고, 실패 이유를 모아 둔다.
private tryOpenWithCandidates(sessionDbPaths: string[], hexKey: string) {
for (const sessionDbPath of sessionDbPaths) {
const handleOut = [0]
const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
if (result === 0 && handleOut[0] > 0)
return { success: true, handle: handleOut[0], matchedPath: sessionDbPath }
errors.push(`${sessionDbPath} => ${this.mapStatusCode(result)}`)
}
// 전부 실패 시 errors 배열이 그대로 사용자에게 노출된다
}
FFI로 네이티브를 부르면 C 쪽에서 죽을 때 Node 프로세스가 통째로 죽는다. try/catch로 못 잡는다. CipherTalk의 해법은 두 겹이다.
겹 1 — 프로세스 격리. WCDB 호출을 Electron utilityProcess에 몰아넣는다. 죽어도 그 프로세스만 죽고, 부모(wcdbService)가 대기 중인 요청을 reject한 뒤 재시작한다.
겹 2 — 요청 직렬화. 이게 진짜 핵심이다. 저장소 주석이 문제를 정확히 서술한다.
// wcdbUtilityProcess.ts
// 모든 요청을 직렬화: 각 요청(커서 open→fetch→close 전 과정)이 끝나야
// 다음이 시작된다. close/open/shutdown 이 어떤 커서 배치의 await 틈새에
// 끼어들어, 네이티브 핸들이 해제된 뒤에도 날아가던 fetch 가 그걸 쓰는
// 상황(use-after-free → koffi napi_throw → utility process fatal)을 막는다.
let queue: Promise<void> = Promise.resolve()
parentPort.on('message', (event: Electron.MessageEvent) => {
const msg = event.data
queue = queue.then(() => handleMessage(msg)).catch(() => undefined)
})
도서관에서 A가 책을 펼쳐 놓고 읽는 중간에 B가 와서 그 책을 서가에 반납해 버리면, A는 사라진 책을 읽으려다 넘어진다. 이게 use-after-free다.
JS의 await는 "여기서 잠깐 다른 일 해도 돼"라는 뜻이다. 커서를 열고 데이터를 await로 가져오는 사이에 다른 요청의 close가 실행될 수 있다. 큐는 "한 사람이 책을 다 읽고 덮을 때까지 아무도 손대지 않는다"는 규칙이다.
단 세 줄이지만, 이게 없으면 앱은 재현 불가능한 랜덤 크래시에 시달린다.
프로토콜은 단순한 요청-응답이되, 특수 ID로 이벤트를 섞어 보낸다.
보안 설정부터 보자. 모든 창이 동일하다.
webPreferences: {
preload: join(__dirname, 'preload.js'),
devTools: ctx.allowDevTools,
contextIsolation: true, // ✅ 렌더러와 preload의 JS 컨텍스트 분리
nodeIntegration: false, // ✅ 렌더러에서 require 금지
webSecurity: false // ⚠️ 동일 출처 정책 전역 해제 — 앞서 지적한 문제
}
통신은 세 가지 패턴을 쓴다.
패턴 1 — invoke/handle (요청-응답). 지배적 패턴. preload.ts에 ipcRenderer.invoke가 303회 등장하고, 도메인별 네임스페이스로 묶여 window.electronAPI에 노출된다. 채널 이름은 도메인:동작 규약(config:get, wcdb:connect, plugin:invoke).
패턴 2 — send/on (이벤트 푸시). 여기서 배울 규약이 하나 있다. 리스너 등록 함수가 해제 함수를 리턴한다.
onChanged: (callback) => {
const listener = (_: any, payload) => callback(payload)
ipcRenderer.on('config:changed', listener)
return () => { ipcRenderer.removeListener('config:changed', listener) }
}
// 덕분에 React에서 이렇게 쓸 수 있다 — 메모리 누수 원천 차단
useEffect(() => {
return window.electronAPI.config.onChanged(setConfig)
}, [])
useEffect의 cleanup과 정확히 맞물린다. 이 규약이 없으면 개발자가 removeListener를 직접 호출해야 하는데, 리스너 참조를 따로 보관해야 해서 거의 항상 빠뜨린다. API가 올바른 사용법을 강제하도록 설계하는 좋은 예다.
패턴 3 — 커스텀 RPC. invoke를 쓸 수 없는 이른 시점(preload 초기화 중)에는 requestId로 응답 채널을 만들고 타임아웃을 건다.
const requestId = `${Date.now()}-${Math.random()}`
const responseChannel = `app:getMcpLaunchConfig:response:${requestId}`
const timeout = setTimeout(() => {
ipcRenderer.removeAllListeners(responseChannel); resolve(null) // 실패해도 진행
}, 600)
ipcRenderer.once(responseChannel, (_, payload) => {
clearTimeout(timeout); resolve(payload ?? null)
})
ipcRenderer.send('app:getMcpLaunchConfig:request', { requestId })
600ms 안에 답이 없으면 null로 진행한다. 부가 기능 때문에 앱 부팅이 막히지 않게 하는 설계다.
여기서부터가 "남이 만든 포맷"과 싸우는 구간이다. 먼저 테이블 이름부터 해시다.
// 메시지 테이블 이름 = 'msg_' + md5(sessionId)
crypto.createHash('md5').update(sessionId).digest('hex').toLowerCase()
// 역방향: 테이블 목록에서 메시지 테이블만 골라내기
String(tableName).match(/^msg_([0-9a-f]{32})$/i)
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"
게다가 컬럼 이름이 위챗 버전마다 다르다. 해법은 후보 배열 + 폴백 조회다.
const MSG_TYPE_COLUMNS = ['local_type', 'localType', 'type', 'Type',
'msg_type', 'msgType', 'MsgType', 'message_type', 'messageType',
'WCDB_CT_local_type']
// getRowField(row, names): 정확 매치 먼저 → 없으면 소문자 매치로 폴백
남의 포맷을 읽을 때 row.local_type이라고 쓰면, 상대가 앱을 업데이트하는 순간 undefined가 되고 조용히 망가진다. 후보 배열로 훑고, 못 찾으면 명시적으로 에러를 내는 편이 안전하다.
같은 원리가 외부 API 응답 파싱, CSV 헤더 처리, 설정 파일 마이그레이션에 그대로 적용된다.
본문 자체도 그냥 텍스트가 아니다. zstd 압축이 걸려 있을 수 있다.
// rowDecoders.ts — 매직 넘버로 zstd 판별
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) { // zstd 프레임 시그니처
return Buffer.from(fzstd.decompress(data)).toString('utf-8')
}
// 실패 시: UTF-8 시도 → 대체문자(U+FFFD)가 20% 넘으면 latin1로 폴백
// hex/base64 문자열 추정은 16자 넘을 때만 (짧은 숫자열 오탐 방지)
메시지 타입은 정수 코드로 들어온다. 주요 코드를 정리하면:
| local_type | 의미 |
|---|---|
| 1 | 텍스트 |
| 3 / 43 / 34 | 이미지 / 영상 / 음성 |
| 42 / 48 | 명함 / 위치 |
| 47 | 애니메이션 이모티콘 |
| 49 | appmsg — 링크·파일·미니앱·송금·인용이 전부 여기 (XML 서브타입으로 구분) |
| 50 / 10000 | 음성통화 / 시스템 메시지 |
| 244813135921 | 인용 메시지 (64비트 확장 타입) |
49 안의 XML <type> 서브코드: 2000=송금, 2001=홍바오, 115=선물, 6=파일, 19=대화기록 전달, 33/36=미니프로그램, 57=인용, 5/49=링크.
사용자가 XML 조각을 채팅에 붙여넣으면, 그 안의 <title>이 바깥 XML의 진짜 <title>로 오인돼 엉뚱한 값을 뽑는다. 저장소의 해법은 순서를 바꾸는 것이었다.
const rawContent = content // ① 원본 보관
content = decodeHtmlEntities(content) // ② 표시용은 디코딩
// ③ 필드 추출은 "디코딩 전 원본"에서 — 내층은 아직 <title> 상태라 안 걸린다
const title = decodeHtmlEntities(extractXmlValue(rawContent, 'title'))
위챗 이미지는 .dat 파일로 저장되고, 버전마다 방식이 다르다. 시그니처로 판별한다.
// datDecryptCore.ts — V4 복호화
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = bytesToInt32(header.subarray(6, 10))
const xorSize = bytesToInt32(header.subarray(10, 14))
// PKCS7: 이미 16의 배수여도 한 블록 더 붙는다 → +16 이 정답
const alignedAesSize = aesSize + (16 - ((aesSize % 16) + 16) % 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false) // 패딩을 직접 제거해야 정확
unpadded = strictRemovePadding(Buffer.concat([decipher.update(aesData), decipher.final()]))
return Buffer.concat([unpadded, rawData, xoredData])
XOR 키는 어떻게 알아낼까? 썸네일 파일 여러 개의 마지막 2바이트 최빈값으로 역산한다 — JPEG는 항상 FF D9로 끝난다는 사실을 이용한 기지 평문 공격(known-plaintext attack)의 실용 버전이다.
복호화 후에도 컨테이너가 wxgf 포맷이면 여러 오프셋(0x10, 0x12, 0x14, 0x18, 0x20, 0xd0, 0x100)에서 PNG/JPG/GIF/WEBP 매직을 찾고, 그래도 없으면 앞 512바이트에서 ff d8 ff를 브루트포스한다. 포맷 리버싱의 현실적인 모습이다.
이미지 복호화는 빠른 .node 네이티브와 느린 순수 TS 두 경로가 있고, 결과에 source: 'native' | 'ts'와 fallbackReason을 붙인다. 어느 경로로 처리됐는지 나중에 알 수 있다는 게 핵심 — 성능 문제나 결과 불일치를 디버깅할 수 있다.
같은 알고리즘을 메인 스레드와 워커가 공유하도록 순수 함수로 분리(datDecryptCore.ts)한 것도 좋은 구조다.
이모티콘·채널 영상 복호화에 쓰이는 isaac64.ts(ISAAC64 난수생성기 재구현)의 주석은 이렇게 경고한다 — 황금비 상수가 0x9e3779b97f4a7c13이지, 흔히 쓰이는 ...7c15가 아니라는 것. 7c15를 쓰면 키스트림이 전부 달라져 복호화가 통째로 실패한다.
암호 알고리즘을 옮겨 쓸 때는 "대충 맞으면 대충 동작"이 없다. 100% 일치하거나 100% 쓰레기다. 반드시 참조 구현의 테스트 벡터로 검증할 것.
1/(k+rank)를 더한다. 점수 스케일이 다른 검색기들(키워드 점수 vs 코사인 유사도)을 정규화 없이 합칠 수 있어서 실무에서 매우 인기 있다. 보통 k=60.키워드 검색은 "정확한 단어"에 강하고 벡터 검색은 "비슷한 의미"에 강하다. 둘을 합치면 "작년에 형이 보내준 그 식당 이름" 같은 애매한 질의도 잡힌다. 이게 요즘 RAG의 표준 구성이다.
메시지는 "여러 DB 파일 × 세션마다 별도 테이블"로 샤딩돼 있다. 통계를 내려면 수백 개 테이블을 훑어야 하는데, 매번 IPC를 왕복하면 느리다.
해법: 한 DB 안의 여러 테이블을 UNION ALL로 한 쿼리에 합치되 200개씩 끊는다. SQLite의 복합 SELECT 개수 상한을 피하면서 왕복 횟수를 수백 분의 1로 줄인다. "경계값을 알고 그 직전까지 배치"하는 전형적인 최적화다.
가상 스크롤 리스트를 "맨 아래로 스크롤" 시키려면 보통 ref.current.scrollToBottom()처럼 명령형으로 호출한다. 이 저장소는 다르게 했다.
/** ChatPage 가 "지금 맨 아래로 가야 한다"는 의도를 표현하는 신호.
증가할 때마다 scrollToIndex 로 하단 이동이 한 번 실행된다. */
bottomSignal: number
/** "맨 위로" 의도 신호 (날짜 점프 등) */
topSignal: number
// 자식 쪽
useEffect(() => {
if (bottomSignal > 0) virtualizerRef.current?.scrollToIndex(items.length - 1)
}, [bottomSignal])
ref로 자식 메서드를 부르면 부모가 자식의 내부 구현을 알아야 하고, 자식이 아직 마운트되지 않았을 때 타이밍 문제가 생긴다.
단조 증가하는 정수를 내려보내면 그냥 평범한 props다. React의 데이터 흐름을 거스르지 않고, 자식은 "값이 바뀌면 스크롤"이라는 규칙만 알면 된다. 부모는 setBottomSignal(n => n + 1)만 하면 끝.
"같은 값을 두 번 보내면?" 문제도 자동 해결된다 — 값이 계속 증가하니 항상 달라진다. (불리언 토글로는 이게 안 된다.)
| 순서 | 파일 | 왜 여기부터인가 |
|---|---|---|
| 1 | vite.config.ts | 엔트리 9개가 보인다 = 프로세스 구조의 전체 지도 |
| 2 | electron/main.ts | 부팅 순서. 376줄로 짧다 |
| 3 | wcdbUtilityProcess.ts | 111줄. 이 프로젝트에서 가장 밀도 높은 코드 |
| 4 | services/dbPathService.ts | 점수화 탐색 패턴. 272줄로 자기완결 |
| 5 | services/wcdbCore.ts | FFI의 실제. 길지만(874줄) 앞 200줄이면 충분 |
| 6 | services/dbAdapter.ts | 106줄. 상태머신 파서 연습에 최적 |
| 7 | services/chat/rowDecoders.ts | 방어적 파싱의 집약 |
| 8 | electron/preload.ts | 931줄이지만 반복 구조. IPC 규약 확인용 |
aiHandlers.ts 92KB, mcp/readService.ts 89KB, memoryDatabase.ts 69KB, messageQueries.ts 60KB, windowManager.ts 57KB — 단일 파일이 50KB를 넘으면 대개 여러 책임이 뭉쳐 있다는 신호다.
반대로 rrf.ts, isaac64.ts, datDecryptCore.ts처럼 작고 순수한 파일들은 테스트하기 쉽고 재사용된다(워커와 메인이 공유). 남의 코드를 볼 때 ls -lS로 크기순 정렬만 해봐도 구조가 보인다.
같은 저장소 안에 있지만 완전히 독립된 npm 패키지다. 자체 package.json·lockfile·의존성·테스트·릴리스 워크플로를 갖고, 데스크톱 빌드에서는 "!CipherTalk-CLI/**/*"로 명시 제외된다. Electron을 전혀 쓰지 않는다.
| 항목 | 내용 |
|---|---|
| 이름/버전 | ciphertalk-cli 0.1.3, 명령어는 miyu |
| 빌드 | tsup 8.5, 테스트 vitest 4, 개발실행 tsx |
| 의존성 | commander 14, chalk 5, ora 9, cli-table3, koffi, zod, MCP SDK |
| 명령 11종 | init config status sessions messages contacts key search export moments mcp |
| 출력 포맷 | --format json|jsonl|table|csv|markdown |
| 설정 위치 | ~/.miyu/config.json |
재미있는 UX 설계가 하나 있다. 서브커맨드 없이 실행하고 TTY면 자동으로 전체화면 인터랙티브 셸에 진입하고, --quiet나 --format을 주면 파이프 모드로 동작한다. 초보자에게는 메뉴를, 스크립트에게는 JSON을 주는 이중 인터페이스다.
CLI는 데스크톱의 데이터 계층을 npm run sync:upstream 스크립트로 포팅(복사)한다. 그 결과 wcdbCore.ts, wcdbService.ts, dbAdapter.ts, messageDbScanner.ts가 양쪽에 중복 존재한다.
동기화 시점은 package.json의 비표준 필드에 기록돼 있다: "_upstream": { "ciphertalk": "6.0.0", "synced_at": "2026-05-14" }.
왜 이렇게 했나 — 데스크톱 코드가 Electron 모듈에 얽혀 있어 그대로 못 쓰기 때문이다. 더 나은 해법은 npm workspaces나 pnpm workspace로 @ciphertalk/core 공유 패키지를 만드는 것. 다만 그러려면 Electron 의존성을 코어에서 걷어내는 리팩터링이 선행돼야 한다. 모노레포 도구는 문제를 해결하지 않는다 — 의존성 분리가 먼저다.
package.json은 "version": "0.1.3"인데, src/cli.ts는 .version('0.1.0', ...)으로 하드코딩돼 있다. 즉 miyu -V를 치면 0.1.0이 나온다.
교훈: 버전 같은 단일 진실원(single source of truth)은 반드시 한 곳에서만 관리하고 나머지는 읽어 오게 해야 한다. Node라면 import pkg from '../package.json' 한 줄이면 끝날 일이다.
Electron 튜토리얼은 보통 "메인 + 렌더러" 2개만 가르친다. 실전 앱은 4종류를 쓴다.
| 프로세스 | 용도 | 이 앱에서 |
|---|---|---|
| Main | 앱 생명주기, 창 관리, OS 접근 | main.ts + ipc 핸들러 31개 |
| Renderer | UI. Chromium 샌드박스 | React 앱 |
| Preload | 둘 사이의 안전한 다리 | preload.ts 931줄 |
| utilityProcess | 죽어도 되는 위험한 작업 격리 | WCDB, 에이전트, 내보내기 |
| worker_threads | CPU 집약 작업 (같은 프로세스 내 스레드) | 이미지 복호화 풀, 음성인식 |
utilityProcess vs worker_threads 선택 기준이 이 저장소에 잘 나타난다.
utilityProcess (별도 프로세스) — 죽을 수 있는 코드. 네이티브 FFI, 서드파티 바이너리. 프로세스가 분리돼 있어 크래시가 전파되지 않는다. 대신 통신 비용이 크다(직렬화 필요).
worker_threads (같은 프로세스의 스레드) — 안 죽지만 느린 코드. 순수 계산, 이미지 처리. 메모리를 공유할 수 있어 빠르지만, 크래시하면 프로세스 전체가 죽는다.
CipherTalk가 WCDB는 utilityProcess에, 이미지 복호화는 worker에 둔 이유가 바로 이것이다. 이미지 복호화는 순수 JS/네이티브 애드온이라 상대적으로 안전하다.
워커풀 관리도 배울 만하다.
// 코어 수에 맞춰 자동 조절, 상하한 고정
const POOL_SIZE = Math.max(2, Math.min(4, cpus().length - 2))
// 60초 동안 전원 유휴면 스레드 회수 (메모리 반납)
// 풀을 못 쓰면 null 반환 → 호출자가 인라인 복호화로 폴백
cpus - 2로 여유를 남기는 것(메인·렌더러 몫), 최소 2 최대 4로 묶는 것, 유휴 시 회수하는 것 — 데스크톱 앱은 사용자 머신을 독점하면 안 된다는 감각이 담겨 있다.
빈 Electron 앱을 만들고 utilityProcess.fork()로 자식을 띄운 뒤, 자식에서 일부러 process.crash()를 호출해 보라. 메인 창이 살아 있는 걸 눈으로 확인하는 게 이 패턴을 체득하는 가장 빠른 길이다.
koffi는 node-gyp 컴파일 없이 문자열 선언만으로 C 함수를 부른다. 그래서 배포가 압도적으로 쉽다(사용자 기기에 빌드 툴체인이 필요 없다).
대신 지켜야 할 규칙이 있다.
| 규칙 | 이유 | 이 저장소의 대응 |
|---|---|---|
| 네이티브가 준 메모리는 반드시 해제 | GC가 관리하지 않는다 → 누수 | wktFree(ptr), wcdb_free 호출 |
| 핸들 생명주기를 직렬화 | use-after-free 방지 | Promise 큐 |
| DLL 검색 경로 설정 | 의존 DLL을 못 찾으면 로드 실패 | process.env.PATH 앞에 삽입 (mac은 DYLD_LIBRARY_PATH) |
| 심볼 존재 여부 확인 | 버전 스큐 | tryBind() |
| asar 밖으로 빼기 | asar 안의 파일은 네이티브가 못 연다 | asarUnpack 설정 |
구버전 DLL에 wcdb_exec_query_with_params 심볼이 없을 때를 대비해, ?를 SQL 리터럴로 직접 치환하는 폴백이 있다. 따옴표 상태머신으로 문자열 내부의 ?를 건너뛰는 게 핵심이다.
function inlineParams(sql: string, params: any[]): string {
let quote: '"' | "'" | '`' | null = null
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]
if (quote) { // 문자열 안에 있는 중
out += ch
if (ch === quote) {
if (sql[i+1] === quote) out += sql[++i] // '' 이스케이프
else quote = null // 문자열 종료
}
continue
}
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; out += ch; continue }
if (ch === '?' && index < params.length) { out += sqlLiteral(params[index++]); continue }
out += ch
}
if (index !== params.length)
throw new Error(`파라미터 개수 불일치: expected ${index}, got ${params.length}`)
}
function sqlLiteral(v: any) {
if (Buffer.isBuffer(v)) return `X'${bufferToHex(v)}'` // BLOB 리터럴
return `'${String(v).replace(/'/g, "''")}'` // 작은따옴표 이스케이프
}
이 코드는 네이티브 바인딩을 못 쓸 때의 폴백이다. 실무에서는 언제나 진짜 파라미터 바인딩(prepared statement)을 써야 한다 — DB 엔진이 값과 코드를 물리적으로 분리해 주기 때문이다.
다만 이 코드를 읽는 것은 가치가 크다. "왜 문자열 연결이 위험한지", "이스케이프가 정확히 무엇을 하는지"를 코드 레벨에서 이해하게 된다. 그리고 상태머신 파서 작성 연습으로 훌륭하다.
| 주제 | 배울 것 |
|---|---|
| 가상 스크롤 | virtua의 Virtualizer. 수십만 항목을 DOM 수십 개로 렌더링 |
| 명령 전달 | ref 메서드 대신 단조 증가 정수 시그널 |
| 라우팅 | Electron에서는 HashRouter. file://에서 BrowserRouter는 새로고침 시 404 |
| 상태 | zustand 10개 스토어로 도메인 분리. 전역 Context 하나에 다 넣지 않기 |
| 스트리밍 UI | use-stick-to-bottom — AI 응답 스트리밍 중 자동 하단 고정 |
| 구독 정리 | IPC 리스너가 해제 함수를 반환 → useEffect cleanup에 직결 |
App.tsx의 라우트 중 /analytics, /annual-report, /group-analytics-window는 전부 <Navigate to="/home" replace />다. 즉 과거에 있던 분석 페이지들이 제거되고 리다이렉트만 남았다.
그리고 그 기능들은 어디로 갔나? AI 에이전트의 도구로 흡수됐다(agent/tools/chatStats.ts). "전용 대시보드"에서 "에이전트가 필요할 때 계산"으로 옮겨간 것이다.
남의 코드에서 리다이렉트만 남은 라우트, sqlite-vec 제거 후 남은 마이그레이션 방어 코드 같은 걸 보면 프로젝트가 어디로 가는 중인지가 보인다. 커밋 로그보다 빠른 방향 파악법이다.
이 저장소의 AI 구성은 요즘 "LLM 앱"의 표준 조립을 잘 보여준다.
| 설계 결정 | 배울 점 |
|---|---|
| 프로바이더 추상화(AI SDK) | 모델을 갈아끼울 수 있게 — 벤더 락인 회피 |
| zod로 도구 스키마 | 타입 + 런타임 검증 + LLM용 JSON Schema를 한 정의로 |
| OpenTelemetry 트레이싱 | LLM 호출은 비결정적 → 관측성 없으면 디버깅 불가능 |
| 스트림 정합성 보정 모듈 | "OpenAI 호환"을 표방하는 서버들이 실제로는 조금씩 다르다는 현실 대응 |
| 야간 배치 요약 | 대화가 쌓이면 컨텍스트가 터진다 → 주기적 압축이 필수 |
| MCP 서버 노출 | 앱을 "최종 UI"가 아니라 데이터 레이어로 포지셔닝 |
"OpenAI 호환 API"라고 광고하는 서버들(로컬 Ollama, 각종 국산 모델 게이트웨이)이 실제로는 스트림 청크 형식이 미묘하게 다르다. 그래서 별도 정규화 모듈이 필요했다.
"표준을 따른다"는 주장을 곧이곧대로 믿으면 안 된다는 실전 교훈. 외부 API를 붙일 때는 정규화 레이어를 하나 두는 것이 거의 항상 이득이다.
이 저장소는 좋은 패턴만큼이나 잘못된 선택도 명확하게 보여준다. 그래서 교보재로 좋다.
| 선택 | 평가 | 올바른 대안 |
|---|---|---|
contextIsolation: true | ✅ 정석 | — |
nodeIntegration: false | ✅ 정석 | — |
| 커스텀 프로토콜로 미디어 서빙 | ✅ 좋은 방향 | — |
플러그인마다 독립 origin(ct-plugin://) | ✅ 훌륭 | — |
webSecurity: false 전역 | ❌ 위험 | 커스텀 프로토콜로 통일하고 webSecurity는 켜기 |
| 개인키 XOR 난독화 후 하드코딩 | ⚠️ 보안 아님 | 보안이 목적이면 서버 검증. 방지턱이 목적이면 OK(솔직히 문서화할 것) |
| 핵심 로직 바이너리만 배포 | ⚠️ 신뢰 문제 | 최소한 재현 가능한 빌드나 해시 공개 |
| 버전 하드코딩 이중화 | ❌ 버그 | package.json에서 읽기 |
| 항목 | 요구사항 | 비고 |
|---|---|---|
| OS | Windows 10 / 11 | README·배지 모두 Windows 단독 명시 |
| macOS | 부분 지원 (진행 중) | dylib·dmg 타깃·MACOS_PORT_GUIDE.md 존재. SIP 해제 요구 |
| Linux | ❌ 미지원 | — |
| Node.js | 22.12.0 이상 | README·CI 모두. engines 필드는 미선언 |
| RAM | 4GB 이상 권장 | 빌드 시 --max-old-space-size=4096 필요 |
| 위챗 버전 | 4.x (Weixin.exe) 전용 | 구버전 경로 인식 코드는 남아 있으나 키 추출은 v4만 |
| 위챗 상태 | 실행 중 + 로그인 필수 | 꺼져 있으면 메모리에서 키를 못 뽑는다 |
| 권한 | 관리자 실행이 필요할 수 있음 | 프로세스 메모리 읽기 권한 때문. 설치본 자체는 asInvoker |
| 디스크 | 클론 시 약 107MB | 네이티브 DLL 포함 |
솔직하게 짚자. 위챗을 쓰지 않으면 이 앱은 실행조차 안 된다. 데이터 폴더가 없고, 스캔할 프로세스가 없다.
그러니 이 문서의 실습 과제(8장)는 "이 앱을 돌려보기"가 아니라 "이 앱의 패턴을 내 프로젝트에 옮겨 심기"로 설계했다. 그게 실제로 남는 학습이기도 하다.
다른 프로세스의 메모리를 읽고 훅을 설치하는 동작은 정보 탈취 멀웨어와 기술적으로 구별되지 않는다. Windows Defender를 비롯한 백신이 차단하거나 격리할 수 있다.
그리고 "예외 처리하면 된다"고 가볍게 넘기면 안 된다 — 백신 예외 등록은 실제로 위험을 감수하는 행위다. 소스가 공개되지 않은 DLL이 관여한다는 점(2장)을 함께 고려해 판단해야 한다.
// wxKeyServiceMac — 에러 코드로 실패 원인이 드러난다
PROCESS_NOT_FOUND // 위챗이 안 켜져 있음
ATTACH_FAILED // 프로세스 attach 실패 → SIP 해제 필요
SCAN_FAILED // 메모리는 읽었으나 키 패턴 못 찾음
HOOK_FAILED // 훅 설치 실패
HOOK_TARGET_ONLY // 대상 프로세스에서만 가능
macOS는 libdobby.dylib(오픈소스 훅 프레임워크 Dobby)를 쓰고, entitlements.mac.plist로 메모리 접근 권한을 요청하며, hardenedRuntime: true로 빌드된다. 다만 notarize 설정이 build 블록에 없어 서명·공증은 환경변수에 의존하거나 미서명일 가능성이 있다.
목표 — Electron의 프로세스 격리가 실제로 무엇을 지켜주는지 체감한다.
할 일 — 빈 Electron 앱을 만들고 ① 버튼 A는 메인 프로세스에서 process.crash()를 호출, ② 버튼 B는 utilityProcess.fork()로 띄운 자식에서 크래시를 호출하게 한다. 각각 눌러보고 창이 어떻게 되는지 관찰한다.
확장 — 자식이 죽었을 때 부모가 감지해 자동 재시작하고, 대기 중이던 요청을 reject하는 로직을 붙여본다. (wcdbService.ts가 하는 일이 정확히 이것)
검증 — 자식을 10번 죽여도 메인 창이 살아 있고, 각 요청이 에러로 정상 종료되면 성공.
목표 — SQL 인젝션이 왜 생기는지 코드 레벨로 이해한다.
할 일 — inlineParams(sql, params)를 백지에서 구현한다. 그다음 자기 구현을 깨는 입력을 찾는다. 힌트가 될 케이스들:
// 이것들이 전부 올바르게 처리되는가?
inlineParams("SELECT * FROM t WHERE a=? AND b='?'", [1]) // 문자열 안 ? 는 건너뛰어야
inlineParams("SELECT * FROM t WHERE a=?", ["O'Brien"]) // 작은따옴표 이스케이프
inlineParams("SELECT * FROM t WHERE a=?", ["'; DROP TABLE t;--"])
inlineParams("SELECT * FROM t WHERE a=?", [Buffer.from([0xde,0xad])])
inlineParams("SELECT ? ", [1, 2]) // 개수 불일치 → throw
검증 — vitest로 케이스 15개 이상 작성해 전부 통과. 그리고 진짜 prepared statement와 성능·안전성을 비교해 보고, 왜 실무에서 후자를 쓰는지 한 문단으로 정리한다.
목표 — 요즘 RAG의 표준 구성을 손으로 조립한다.
할 일 — 아무 텍스트 데이터(내 메모, 블로그 글, 슬랙 export 등) 1,000건을 준비하고:
① better-sqlite3로 FTS5 가상테이블을 만들어 키워드 검색
② 임베딩 모델(로컬 transformers.js나 OpenAI API)로 벡터 검색
③ rrf.ts처럼 score = Σ 1/(k + rank)로 두 결과를 융합 (k=60)
function rrf(lists: string[][], k = 60): Map<string, number> {
const scores = new Map<string, number>()
for (const list of lists)
list.forEach((id, rank) =>
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1)))
return scores // 내림차순 정렬해서 사용
}
검증 — 질의 20개를 만들고 정답을 라벨링한 뒤, 키워드만 / 벡터만 / RRF 융합 세 방식의 Recall@5를 비교한다. 융합이 이기는지, 진다면 왜인지 분석하는 게 진짜 학습이다.
목표 — 하이브리드 암호 구조를 이해하고, 헤더 파싱·패딩 처리를 정확히 다룬다.
할 일 — CipherTalk의 V4 포맷을 모방한 인코더를 먼저 만든다(이게 핵심 — 만들 줄 알아야 풀 줄 안다).
그다음 디코더를 짜고, 다음 함정들을 직접 밟아 본다:
• PKCS7에서 이미 16의 배수여도 한 블록이 더 붙는다 (alignedAesSize 계산이 왜 저런지)
• setAutoPadding(false)로 두고 직접 제거하지 않으면 왜 깨지는지
• 리틀엔디안/빅엔디안을 바꿔 읽으면 무슨 일이 생기는지
확장 — XOR 키를 모른다고 가정하고, JPEG가 항상 FF D9로 끝난다는 사실만으로 여러 파일에서 키를 역산해 본다(기지 평문 공격). 최빈값을 취하는 이유도 직접 확인.
검증 — 랜덤 크기 파일 100개를 인코드→디코드해서 원본과 바이트 단위로 일치. 경계 케이스(0바이트, 1바이트, 정확히 16바이트, 16의 배수)를 반드시 포함.
목표 — CipherTalk가 하려다 절반만 성공한 구조를 제대로 만들어 본다.
할 일 — 아무 데이터 소스(로컬 파일 인덱스, 북마크, 가계부 등)를 정하고 pnpm workspace로 4개 패키지를 만든다:
핵심 제약 — core는 electron을 절대 import하지 않는다. 파일 경로·설정 위치 같은 환경 의존성은 주입받는다:
// core/src/index.ts — 환경을 인터페이스로 추상화
export interface Env {
configDir: string
readFile(p: string): Promise<Buffer>
log(msg: string): void
}
export function createEngine(env: Env) { /* ... */ }
// desktop: app.getPath('userData') 를 주입
// cli: ~/.myapp 를 주입 ← 같은 코어, 다른 환경
왜 이게 어려운가 — CipherTalk가 "복사 + 수동 동기화"로 후퇴한 이유가 바로 이 분리를 미리 못 했기 때문이다. 직접 해보면 의존성 분리를 나중에 하는 비용을 체감하게 된다.
검증 — core의 package.json에 electron이 없고, 셋 다 동작하며, 로직을 한 번 고치면 세 곳에 동시 반영되면 성공.
읽을 것 — Electron 공식 문서의 Process Model, Context Isolation, Security 체크리스트 3편. 그리고 이 저장소의 main.ts(376줄) + wcdbUtilityProcess.ts(111줄).
만들 것 — 실습 과제 1. 추가로 contextIsolation을 false로 바꿔 렌더러에서 require('fs')가 되는 걸 확인한 뒤, 왜 그게 재앙인지 직접 설명해 본다.
도착점 — "Electron 앱에서 이 코드는 어느 프로세스에서 도는가?"를 즉답할 수 있다.
주제 — await 사이의 틈에서 무슨 일이 벌어지는가. Promise 큐, 뮤텍스, 세마포어, AbortController.
만들 것 — 파일 핸들이나 DB 커넥션을 다루는 클래스를 만들고, 일부러 경쟁 상태를 만들어 깨뜨린다. 그다음 queue = queue.then(...) 패턴으로 고친다.
심화 — 큐가 왜 .catch(() => undefined)를 달아야 하는지 생각해 보라. (하나 실패하면 체인 전체가 멈춘다.)
도착점 — "이 코드에 race condition이 있나?"를 코드만 보고 의심할 수 있다.
읽을 것 — koffi 공식 문서. 이 저장소의 wcdbCore.ts 앞 200줄.
만들 것 — C로 간단한 라이브러리(문자열 반환, 구조체 out 파라미터, 메모리 할당+해제 쌍)를 만들고 koffi로 호출한다. 일부러 free를 빼먹고 메모리 사용량이 늘어나는 걸 관찰한다.
비교 — 같은 걸 node-gyp 네이티브 애드온과 napi-rs(Rust)로도 만들어 보고, 셋의 트레이드오프(배포 난이도 / 성능 / 타입 안전성)를 표로 정리한다.
도착점 — "이 작업은 FFI가 맞나, 네이티브 애드온이 맞나, 아니면 그냥 JS로 될 일인가"를 판단할 수 있다.
주제 — 엔디안, 매직 넘버, 헤더 파싱, 블록 암호 모드(ECB/CBC/GCM), 패딩(PKCS7), 스트림 암호와 키스트림.
만들 것 — 실습 과제 4. 추가로 ECB 모드의 유명한 취약점을 직접 재현한다 — 단색 영역이 큰 이미지를 ECB로 암호화하면 원본 윤곽이 그대로 보인다("ECB 펭귄"). CipherTalk가 ECB를 쓰는 이유(랜덤 액세스 필요)와 그 대가를 함께 이해한다.
도착점 — 암호 라이브러리를 쓸 때 모드와 패딩을 의식적으로 선택하게 된다.
주제 — 역색인, BM25, FTS5 문법, 임베딩, 코사인 유사도, ANN 인덱스(HNSW), RRF, 리랭킹.
만들 것 — 실습 과제 3. 여기에 평가 세트를 반드시 만든다 — 이 저장소에도 evaluation/retrieval/이 있는 이유다.
핵심 감각 — "체감상 좋아진 것 같다"는 검색 개선에서 가장 위험한 말이다. Recall@k, MRR 같은 숫자 없이는 개선인지 퇴보인지 알 수 없다.
도착점 — RAG 파이프라인을 조립하고 측정으로 개선할 수 있다.
주제 — 도구 호출 루프, zod 스키마 설계, 컨텍스트 관리와 압축, MCP 서버/클라이언트, 관측성.
만들 것 — 실습 과제 5의 mcp-server 패키지를 완성한다. 도구 5개를 zod로 정의하고 Claude Code나 다른 MCP 클라이언트에 붙여본다.
심화 — OpenTelemetry로 도구 호출을 트레이싱하고, "왜 이 도구가 안 불렸나"를 트레이스로 진단해 본다. 도구 설명(description)을 한 문장 바꾸면 호출률이 바뀐다는 걸 실험으로 확인하는 게 이 주차의 백미다.
도착점 — 내 데이터를 에이전트가 쓸 수 있게 노출하는 전체 과정을 혼자 할 수 있다.
1~2주차 = 안 죽는 앱 만들기 (프로세스·비동기 자원)
3~4주차 = 남의 포맷과 싸우기 (FFI·바이너리·암호)
5~6주차 = 데이터를 지능에 연결하기 (검색·에이전트)
이 세 축이 정확히 CipherTalk를 구성하는 세 축이기도 하다.
exposeInMainWorld('electronAPI', {...})로 내가 허용한 함수만 웹페이지에 노출한다. Node 전체를 열어주는 대신 필요한 것만 주는 방식.MessagePort로 통신한다..node, .dll)은 아카이브 안에서 로드할 수 없어 asarUnpack으로 밖에 꺼내야 한다. "패키징하니까 안 되는" 문제의 단골 원인.FF D8 FF, PNG는 89 50 4E 47, zstd는 28 B5 2F FD(LE로 읽으면 0xFD2FB528). 확장자보다 신뢰할 수 있다.FF D9로 끝난다"를 이용해 XOR 키를 역산하는 게 정확히 이것이다. XOR 암호가 약한 근본 이유.0x1234 → 34 12), 빅엔디안(BE)은 높은 자리부터. 바이너리 포맷 파싱에서 가장 흔한 버그 원인.tryBind()가 DLL 심볼에 이걸 적용한 예.MATCH 연산자로 검색한다. 별도 서버 없이 검색을 붙일 수 있어 로컬 앱에 최적.1/(k+rank)를 더하므로, 스케일이 다른 검색기들을 정규화 없이 합칠 수 있다. 보통 k=60.| 항목 | 링크 / 내용 |
|---|---|
| 저장소 | github.com/ILoveBingLu/CipherTalk |
| CLI 패키지 | npm install -g ciphertalk-cli → miyu |
| 라이선스 | CC BY-NC-SA 4.0 (데스크톱) — 상업적 이용 금지 / CLI는 MIT |
| 버전 | 2026.7.16 (날짜 기반 버전 체계) |
| 규모 | 별 1,244 · 포크 325 · TypeScript · 클론 약 107MB |
| 주제 | 어디를 볼 것인가 |
|---|---|
| Electron 보안 | 공식 문서의 Security 체크리스트 — webSecurity 항목을 특히 |
| Electron 프로세스 | Process Model, utilityProcess API 문서 |
| koffi | 공식 문서의 Memory management 절 (해제 규칙) |
| WCDB | Tencent/wcdb 저장소 — 텐센트가 공개한 원본 엔진 |
| SQLCipher | 공식 문서의 Design 문서 — 키 유도와 페이지 암호화 방식 |
| SQLite FTS5 | SQLite 공식 문서 FTS5 절 |
| RRF | Cormack et al., "Reciprocal Rank Fusion outperforms Condorcet..." (SIGIR 2009) |
| MCP | modelcontextprotocol.io — 스펙과 SDK |
| Vercel AI SDK | sdk.vercel.ai — 도구 호출과 스트리밍 |
package.json 두 개(루트·CLI)의 정확한 버전, 디렉토리 구조, vite.config.ts의 엔트리 9개, IPC 패턴과 invoke 호출 횟수, 보안 설정 값, 코드 인용 부분(wcdbUtilityProcess, wcdbCore, wxKeyService, dbAdapter, datDecryptCore, dbPathService), 메시지 타입 코드, 라우트 구성, CLI 명령 목록, electron-builder 설정, CI 워크플로. 실제 import를 세어 antd 미사용을 확인했고, 버전 하드코딩 불일치도 소스 대조로 찾았다.
네이티브 바이너리 내부는 검증 불가능하다. WCDB.dll, wcdb_api.dll, wechat_key_tool.dll, ciphertalk-image-native-*.node의 소스가 저장소에 없다. 따라서 이 문서의 키 추출·복호화 설명은 JS 쪽 호출 코드와 주석에서 추론한 것이며, DLL이 실제로 무엇을 하는지(추가 통신 여부 등)는 확인할 수 없었다.
또한 실제 위챗 환경에서 동작을 검증하지 않았다. 동작 흐름 설명은 코드 독해에 기반한다.
내 계정의 채팅 기록이라도 대화 상대방의 발언이 함께 들어 있다. 개인적 보관은 대개 문제가 없지만, 제3자에게 공개·제공하는 순간 상대방의 개인정보·통신비밀 문제가 발생한다. 한국 통신비밀보호법은 대화 당사자가 아닌 자의 취득을 엄격히 금지하고, 당사자라도 공개에는 별도 판단이 필요하다.
남의 기기·계정에 적용하는 것은 명백한 범죄다. "가족 사이니까", "회사 기기니까" 같은 예외는 생각보다 좁게 인정된다.
법적 증거 용도를 고려한다면 반드시 변호사와 먼저 상의하라. 직접 추출한 기록은 증거능력 자체가 다투어질 수 있고, 취득 과정이 위법하면 오히려 불리해진다. (이 문서는 법률 자문이 아니다.)
이 앱을 실행하는 것은 소스가 공개되지 않은 바이너리에게 위챗 프로세스 메모리 접근을 허용하는 결정이다. 백신 예외 등록까지 하면 방어선이 하나 더 사라진다.
중요한 데이터가 있는 주 사용 기기라면, 최소한 ① 데이터 폴더를 먼저 백업하고 ② 네트워크를 차단한 상태에서 실행해 보고 ③ 방화벽으로 외부 통신을 관찰하는 정도의 절차는 밟는 게 합리적이다.
CipherTalk의 진짜 가치는 기능이 아니라 "통제할 수 없는 외부 시스템과 안정적으로 일하는 법"의 실전 사례집이라는 데 있다. 크래시 격리, 비동기 자원 관리, 방어적 파싱, 기능 탐지, 진단 가능한 실패 — 이 패턴들은 위챗과 아무 상관 없는 프로젝트에서도 그대로 쓰인다.
동시에 webSecurity:false, 하드코딩된 개인키, 복사 기반 코드 공유, 버전 이중 관리 같은 잘못된 선택도 함께 읽는 것이 이 저장소를 공부하는 올바른 방법이다. 좋은 코드만 읽으면 왜 좋은지 모른다.