Jason Dev
About
Bitcoin Air-Gap Interface
published: 2024-02-11
airgap
psbt
qr

에어갭(air-gap)은 보안을 위해 물리적인 네트워크 연결 수단을 완전히 제거하는 것을 말한다. 따라서 에어갭 방식을 지원하는 하드웨어 지갑은 유선랜, 와이파이, 블루투스 등의 통신 장비가 없다. 에어갭 하드웨어 지갑은 Micro SD 카드 등의 연결 수단을 제공하기도 하지만 대개 카메라와 QR 코드를 주요 통신 수단으로 활용한다.

필자도 에어갭 방식을 지원하는 하드웨어 지갑을 사용하고 있다. 블루월렛 앱에서 트랜잭션을 만들어서 QR 코드를 생성하고, 하드웨어 지갑에서 QR 코드를 읽은 후 서명을 진행한다. 그러면 다시 서명된 트랜잭션을 QR 코드로 만들고 블루월렛 앱에서 카메라를 통해 서명된 데이터를 받는다.

그런데 필자가 사용하는 블루월렛은 매번 opt-in RBF가 비활성화된 상태로 트랜잭션을 생성했다. 따라서 트랜잭션 수수료가 갑자기 올라가는 경우에는 CPFP 등의 방식으로 좀 더 힘들고 비싼 방식으로 처리할 수 밖에 없었다. 에어갭 방식으로 하드웨어 지갑과 소통하면서도 내가 직접 트랜잭션을 만들고 싶다는 생각이 강하게 들었다.

이 번 글에서는 bitcoind에서 직접 트랜잭션을 생성하고 에어갭 방식으로 서명하는 과정을 알아본다.

사전 지식

개발 환경 설정은 이전 글에서 다룬 내용을 참고하자.

PSBT에 대한 이해가 필요하므로 이전 글을 참고하자.

블루월렛의 트랜잭션 데이터

하드웨어 지갑과 트랜잭션을 주고받을 때는 PSBT 형식의 트랜잭션을 이용한다.

블루월렛에서 서명되지 않은 트랜잭션(PSBT)을 생성하면 QR 코드가 노출된다. 이 QR 코드의 내용을 psbt 변수에 저장 후 decodepsbt 명령어로 트랜잭션의 내용을 살펴보자.

bc1 decodepsbt $psbt
{
"tx": {
"txid": "...",
"hash": "...",
"version": 2,
"size": 113,
"vsize": 113,
"weight": 452,
"locktime": 0,
"vin": [
{
"txid": "...",
"vout": 1,
"scriptSig": {
"asm": "",
"hex": ""
},
"sequence": 4294967295
}
],
"vout": [
{
"value": 0.00010000,
"n": 0,
"scriptPubKey": { ... }
},
{
"value": ...,
"n": 1,
"scriptPubKey": { ... }
}
]
},
"global_xpubs": [],
"psbt_version": 0,
"proprietary": [],
"unknown": {},
"inputs": [
{
"witness_utxo": {
"amount": ...,
"scriptPubKey": { ... }
},
"bip32_derivs": [
{ ... }
]
}
],
"outputs": [
{},
{ "bip32_derivs": [{ ... }] }
],
"fee": 0.00006132
}

민감한 정보는 말줄임 처리를 했다. 입력의 sequence 필드를 보면 이 트랜잭션의 opt-in RBF은 비활성화된 상태라는 것을 알 수 있다.

analyzepsbt 명령어를 이용해서 트랜잭션의 현재 상태를 확인해보자.

bc1 analyzepsbt $psbt
{
"inputs": [
{
"has_utxo": true,
"is_final": false,
"next": "signer",
"missing": {
"signatures": ["..."]
}
}
],
"estimated_vsize": 141,
"estimated_feerate": 0.00043489,
"fee": 0.00006132,
"next": "signer"
}

next 필드를 통해 서명이 필요한 상태라는 것을 확인할 수 있다.

bitcoind에서 watch-only 지갑 생성하기

watch-only 지갑은 개인키 정보가 없어서 서명을 할 수 없는 지갑이다. 서명 외에는 거의 모든 작업을 수행할 수 있다.

watch-only 지갑을 생성하기 위해서는 fingerprint, path, xpub(확장 공개키) 등의 정보가 필요한데, 이 정보는 블루월렛에서 지갑 내보내기를 하거나 하드웨어 지갑을 통해서 획득할 수 있다.

아래와 같은 형식으로 desc1, desc2 쉘 변수를 생성한다.

desc1="wpkh([_fingerprint_/_path_]_xpub_/0/*)"
desc2="wpkh([_fingerprint_/_path_]_xpub_/1/*)"

native segwit 주소 유형이라고 가정하고 wpkh 함수를 사용했다. 각 주소 유형에 맞는 함수 이름을 입력해야 한다. (참고 링크)

아래는 값을 채운 형태를 보여주기 위한 예다.

desc1="wpkh([01234abc/84'/0'/0']xpub1234abcd1234abcd1234abcd1234abcd1234abcd/0/*)"

desc1, desc2 끝에 checksum 값을 붙여야 하는데, 이 정보는 getdescriptorinfo 명령어를 통해 확인할 수 있다.

bitcoin-cli getdescriptorinfo $desc1
{
"descriptor": "...",
"checksum": "...",
"isrange": true,
"issolvable": true,
"hasprivatekeys": false
}

descriptor 필드에서 checksum 값이 붙은 정보를 확인할 수 있다. 이 값을 desc1 변수에 저장하자. desc2 변수도 마찬가지 과정으로 갱신한다.

이제 지갑을 생성해보자.

bitcoin-cli -named createwallet wallet_name="w1" disable_private_keys=true blank=true

개인키 정보가 없으므로 disable_private_keys에 true를 입력했다.

편의를 위해 w1 지갑으로 실행하는 명령어의 별칭을 만들자.

alias bc1="bitcoin-cli -rpcwallet=w1"

importdescriptors 명령어를 이용해서 w1 지갑에 지금까지 준비한 지갑 정보를 입력한다.

bc1 importdescriptors '[{ "desc": "'$desc1'", "timestamp": 1455191480, "internal": false, "active": true},{ "desc": "'$desc2'", "timestamp": 1455191480, "internal": true, "active": true}]'

timestamp 부분에는 이 지갑과 관련된 가장 오래된 트랜잭션의 시간을 입력한다. bitcoind는 timestamp에 입력된 시간부터 스캔을 시작한다. 잘못된 값을 입력하면 지갑의 UTXO 정보가 누락될 수 있다. 만약 timestamp 값을 잘못 입력했다면 rescanblockchain 명령어를 이용해서 스캔을 다시 할 수 있다.

bc1 rescanblockchain block_num

block_num 부분에는 스캔 시작을 원하는 블록 번호를 입력한다. 여기서 입력한 블록부터 가장 최근의 블록까지 스캔을 하게 된다.

umbrel에서 importdescriptors 사용하기

umbrel에서 다음과 같이 importdescriptors 명령어를 실행하면 JSON 파싱 에러가 날 수 있다.

~/umbrel/scripts/app compose bitcoin exec bitcoind bitcoin-cli -rpcwallet=w1 importdescriptors '...'

이때는 docker 컨테이너 내부에서 bitcoin-cli 명령어를 직접 사용하면 에러가 나지 않는다. 다음 명령어를 통해 docker 컨테이너 ID를 확보하자.

docker container ls | grep bitcoind
5d0a2a9ed9e1 lncm/bitcoind:v25.1

가장 왼쪽에 출력된 값이 컨테이너 ID이다.

다음 명령어를 통해 컨테이너 쉘을 사용할 수 있다.

docker exec -it 5d0a2a9ed9e1 /bin/sh

접속이 잘 됐다면 이 상태에서 bitcoin-cli 명령어를 사용하면 된다.

PSBT 생성하기

walletcreatefundedpsbt 명령어를 이용해서 하드웨어 지갑으로 보낼 트랜잭션을 생성하자.

bc1 -named walletcreatefundedpsbt outputs='{"bc1...": 0.0005}' options='{"fee_rate":40, "replaceable":true}'
{
"psbt": "...",
"fee": 0.00005640,
"changepos": 1
}

수수료율로 40 sats/vB를 입력했다. replaceable 필드를 통해 opt-in RBF를 활성화했다.

출력된 psbt 필드의 값을 그대로 QR 코드로 만들면 하드웨어 지갑이 인식하지 못한다. 카메라를 통한 QR 코드 인식 방식은 오인식의 가능성이 있으므로 데이터 무결성을 보장해주는 방식이 필요하다. 사실상의 표준으로 Uniform Resource(UR) Types가 많이 사용된다. UR Types는 효율적인 인코딩, 데이터 분할 등의 추가적인 이점을 제공한다. PSBT 데이터가 큰 경우에는 하나의 QR 코드에 모든 정보를 담을 수 없으므로 UR Types의 데이터 분할 기능이 도움이 된다.

PSBT 데이터가 큰 경우 아래처럼 여러 장의 QR 코드를 순차적으로 보여주는 방식을 사용한다.

QR 코드 생성하기

지금부터 PSBT 데이터로부터 QR 코드를 생성해보자.

사용한 패키지

  • UR Types 인코딩/디코딩: @keystonehq/bc-ur-registry
  • QR 코드 생성: qr-code-generator
  • QR 코드 스캔: qr-scanner

구현

먼저 PSBT 데이터로부터 UR 인코더를 생성한다.

const psbt = "...";
const MAX_QR_LENGTH = 600;
const hex = base64ToHex(psbt);
const size = hex.length / 2;
const prefix = "59" + numberToHex(size);
const cbor = prefix + hex;
const cryptoPSBT = CryptoPSBT.fromCBOR(Buffer.from(cbor, "hex"));
const encoder = cryptoPSBT.toUREncoder(MAX_QR_LENGTH);

psbt 변수에는 앞에서 walletcreatefundedpsbt 명령어를 통해 생성한 값을 넣는다. cbor 변수를 만드는 과정은 여기를 참고했다.

이제 encoder 객체로부터 QR 코드 목록을 만들자.

const newQrList: QrCode[] = [];
for (let i = 0; i < encoder.fragmentsLength; i++) {
newQrList.push(
QrCode.encodeText(encoder.nextPart().toLocaleUpperCase(), Ecc.LOW)
);
}

각 QR 코드가 MAX_QR_LENGTH를 넘지 않도록 분할해서 QR 코드 목록을 생성했다.

전체 코드는 여기에서 확인할 수 있다. 실제 동작하는 웹사이트는 여기에서 확인할 수 있다.

아래는 하드웨어 지갑에서 QR 코드를 인식한 결과 화면이다.

서명된 QR 코드 읽기

하드웨어 지갑에서 서명을 진행하면 지갑 화면에 QR 코드가 노출된다.

아래는 qr-scanner 패키지를 이용해서 QR 코드로부터 서명된 트랜잭션 데이터를 얻는 코드다.

const urRegistryDecoder = new URRegistryDecoder();
const qrScanner = new QrScanner(
videoRef.current,
(result) => {
if (result.data) {
urRegistryDecoder.receivePart(result.data);
if (urRegistryDecoder.isComplete()) {
try {
const cryptoPSBT = urRegistryDecoder.resultRegistryType();
if (isPSBT(cryptoPSBT)) {
const hex = cryptoPSBT.getPSBT().toString("hex");
const signedPsbt = hexToBase64(hex);
}
} catch {}
qrScanner.stop();
}
}
},
{}
);
qrScanner.start();

하드웨어 지갑의 QR 코드도 UR Types 형태로 전달된다 이를 receivePart 함수로 입력한다. 그리고 signedPsbt 변수에 서명된 트랜잭션 데이터가 저장된다.

서명된 트랜잭션을 쉘의 psbt 변수에 저장하고 finalizepsbt 명령어를 이용해서 최종 트랜잭션 데이터를 얻을 수 있다.

bc1 finalizepsbt $psbt
{
"hex": "...",
"complete": true
}

QR 코드를 읽는 코드도 여기에서 확인할 수 있다. 마찬가지로 여기에서 실제 동작을 확인할 수 있다.

댓글 삭제 시 메일 주소가 필요합니다