니모닉 한 장이 무섭다
Wallet SDK에서 지갑 백업은 BIP-39 니모닉 12단어 한 장이다. 그런데 이 “한 장”이라는 게 생각할수록 무섭다. 그 한 장을 잃으면 자산이 영구 소실이고, 그 한 장이 새면 즉시 전액 탈취다. 분실이 무서워 복사본을 늘리면 유출 위험이 늘고, 유출이 무서워 한 곳에만 두면 분실 위험이 남는다. 가용성과 기밀성이 정면으로 충돌하고, 단일 백업으로는 이 트레이드오프를 벗어날 수 없다.
이 충돌을 푸는 표준이 SLIP-39다. Trezor(SatoshiLabs)가 만든 규격으로, Shamir Secret Sharing을 니모닉에 적용해 시크릿을 N조각(share)으로 나누고 그중 T개만 모으면 복구되게 한다. 2-of-3이면 한 장을 잃어도 복구되고, 한 장이 새도 아무 정보가 드러나지 않는다.
조각의 수학 — 왜 “일부가 새도 안전”한가
share가 “12단어를 3등분한 것”이면 한 조각만 새도 정보의 1/3이 새는 셈이다. SLIP-39는 그런 게 아니다. 각 share는 독립적인 20단어 뭉치고, 임계치 미만으로는 0비트도 새지 않는다. 이 보장의 근거는 단순하다.
점 2개 → 그 둘을 지나는 직선은 딱 하나
점 t개 → 그들을 지나는 (t-1)차 다항식은 딱 하나
Shamir는 이걸 거꾸로 쓴다. 2-of-3이라면 시크릿을 지나는 직선을 랜덤하게 하나 긋고, 그 직선 위의 점 3개를 share로 나눠준다. 점 2개를 모으면 직선이 유일하게 결정되어 시크릿이 복원된다. 점 1개만 가진 사람에게는? 그 점을 지나는 직선이 무한히 많고 각 직선마다 시크릿이 다르므로, 시크릿은 무엇이든 될 수 있다. 힌트가 조금 새는 게 아니라 수학적으로 모든 값이 동등하게 가능하다(정보이론적 안전). 복원 계산이 라그랑주 보간이고, 실수 대신 GF(256) 유한체 위에서 바이트 단위로 수행한다.
여기에 SLIP-39는 2단계 그룹 구조를 얹는다. 마스터 시크릿을 먼저 그룹 레벨로 나누고, 각 그룹을 다시 멤버 레벨로 나눠 “가족 중 2명 그리고 친구 중 3명” 같은 복합 정책도 표현할 수 있다. 단순 구성이면 평범한 T-of-N으로 동작한다.
문제: 쓸 만한 라이브러리가 없다
도입을 결정하고 라이브러리를 찾았는데 결과가 황당했다. 이 SDK의 암호 신뢰 기반인 @scure/@noble 생태계에는 SLIP-39가 없고, 존재하는 JS 구현들은 수년째 미유지보수에 “use at your own risk”가 명시된 개인 프로젝트뿐이었다. 감사(audit)받은 JS/TS SLIP-39 라이브러리는 사실상 없다.
마스터 시크릿이 지나가는 경로에 “작성자를 믿으세요” 수준의 코드를 넣을 수는 없었다. 남은 길은 직접 구현. 다행히 두 가지가 있었다 — 해시/KDF 프리미티브(HMAC-SHA256, PBKDF2)는 감사된 @noble/hashes로 충당할 수 있고, Trezor 공식 참조 구현의 테스트 벡터 45케이스가 공개돼 있다. SLIP-39는 입출력이 고정된 순수 함수라 외부 정답지로 정확성을 못 박을 수 있다. “감사된 부품 + 벡터 검증된 자체 로직”이면 성립한다고 판단했다.
무엇을 만들었나
구현한 모듈은 다섯 개고, 파이프라인은 이렇다.
flowchart LR
MS[마스터 시크릿] -->|PBKDF2 + 4라운드 Feistel| EMS[암호화된 시크릿]
EMS -->|GF-256 Shamir 분산| SH[지분 값들]
SH -->|메타데이터 비트패킹| W[10비트 워드열]
W -->|RS1024 체크섬 3단어| M[20단어 share × N]
- GF(256) 산술 — 라그랑주 보간의 토대. 덧셈이 XOR이고 곱셈은 룩업 테이블
- Shamir 분산/복원 — 시크릿은 x=255에 놓이고, x=254에는 digest share(HMAC-SHA256 앞 4바이트)가 들어간다. 복구 시 이 값을 재계산해 대조하므로, 다른 지갑의 share가 섞이거나 조합이 틀리면 “그럴듯한 쓰레기” 대신 명확한 에러가 난다
- RS1024 체크섬 — share 끝 3단어. 최대 3단어 오류를 확정 검출해, 손으로 적다 틀린 단어를 입력 즉시 잡아낸다
- 패스프레이즈 암호화 — PBKDF2 + 4라운드 Feistel 네트워크
- share 인코딩 — 임계값·그룹 구성 메타데이터 비트패킹 + BIP-39와 다른 전용 1024 워드리스트
메타데이터가 share 안에 들어 있다는 게 UX 관점에서 은근히 좋다. 첫 share를 입력하는 순간 “3장 중 2장 필요, 1장 입력됨”을 지갑이 스스로 안내할 수 있다 — 사용자가 임계값을 외울 필요가 없다.
자체 테스트는 전부 통과했다 — 그런데
split→recover 왕복 테스트를 먼저 만들었고, 전부 통과했다. 2-of-3의 어느 조합이든 복구됐고, 임계값 미달이면 실패했고, 2단계 그룹 구성도 동작했다. 여기까지만 보면 구현은 끝난 것처럼 보였다.
그다음 Trezor 공식 벡터를 픽스처로 들여와 돌렸다. 유효한 share 묶음이 전부 체크섬 검증에서 실패했다.
원인은 RS1024 생성 다항식 테이블의 마지막 상수 하나였다. 기억에 의존해 0x3f3f4f00으로 적었는데, 참조 구현 원본과 대조하니 0x3f3f120이었다. 상수 하나가 틀린 것이다.
무서운 건 이 버그가 왕복 테스트로는 절대 잡히지 않는다는 점이다. 우리 split과 우리 recover는 같은 잘못된 상수를 공유하므로, 우리끼리 만들고 우리끼리 검증하면 완벽하게 맞아떨어진다. 다만 그 결과물이 표준과 다른 물건일 뿐이다. 이 상태로 배포됐다면 “우리 SDK로 만든 share는 우리 SDK로만 복구되는” 비표준 포맷이 됐을 것이고, 아무 테스트도 경고해주지 않았을 것이다.
상수를 고치자 45케이스가 전부 통과했다 — 유효 케이스 15개는 마스터 시크릿 값까지 일치했고, 거부 케이스 30개(손상 체크섬, 임계값 미달, 잘못된 패딩)는 전부 에러를 냈다.
벡터가 못 보는 것들
그렇다고 “벡터 통과 = 검증 끝”도 아니다. 벡터는 입력과 출력만 본다. 출력에 드러나지 않는 결함은 별도로 방어해야 했다.
| 영역 | 벡터 커버 | 방어 |
|---|---|---|
| 복구 정확성 · 표준 준수 | ✅ | 공식 벡터 대조 |
| split 난수 품질 | ⚠️ 간접 | crypto.getRandomValues + 왕복 프로퍼티 테스트 |
| 메모리 위생 | ❌ | 중간 시크릿 사용 직후 zeroize (덮어쓰기 소거) |
| 타이밍 사이드채널 | ❌ | digest 비교에 constant-time 비교 |
기능은 완벽히 맞는데 비교 연산의 타이밍으로 정보를 흘리는 코드도 벡터는 전부 통과한다. 이런 부류는 출력이 아니라 실행 중 상태의 문제라, 기존 SDK의 시크릿 위생 관행(zeroize, constant-time)을 SLIP-39 경로에도 똑같이 깔았다.
패스프레이즈 — 일부러 채점하지 않는 설계
SLIP-39의 패스프레이즈에는 특이한 성질이 있다. 맞았는지 확인할 방법이 없다. 어떤 패스프레이즈를 넣어도 에러 없이 각각 유효한(서로 다른) 시크릿이 나온다. Keystore V3가 MAC이라는 채점표를 저장해 틀린 비밀번호에 에러를 내는 것과 정반대다.
버그가 아니라 설계다. 채점표가 없으면 강압 상황에서 미끼 패스프레이즈를 내줘도 진짜 지갑이 따로 있다는 사실을 증명할 수 없다(plausible deniability). 대신 대가가 있다 — 오타도 조용히 통과해 멀쩡해 보이는 빈 지갑이 열린다. 그래서 복구 UX에는 “복구된 주소를 보여주고 사용자가 확인”하는 절차가 필수라고 문서에 박아뒀다.
니모닉과 어떻게 잇나
SLIP-39는 BIP-39와 호환되지 않는다(다른 워드리스트, 다른 파생 흐름). 그래서 통합 지점을 정해야 했는데, “기존 BIP-39 니모닉의 엔트로피를 분산 백업”하는 방식을 택했다.
백업: 니모닉 → 엔트로피(16바이트) → splitToShares → share들
복구: share들 → 엔트로피 → 원래 니모닉 복원 → 기존 파생 경로 그대로
복구 결과가 원래 니모닉 그 자체라 주소도 동일하고, 기존 Keystore·파생 흐름을 전부 재사용한다. 대가는 하드웨어 지갑과의 크로스 복구 포기 — share 인코딩은 표준이라 Trezor가 읽을 수는 있지만, Trezor는 복원된 값을 곧장 BIP-32 시드로 쓰고 우리는 니모닉 엔트로피로 재해석하므로 파생 결과가 다르다. “SLIP-39 지원”이라는 표현이 하드웨어 지갑 호환을 함의하지 않도록 문서에 명시했고, 남은 숙제로 SDK 없이 share만으로 니모닉을 복구하는 오프라인 단독 복구 도구를 계획에 올려뒀다 — 백업의 복구 가능성이 우리 서비스의 수명에 종속되면 안 되기 때문이다.
회고
자체 왕복 테스트는 “우리 코드끼리 일관적인가”만 증명한다. 양쪽이 같은 버그를 공유하면 통과해버리기 때문이다. 표준 준수는 오직 외부의 정답지 — 공식 테스트 벡터 — 로만 증명된다.
암호 상수를 기억으로 쓰면 안 된다는 것도 배웠지만, 더 큰 교훈은 검증 순서다. 벡터를 먼저 확보하고 구현이 그것을 통과하게 만드는 것 — 이번엔 벡터를 뒤에 돌려서 다행히 잡았지만, 처음부터 정답지를 깔고 시작했다면 그 상수는 애초에 오래 살아남지 못했을 것이다. 이후 다른 모듈 작업에서는 실제로 벡터 픽스처부터 들여오고 구현을 시작하고 있다.