비트코인기술보안중급

PSBT: 개인키 없는 컴퓨터가 거래를 만드는 법

에어갭 지갑과 멀티시그가 실제로 굴러가게 만드는 형식. BIP-174의 여섯 역할, 서명 기기가 반드시 확인해야 하는 것들, 오프라인 기기에 수수료를 속이는 공격과 탭루트가 그것을 끝낸 방식까지.

·13분·최종 수정

지갑 앱에서 보내기를 누르면 한 대의 기계가 모든 일을 한다. 쓸 UTXO를 고르고, 출력을 만들고, 수수료를 계산하고, 개인키로 서명한 뒤 네트워크에 던진다. 전부 한 곳에서 일어나기 때문에 중간에 무언가를 주고받을 필요가 없다.

자기 보관을 진지하게 하는 순간 이 전제가 깨진다. 개인키는 인터넷에 연결된 적 없는 기기 안에 있는데, 내가 어떤 UTXO를 가지고 있는지 아는 것은 온라인 컴퓨터다. 2-of-3 멀티시그라면 서명해야 할 키가 애초에 세 장소에 흩어져 있다.

거래를 아는 쪽과 서명할 수 있는 쪽이 분리되면, 완성되지 않은 거래를 기계 사이에서 옮길 그릇이 필요해진다. 그 그릇이 PSBT다.

표준이 없던 시절의 문제

PSBT는 Partially Signed Bitcoin Transaction, 부분 서명 비트코인 거래의 약자다. 비트코인 코어 메인테이너인 에이바 차우(Ava Chow)가 2017년에 올린 BIP-174로 표준이 됐다.

BIP-174가 스스로 밝히는 동기는 두 가지다. 첫째, 미완성 거래를 여러 서명자에게 돌리는 방식이 그때까지 구현마다 제각각이어서 서로 다른 지갑을 쓰는 사람들끼리 협업할 수 없었다. 둘째가 더 중요하다. 거래에 서명하려면 지출하려는 UTXO의 내용을 알아야 하는데, 하드웨어 지갑이나 에어갭 지갑은 블록체인을 볼 수 없다. BIP-174는 이 문제를 이렇게 정리한다. 오프라인 서명자가 UTXO 집합에 직접 접근하지 않고도, 속아 넘어갈 위험 없이 서명할 수 있게 하는 것.

뒷부분이 핵심이다. 오프라인 기기에 "이 거래에 서명해"라고 건네는 것은 쉽다. 어려운 것은 그 기기가 자기가 무엇에 서명하는지 스스로 검증할 수 있게 만드는 일이다.

파일 안에 무엇이 들어 있나

PSBT는 사람이 읽는 형식이 아니라 바이트 구조다. 5바이트 매직으로 시작한다.

0x70 0x73 0x62 0x74 0xFF
   p    s    b    t   구분자

앞 네 바이트는 psbt의 ASCII다. 뒤의 0xFF는 일부러 붙인 안전장치다. 일반 거래 파서가 이 파일을 읽으면 반드시 실패하고, PSBT 파서 역시 일반 거래를 PSBT로 오독하지 않는다. 두 형식이 조용히 섞이지 않도록 헤더에서 갈라놓은 것이다.

그 뒤는 키-값 맵이 세 층으로 이어진다.

<매직> <글로벌 맵> <입력 맵>* <출력 맵>*

글로벌 맵에는 거래 전체에 해당하는 정보가, 입력 맵과 출력 맵에는 각 입력과 출력에 딸린 정보가 들어간다. 각 맵은 0x00 바이트로 끝난다. 키 길이가 0인 키는 존재할 수 없으니, 이 바이트를 만나면 맵이 끝났다는 뜻이 되어 파서 구현이 단순해진다.

값들은 타입 번호로 구분된다. 실제로 쓰이는 것 몇 가지만 보면 구조가 잡힌다.

필드무엇을 담나
PSBT_GLOBAL_UNSIGNED_TX서명이 전부 비어 있는 거래 본체 (v0)
PSBT_IN_NON_WITNESS_UTXO이 입력이 참조하는 이전 거래 전체
PSBT_IN_WITNESS_UTXO이 입력이 참조하는 출력 하나만
PSBT_IN_PARTIAL_SIG서명자 한 명이 붙인 서명
PSBT_IN_BIP32_DERIVATION이 키가 어느 지갑의 몇 번째 키인지
PSBT_IN_WITNESS_SCRIPT멀티시그 같은 스크립트 본문
PSBT_OUT_BIP32_DERIVATION이 출력이 내 거스름돈인지 판별할 근거
PSBT_IN_FINAL_SCRIPTWITNESS완성된 증인 데이터

PSBT_IN_NON_WITNESS_UTXOPSBT_IN_WITNESS_UTXO의 차이는 그냥 용량 문제처럼 보인다. 뒤에서 보겠지만 이 선택 하나에 실제 도난 사고 하나가 걸려 있다.

여섯 개의 역할

BIP-174의 진짜 설계는 형식이 아니라 역할 분리다. 명세는 PSBT를 다루는 주체를 여섯으로 나눈다. 한 프로그램이 여러 역할을 겸할 수 있지만, 각 역할이 할 수 있는 일은 엄격히 제한된다.

생성자(Creator)는 빈 PSBT를 만든다. 서명이 비어 있는 거래를 넣고 입력과 출력 자리를 비워둔다.

갱신자(Updater)는 자기가 아는 정보를 채운다. 각 입력이 참조하는 UTXO, 리딤 스크립트, 위트니스 스크립트, BIP32 파생 경로. 온라인에 있는 감시 전용 지갑이 보통 이 역할이다.

서명자(Signer)는 서명만 붙인다. 명세가 서명자에게 요구하는 제약이 이 설계의 핵심이다. 서명자는 PSBT 안에 들어 있는 UTXO 정보만 사용해야 하고, 다른 데이터 출처가 필요해서도 안 되며, PSBT에 데이터를 추가만 할 수 있다. 서명할 수 없으면 아무것도 붙이지 않고 그대로 돌려준다.

결합자(Combiner)는 여러 벌의 PSBT를 하나로 합친다. 놀랍게도 결합자는 스크립트를 이해할 필요가 전혀 없다. 키-값 쌍을 합치고 중복을 제거할 뿐이다.

입력 완결자(Input Finalizer)는 모아진 부분 서명들이 검증을 통과하기에 충분한지 판단하고, 충분하면 최종 scriptSigscriptWitness를 조립한다. 이때 재료로 쓰인 중간 데이터는 지운다.

추출자(Transaction Extractor)는 완결된 PSBT에서 네트워크에 그대로 던질 수 있는 거래를 뽑아낸다.

역할을 이렇게 잘게 쪼갠 이유는 분명하다. 가장 위험한 물건, 즉 개인키를 쥔 주체가 하는 일을 최소화하기 위해서다. 서명자는 거래를 만들지도, 고치지도, 완성하지도 않는다. 오직 자기가 검증한 것에 서명을 하나 더할 뿐이다.

에어갭 지갑에서 실제로 일어나는 일

콜드 스토리지 운영은 이 역할들이 순서대로 실행되는 과정이다.

  1. 온라인 컴퓨터의 감시 전용 지갑이 거래를 만든다. 이 지갑은 공개키만 알고 개인키가 없어서 서명할 수 없다. 생성자이자 갱신자 역할이다.
  2. 미서명 PSBT를 microSD 카드에 파일로 내보내거나 QR 코드로 화면에 띄운다. 바이너리 파일은 .psbt 확장자를 쓰고, QR로 옮길 때는 Base64 문자열로 바꾼다.
  3. 에어갭 기기가 그것을 읽어 화면에 보내는 주소와 금액을 표시한다. 사용자가 확인하면 서명을 붙인다. USB도 무선도 쓰지 않는다.
  4. 서명된 PSBT가 같은 경로로 온라인 컴퓨터에 돌아온다.
  5. 온라인 컴퓨터가 완결하고 추출해서 브로드캐스트한다.

하드웨어 지갑 중 콜드카드가 SD 카드를, 시드사이너나 제이드가 QR을 쓰는 것은 이 3번 단계를 어떤 물리적 매체로 건널 것인가의 차이일 뿐, 오가는 내용물은 같은 PSBT다.

멀티시그에서는 여기에 결합 단계가 하나 붙는다. 같은 PSBT를 세 명에게 각각 보내고, 각자 자기 서명만 붙여 돌려주면, 결합자가 그것들을 하나로 합친다. 명세는 이 성질을 명시적으로 보장한다. A와 B가 각자 독립적으로 처리한 결과를 합친 것은 A를 거친 뒤 B를 거친 것과 같다. 서명 순서가 결과를 바꾸지 않기 때문에 세 명이 동시에, 서로 다른 도시에서, 서로 다른 지갑 소프트웨어로 서명해도 된다.

서명 기기가 반드시 확인해야 하는 것

서명자가 화면에 무언가를 띄우고 사용자가 승인 버튼을 누른다고 해서 안전한 것이 아니다. 기기가 자기에게 들어온 데이터를 검증하지 않으면, 화면에 표시된 내용 자체가 거짓일 수 있다. 그래서 BIP-174는 서명자가 확인해야 할 항목을 못박아 둔다.

  • 이전 거래 전체가 제공됐다면, 그 해시가 입력이 가리키는 prevout의 해시와 일치해야 한다.
  • 위트니스 UTXO만 제공됐다면 비위트니스 서명을 만들어서는 안 된다.
  • 리딤 스크립트가 제공됐다면 scriptPubKey가 그 스크립트에 대응해야 한다.
  • 위트니스 스크립트가 제공됐다면 scriptPubKey 또는 리딤 스크립트가 그것에 대응해야 한다.
  • 사이해시 타입이 지정됐다면 받아들일 수 있는 것인지 확인하고, 아니면 실패해야 한다.

거스름돈 판별도 여기에 딸린 문제다. 2-of-3 금고에서 1 BTC를 꺼내 0.1을 보내면 0.9는 내 거스름돈으로 돌아온다. 그런데 서명 기기 입장에서 그 0.9짜리 출력이 정말 내 것인지, 아니면 공격자가 자기 주소로 바꿔친 것인지는 주소만 봐서는 알 수 없다. 판별 근거가 PSBT_OUT_BIP32_DERIVATION 필드다. 기기는 그 파생 경로를 따라 키를 직접 유도해 보고, 자기가 만들 수 있는 키가 나오면 그것이 자기 거스름돈이라고 확인한다. 이 필드가 빠진 PSBT를 무심코 서명하면, 기기는 거스름돈을 남의 것으로 오인해 사용자에게 되묻지 않는다.

오프라인 기기에 수수료를 속이는 공격

PSBT를 이해할 때 가장 실질적인 대목이다.

SegWit 입력에 서명할 때 쓰는 BIP-143 방식은 서명 대상 안에 자기 입력의 금액을 포함한다. 그 덕에 서명 기기는 이전 거래 전체를 받지 않고 지출하려는 출력 하나만 받아도 서명할 수 있다. PSBT_IN_WITNESS_UTXO가 존재하는 이유다. 데이터가 훨씬 작으니 QR로 넘기기에도 좋다.

문제는 서명이 자기 입력의 금액만 커밋한다는 점이다. 다른 입력의 금액은 서명에 묶여 있지 않다. 입력이 여럿인 거래에서 공격자는 다른 입력의 금액을 실제보다 작게 적어 보낼 수 있고, 기기는 그것을 검증할 방법이 없다. 기기가 계산해 화면에 띄우는 수수료는 그래서 틀린 값이 된다.

같은 거래를 사용자에게 여러 번 서명하게 만들 수 있다면 공격은 완성된다. 매번 다른 입력에 대해 조작된 금액을 넣어 서명을 받아내면, 완성된 거래에서 입력 총액과 출력 총액의 차이, 즉 채굴자에게 가는 수수료가 사용자가 본 적 없는 액수로 부풀어 오른다. 코인은 공격자에게 가지 않지만 사용자에게서는 사라진다.

그래서 많은 지갑이 SegWit 입력인데도 PSBT_IN_NON_WITNESS_UTXO, 즉 이전 거래 전체를 요구한다. 전체 거래를 받으면 기기가 그 해시를 직접 계산해 입력이 가리키는 값과 대조할 수 있고, 그러면 금액을 조작할 수 없다. BIP-174도 서명자가 이 데이터가 없는 SegWit 입력의 서명을 거부해도 된다고 명시한다. 하드웨어 지갑에 큰 거래를 서명시킬 때 이상하리만치 오래 걸리는 이유가 대체로 이것이다. 기기가 참조된 이전 거래들을 통째로 받아 해시하고 있는 것이다.

탭루트는 이 문제를 우회가 아니라 원천에서 끝냈다. BIP-341의 서명 메시지는 자기 입력만이 아니라 모든 입력의 금액과 scriptPubKey를 커밋한다. BIP-341 원문은 그렇게 설계한 이유를 각주에 한 줄로 적어두었다. 오프라인 서명 기기에 거래의 수수료를 속일 가능성을 제거하기 위해서다. 탭루트 입력을 서명할 때 기기는 이전 거래를 통째로 받지 않고도 수수료를 정확히 알 수 있다.

v2가 푼 문제: 나중에 입력 추가하기

BIP-174의 v0에는 구조적 한계가 하나 있다. 글로벌 맵에 들어 있는 미서명 거래가 입력과 출력의 목록을 통째로 고정해 버린다는 점이다. 한 번 만들어진 PSBT에는 새 입력을 추가할 수 없다.

혼자 쓸 때는 문제가 없지만 협업 거래에서는 곧바로 막힌다. 여러 사람이 각자 자기 입력을 들고 와서 하나의 거래를 만드는 코인조인이 그렇고, 수취인이 자기 입력을 슬쩍 끼워 넣어 거래 그래프 분석을 무력화하는 PayJoin이 그렇다.

BIP-370이 정의한 PSBT v2는 고정된 거래 본체를 없애고 입력과 출력을 개별 필드로 분해했다. PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_OUT_AMOUNT, PSBT_OUT_SCRIPT 같은 것들이다. 부수 효과로 한 입력에 관한 정보가 두 곳에 흩어지지 않고 그 입력의 맵 안에 모두 모이게 됐다.

v2는 역할도 하나 늘렸다. 구성자(Constructor)다. 구성자는 생성자가 만든 빈 PSBT에 입력과 출력을 추가한다. 다만 아무 때나 추가할 수 있는 것은 아니고, PSBT_GLOBAL_TX_MODIFIABLE 플래그를 먼저 확인해야 한다. 이 플래그는 서명이 붙는 순간 서명자가 직접 갱신한다. 누군가 SIGHASH_ANYONECANPAY가 아닌 서명을 붙였다면 입력 추가 가능 플래그가 꺼지고, SIGHASH_NONE이 아닌 서명을 붙였다면 출력 추가 가능 플래그가 꺼진다. 이미 만들어진 서명을 무효로 만드는 변경이 애초에 시도되지 않도록 형식 자체가 막는 구조다.

한편 BIP-371은 탭루트용 필드를 v0과 v2 양쪽에 추가한다. PSBT_IN_TAP_KEY_SIG, PSBT_IN_TAP_SCRIPT_SIG, PSBT_IN_TAP_LEAF_SCRIPT, PSBT_IN_TAP_INTERNAL_KEY, PSBT_OUT_TAP_TREE 같은 필드들이다. 슈노어 서명과 스크립트 트리는 기존 필드로 표현할 수 없어서 따로 정의해야 했다.

직접 해보기

비트코인 코어에는 각 역할에 대응하는 RPC가 그대로 들어 있다. 시그넷이나 테스트넷에서 실제 코인 위험 없이 확인할 수 있다.

# 생성자 + 갱신자: 자금이 채워진 PSBT를 만든다
bitcoin-cli walletcreatefundedpsbt '[]' '[{"주소":0.001}]'

# 무엇이 들어 있는지 사람이 읽는 형태로 본다
bitcoin-cli decodepsbt "cHNidP8B..."

# 지금 무엇이 빠졌고 다음 역할이 무엇인지 알려준다
bitcoin-cli analyzepsbt "cHNidP8B..."

# 서명자: 서명을 붙인다
bitcoin-cli walletprocesspsbt "cHNidP8B..."

# 결합자: 여러 서명자에게서 돌아온 것을 합친다
bitcoin-cli combinepsbt '["cHNidP8B...","cHNidP8B..."]'

# 완결자 + 추출자: 네트워크용 거래를 뽑는다
bitcoin-cli finalizepsbt "cHNidP8B..."

Base64로 인코딩된 PSBT가 언제나 cHNidP8으로 시작하는 것은 우연이 아니다. 앞서 본 5바이트 매직 70 73 62 74 ff를 Base64로 옮긴 결과다. 문자열을 보고 이것이 PSBT인지 아닌지 눈으로 판별할 수 있다.

analyzepsbt가 특히 쓸모 있다. 지금 이 PSBT에 무엇이 빠졌는지, 다음에 필요한 역할이 무엇인지 알려주기 때문에 여러 기기를 오가다 흐름을 놓쳤을 때 기준점이 된다.

정리

PSBT는 화려한 기능이 아니라 배관에 가깝다. 그러나 이 배관이 없으면 개인키를 인터넷에서 완전히 떼어놓는 운영도, 여러 사람이 각자 다른 지갑으로 하나의 금고를 지키는 구성도 성립하지 않는다. 하드웨어 지갑과 멀티시그가 실제로 굴러가는 것은 이 형식이 그 아래에 깔려 있기 때문이다.

그리고 이 형식은 데이터를 옮기는 그릇에 그치지 않는다. 서명 기기가 무엇을 검증해야 하는지 명세가 규정하고 있고, 검증에 필요한 재료를 어느 필드에 담을지까지 정해 놓았다. 수수료 사기 공격의 존재와 그것을 막는 방법이 명세의 각주에 남아 있는 것도 그래서다. 서명은 승인 버튼을 누르는 행위가 아니라, 기기가 스스로 확인한 사실에 도장을 찍는 행위여야 한다. PSBT는 그 확인을 가능하게 만드는 형식이다.

관련 글