이번에는 키와 지갑에 대해 이야기해보자. 일반적으로 우리가 암호화폐 지갑으로 부르는 것들은 유저 인터페이스상에 주소로 표현되며, 잔액을 표시할 수 있어 지갑이 코인을 가지고 있는 것으로 보인다. 하지만 지갑은 알고보면 그냥 공개키(Public Key)와 개인키(Private Key)를 가지고 있는 것 뿐이다. 그렇다면 주소는 어디있으며 잔액은 어디에 있는가에 대한 질문이 생기게되는데 잔액의 경우 이전 포스트에서 UTXO(Unspent Transaction Output)의 총합으로 처리할 수 있다고 했다.
공캐키와 개인키
공캐키(Public Key)와 개인키(Private Key)는 암호학에서 나오는 개념이며 암호화의 본질은 다른 사람이 메시지를 읽지 못하도록 하고 허락된 당사자만이 처리할 수 있도록 하는 것인데, 우리가 이전에 사용하던 공인인증서에도 그러한 개념이 적용되어 있다. 공개키와 개인키는 수학적으로 연관되어 있어서 개인키가 있으면 공개키를 알 수 있지만, 공개키만을 가지고 개인키를 찾을 수는 없도록 되어있다.
블록체인에서는 메시지를 암호화한다기 보다는 거래를 검증하기 위해 사용한다. 공개키는 모두에게 공개되어 있는 것이며 개인키는 마치 비밀번호처럼 반드시 지켜야할 존재로 여겨진다. 두 키는 쌍이 하나의 세트이기 때문에 독립적으로 사용될 수 없다. 이 두 키를 이용하는 방법은 일반적으로 두 가지가 있다.
거래당사자 A, B가 있다고 가정해보자.
- A 가 메시지를 보낼때 오직 B 만이 읽고 검증 (A 가 메시지를 B의 공개키로 잠그고, B 는 자신의 개인키로 메시지를 검증)
- A 가 메시지를 보내면서, A 가 메시지를 보냈다는 것을 증명 (A 가 메시지를 자신의 개인키로 잠그고, B 는 A의 공개키로 메시지를 검증)
위 두 개의 사례는 거래에서 분명하게 사용된다. 전자의 경우 B 에게 자금을 전송하기 위해 TXOuput.ScriptPubKey
를 묶었을 때 우리는 상대방의 공개키로 묶는다. 그 이유인 즉슨 해당 UTXO 는 오직 금을 전송받는 당사자만이 사용할 수 있어야하기 때문이다. 후자의 경우 A 가 거래를 생성하여 보냈다는 것을 분명히 증명하기위한 것으로 사용한다. 이 경우 TXInput
에서 메시지를 개인키로 잠궈야 하는데, 그 결과로 디지털 서명(Digital Signature)가 생성된다. 또한 서명 검증을 위해 A의 공캐키가 첨부되어야 거래 당사자인 B 가 서명을 검증할 수 있다.
타원곡선암호화의 수학적 이해에 대해서는 http://wiki.hash.kr/index.php/%ED%83%80%EC%9B%90%EA%B3%A1%EC%84%A0%EC%95%94%ED%98%B8 를 참고할 수 있다.
암호학적 난수생성기(CSPNG; Cryptographically Secure Pseudorandom Number Generator)
개인키는 간단히 말하자면 난수생성기로 생성한 난수다. 하지만 개인키를 생성할 때의 난수는 예측가능한 일반적인 난수생성기를 사용해서는 안 되고 반드시 암호학으로 안전하며 예측하기 어려운 난수생성기를 사용해야 한다고 이야기하고 있다. 개인키가 노출되거나 예측을 하게되는 경우 암호화폐에 대한 제어권한을 탈취당하기 때문에 개인키를 생성하는 부분은 중요한 부분이기 때문이다.
type Wallet
우리가 아는 일반적인 지갑과는 다르게 지갑 자체가 암호화폐를 가지고 있는 것도 아니고, 심지어 주소를 가지고 있는 것도 아니다. 지갑은 그저 개인키와 공개키를 가지고 있다. 따라서 다음과 같이 타입을 만들 수 있다.
type Wallet struct {
PrivKey *ecdsa.PrivateKey
PubKey []byte
}
ECDSA(Elliptic Curve Digital Signature Algorithm)는 타원곡선암호화를 뜻한다. 타원곡선암호화 자체에 대해 이야기하지는 않겠지만, ECDSA 가 개인키에서 공개키를 만들때, 그리고 디지털 서명을 만들때 사용된다는 점이다. 여기서 다시한 번 강조하고 싶은 부분이 있다면, 공개키는 개인키로부터 도출된다는 점이다. 개인키(k) * 타원곡선 생성포인트(G)를 곱한것이 공개키(K)다.
공개키(K) = 개인키(k) * G
생성포인트 G 는 x, y 좌표가 있는데, 이 두개를 이어주면 공개키를 얻어올 수 있다. 이부분은 아래의 NewWallet()
함수에서 살펴보도록 하자.
func NewWallet() *Wallet
지갑을 새로 생성하는 함수다. 난수생성기와 ECDSA 를 사용하여 개인키를 생성하고 생성포인트를 사용하여 공개키를 생성한 후 지갑에 지정해준다. 함수 내에 쓰여있는 rand
의 경우 math/rand
가 아닌 crypto/rand
라는 것을 기억하자. 비트코인에서는 공개키의 경우 키포맷(압축, 비압축)에 따라 따라오는 접두사가 있는데, 여기서는 생략했다.
func NewWallet() *Wallet {
curve := elliptic.P256()
privKey, err := ecdsa.GenerateKey(curve, rand.Reader)
if err != nil {
log.Panic(err)
}
pubKey := append(privKey.PublicKey.X.Bytes(), privKey.PublicKey.Y.Bytes()...)
return &Wallet{privKey, pubKey}
}
func .GetAddress() string
지갑에 공개키와 개인키 쌍을 설정했다면, 이제 주소를 생성할 수 있다. 결론부터 말하자면, 주소는 개인키로부터 도출된다. 비트코인 주소의 경우 주소의 접두사로 1
이 붙는 것을 볼 수 있다. 공개키를 더블 해싱(Double-Hashing)하여 SHA256, RIPEMD160 를 각각 한 번씩 해주고, 비트코인 주소를 의미하는 버전 접두어 0x00
을 붙인 다음, 마지막으로 Base58CheckEncode 를 해주면 주소가 생성된다.
func (w *Wallet) GetAddress() string {
publicRIPEMD160 := HashPubKey(w.PubKey)
version := byte(0x00)
return base58.CheckEncode(publicRIPEMD160, version)
}
Base58 인코딩은 Base64 에서 표현상 일부 헷갈리는 문자 0, o 와 같은 것들을 제외한 것이다. Check 가 붙어있기 때문에 내부적으로 Checksum 을 덧붙인다.
Golang 에서 내부적으로 base58 을 지원하지 않는다. (https://github.com/btcsuite/btcutil)
RIPEMD160 패키지도 DEPRECATED 되었기에 별도로 받아야 한다. (golang.org/x/crypto/ripemd160)
func HashPubKey() []byte
이 함수는 주어진 공개키를 SHA256, RIPEMD160 해싱처리하고 반환해준다. 이 함수를 밖으로 빼낸 이유는 공개키를 해싱하여 처리할 일이 트랜잭션(Transaction)에서 많기 때문이다.
func HashPubKey(pubKey []byte) []byte {
publicSHA256 := sha256.Sum256(pubKey)
RIPEMD160Hasher := ripemd160.New()
_, err := RIPEMD160Hasher.Write(publicSHA256[:])
if err != nil {
log.Panic(err)
}
return RIPEMD160Hasher.Sum(nil)
}
type KeyStore
여기서 말하는 키스토어는 다른 것이 아닌, 키를 가지고 있는 지갑들을 다수 보관하기 위한 저장소를 의미한다. 지갑들은 wallet.dat
라는 파일에 저장할 것이다. 나중에 CLI 를 통해 앱을 동작시킬 때 주소를 통해 할 것이고 한 번 생성한 지갑을 일회용으로 쓸 것이 아닌이상은 저장해두는 것이 필수다.
const walletFile = "wallet.dat"
type KeyStore struct {
Wallets map[string]*Wallet
}
.Wallets 의 키로는 생성된 주소가 들어갈 것이며 값으로는 그에 해당하는 *Wallet 이 들어갈 예정이다.
func createKeyStore() error
이 함수는 단순히 wallet.dat
파일을 만들기 위한 것이다. 큰 의미를 가지지는 않지만 분명히 필요하다. 함수의 이름이 소문자로 시작하기때문에 외부에서 접근하지 않는 것들 전제로한다.
func createKeyStore() error {
file, err := os.OpenFile(walletFile, os.O_CREATE, 0644)
if err != nil {
return err
}
return file.Close()
}
func NewKeyStore() *KeyStore
wallet.dat
파일을 읽어와 새로운 KeyStore
를 반환한다. 만약 wallet.dat
파일이 없다면 생성하고 비어있는 KeyStore
를 반환한다.
func NewKeyStore() *KeyStore {
keyStore := KeyStore{make(map[string]*Wallet)}
if _, err := os.Stat(walletFile); os.IsNotExist(err) {
err := createKeyStore()
if err != nil {
log.Panic(err)
}
} else {
fileContent, err := ioutil.ReadFile(walletFile)
if err != nil {
log.Panic(err)
}
gob.Register(elliptic.P256())
decoder := gob.NewDecoder(bytes.NewReader(fileContent))
err = decoder.Decode(&keyStore)
if err != nil {
log.Panic(err)
}
}
return &keyStore
}
func .Save()
.Wallets
와 wallet.dat
파일을 내용을 동기화시키기 위해 KeyStore
자체를 인코딩하여 저장한다.
func (ks *KeyStore) Save() {
buf := new(bytes.Buffer)
gob.Register(elliptic.P256())
encoder := gob.NewEncoder(buf)
err := encoder.Encode(ks)
if err != nil {
log.Panic(err)
}
err = ioutil.WriteFile(walletFile, buf.Bytes(), 0644)
if err != nil {
log.Panic(err)
}
}
func .CreateWallet() *Wallet
지갑을 만들고 키스토어에 저장한다.
func (ks *KeyStore) CreateWallet() *Wallet {
wallet := NewWallet()
ks.Wallets[wallet.GetAddress()] = wallet
ks.Save()
return wallet
}
type CLI
주소를 생성하는 커맨드를 추가해서 테스트해보자!
func .newWallet() string
CLI 에 쓰일 메서드다. 지갑을 만들고 주소를 반환한다.
func (c *CLI) newWallet() string {
return wallet.NewKeyStore().CreateWallet().GetAddress()
}
func .Run()
func (c *CLI) Run() {
// ...
newWalletCmd := flag.NewFlagSet("newwallet", flag.ExitOnError)
switch os.Args[1] {
// ...
case "newwallet":
newWalletCmd.Parse(os.Args[2:])
}
// ...
if newWalletCmd.Parsed() {
fmt.Printf("Address: %s", c.newWallet())
}
}
결론
우리가 여기서 만든 것을 실행해보면 다음과 같이 나온다. 해당 주소는 blockchain.info 에 가서 확인해보면 유효한 주소임을 알 수 있다.
$ ./bc newwallet
Address: 1Fuim2277hQAkMH4KqZ75EbGm7nAPZA7nJ
$ ./bc newwallet
Address: 13RKDDXoHj4khXYkWpx37suWoMDE2Zrmo5
해당 포스트의 코드만 작성하고나서는 빌드가 정상적으로 되지 않을 것이다. 트랜잭션 부분에서 변경사항에 대해 아직 손을 봐야할게 있기 때문이다. 그에 대한 것은 내용이 다소 많아서 다음 포스트에 하기로 하자.