[밑바닥비트코인] 9. p2sh 스크립트

2021. 8. 16. 19:12Blockchain

Multi-signature (multisig) refers to requiring multiple keys to authorize a Bitcoin transaction, rather than a single signature from one key. It has a number of applications.

1. Dividing up responsibility for possession of bitcoins among multiple people.

2. Avoiding a single-point of failure, making it substantially more difficult for the wallet to be compromised.
3. m-of-n backup where loss of a single seed doesn't lead to loss of the wallet.

출처 : https://en.bitcoin.it/wiki/Multi-signature

 

위는 다중서명(Multisig)을 하는 이유와 그에 대한 효과에 대해서 말하고 있다. 

 

직영하자면 다중서명은 비트코인 트랜잭션을 인증하기 위해 (단일 서명-단일 키가 아닌) 다수의 키가 필요한 것이다. 

 

비트코인 소유에 대해 다수의 사람들에게 책임을 분산시키고 단일 개인키로 야기되는 단일 실패 지점 문제를 해결한다.

 

 

 

다중서명 구현 


출처 : https://medium.com/@ackhor/ch10-3-something-on-p2sh-unlocking-locking-script-482038515392

 

위 그림은 3-2 다중서명의 매커니즘을 도식화하였다. 

 

m-of-n 다중서명 n개의 공개키로 잠긴 스크립트를 그보다 적은 m개의 서명으로 해제한다.

 

먼저 송신자는 3개의 공개키를 사용하여 잠금스크립트를 구성하고, 

 

수신자 측은 2명의 서명을 통해서 이를 해제한다. 

 

이제 다중서명 스크립트를 처리하는 코드를 구현해보도록 하자.

def op_checkmultisig(stack, z):
    if len(stack) < 1:
        return False
    n = decode_num(stack.pop())
    if len(stack) < n + 1:
        return False
    sec_pubkeys = []
    for _ in range(n):
        sec_pubkeys.append(stack.pop())
    m = decode_num(stack.pop())
    if len(stack) < m + 1:
        return False
    der_signatures = []
    for _ in range(m):
        der_signatures.append(stack.pop()[:-1])
    stack.pop()
    try:
        points = [S256Point.parse(sec) for sec in sec_pubkeys]
        sigs = [Signature.parse(der) for der in der_signatures]
        for sig in sigs:
            if len(points) == 0:
                return False
            while points:
                point = points.pop(0)
                if point.verify(z, sig):
                    break
        stack.append(encode_num(1))
    except (ValueError, SyntaxError):
        return False
    return True

다중서명 스크립트 검증 절차를 수행하는 OP 코드이다.

    if len(stack) < 1:
        return False
    n = decode_num(stack.pop())
    if len(stack) < n + 1:
        return False

먼저 처음 스크립트가 텅 비어있으면 False를, 아니라면 스택의 가장 위 값을 읽어들여 공개키의 개수를 저장한다. 

 

남은 스택의 개수가 n(공개키)보다 작다면 False 반환한다. 

    sec_pubkeys = []
    for _ in range(n):
        sec_pubkeys.append(stack.pop())
    m = decode_num(stack.pop())
    if len(stack) < m + 1:
        return False
    der_signatures = []
    for _ in range(m):
        der_signatures.append(stack.pop()[:-1])

공개키의 개수(n)만큼 스택에서 pop해여 공개키 리스트(sec_pubkey)에 저장한다. 

 

그 다음 서명의 개수(m)를 pop하고, 남은 스택이 m 이하라면 False를 반환한다. 서명도 마찬가지로 

 

서명 리스트(der_signatures)에 저장한다.

    stack.pop()

마지막 원소(off-by-one 버그 방지용)도 pop한다.

    try:
        points = [S256Point.parse(sec) for sec in sec_pubkeys]
        sigs = [Signature.parse(der) for der in der_signatures]
        for sig in sigs:
            if len(points) == 0:
                return False
            while points:
                point = points.pop(0)
                if Verify.verify(sig, z, point):
                    break
        stack.append(encode_num(1))

공개키 리스트와 서명 리스트에서 원소마다 순회하여 이를 parsing한 결과를 다시 리스트 형태로 저장한다. 

 

Sigs 리스트를 순회하여 서명을 검증한다. 하나의 서명(sig)이 공개키들(points)을 순회하며 검증에 성공(verify)하면 

 

다음 서명(sig)으로 넘어가서 동일한 과정을 수행한다. 

 

모든 반복문이 끝나면 스택에 1을 남긴다. 

 

단일 개인키에 비해서 더 강한 보안이 장점인 다중서명 방식은 비효율적으로 긴 잠금 스크립트와 같은 

 

단점을 지니고 있다. 잠금 스크립트가 길어질수록 노드와 블록체인 네트워크에는 부담일 수 밖에 없다. 

 

 

 

p2sh(Pay-to-Script-Hash)


다중서명의 단점을 해결하기 위해서 등장한 것이 p2sh이다. 

 

p2sh에서는 위에서 살펴본 잠금스크립트 대신 이의 해시값을 제시하고, 이 해시값이 일치하면 

 

(수신자 측이 가지고 있는) 잠금 스크립트를 꺼내 검증하는 방식이다.

 

p2sh에서 전체 잠금 스크립트를 리딤 스크립트(redeem script)라고 부른다. 

출처 : https://www.researchgate.net/figure/P2SH-script-validation_fig5_335146032

 

위는 p2sh의 일부 동작방식을 나타낸 그림이다. 

 

p2sh는 위와 같은 패턴을 만나면 리딤 스크립트의 해시값(하나는 송신자 것, 하나는 수신자 것)을 비교하고 이것이 

 

동일하면 리딤 스크립트로 서명과 공개키를 검증한다. 

 

class Script:
    def evaluate(self, z):
    	.
        .
        while len(cmds) > 0:
            cmd = cmds.pop(0)
            if type(cmd) == int:
            .
            .
            else:
                stack.append(cmd)
                if len(cmds) == 3 and cmds[0] == 0xa9 \
                    and type(cmds[1]) == bytes and len(cmds[1]) == 20 \
                    and cmds[2] == 0x87:  # <1>
                    cmds.pop()  # <2>
                    h160 = cmds.pop()
                    cmds.pop()
                    if not op_hash160(stack):  # <3>
                        return False
                    stack.append(h160)
                    if not op_equal(stack):
                        return False
                    if not op_verify(stack):  # <4>
                        LOGGER.info('bad p2sh h160')
                        return False
                    redeem_script = encode_varint(len(cmd)) + cmd  # <5>
                    stream = BytesIO(redeem_script)
                    cmds.extend(Script.parse(stream).cmds)  # <6>
                    .
                    .

p2sh를 위해 Script 클래스의 evaluate 함수를 변형하였다. 

                stack.append(cmd)
                if len(cmds) == 3 and cmds[0] == 0xa9 \
                    and type(cmds[1]) == bytes and len(cmds[1]) == 20 \
                    and cmds[2] == 0x87:  # <1>

처음 읽어들인 cmd가 int형이 아닌 경우에 해당하기 때문에 redeem script에 해당한다는 것을 알 수 있다. 

 

이 리딤스크립트를 일단 stack에 쌓는다.

 

0xa9는 OP_HASH160, 0x87은 OP_EQUAL이다. 중간에 위치한 리딤 스크립트의 해시값은 길이 20의 바이트형이다. 

                    cmds.pop()  # <2>
                    h160 = cmds.pop()
                    cmds.pop()

특별한 패턴을 만났으므로 처음 pop한 값은 OP_hash160이다. 

 

그 다음 리딤 스크립트의 해시값을 pop하여 h160에 저장한다. 

 

마지막으로 OP_EQUALVERIFY를 pop한다. 

                    if not op_hash160(stack):  # <3>
                        return False
                    stack.append(h160)
                    if not op_equal(stack):
                        return False
                    if not op_verify(stack):  # <4>
                        LOGGER.info('bad p2sh h160')
                        return False

스택의 가장 위에 남아있는 값이 리딤스크립트이므로 op_hash160 함수를 통해 이의 해시값을 구한다. 

 

그리고 h160을 스택에 쌓고 OP_EQUAL을 통해 서로 같은지 검증한다. 

 

OP_VERIFY로 스택에 1이 남아있는지 검증한다. 

               redeem_script = encode_varint(len(cmd)) + cmd  # <5>
               stream = BytesIO(redeem_script)
               cmds.extend(Script.parse(stream).cmds)  # <6>

cmd에 있는 리딤 스크립트의 길이를 인코딩하여 앞에 위치시킨 뒤  Script.parse를 통해 parsing하여 

 

다시 cmds에 넣는다. 

 

나머지 과정은 이전에 구현했던 코드들을 통해서 공개키-서명이 검증된다. 

 

 

 

p2sh 서명 검증


p2sh에서 서명을 검증하기 위해 z를 계산하는 과정을 알아보도록 하자. 

class Tx:
    def sig_hash(self, input_index, redeem_script=None):
    s = int_to_little_endian(self.version, 4)
    s += encode_varint(len(self.tx_ins))
    for i, tx_in in enumerate(self.tx_ins):
        if i == input_index:
            if redeem_script:
                script_sig = redeem_script
            else:
                script_sig = tx_in.script_pubkey(self.testnet)
        else:
            script_sig = None
        s += TxIn(
            prev_tx=tx_in.prev_tx,
            prev_index=tx_in.prev_index,
            script_sig=script_sig,
            sequence=tx_in.sequence,
        ).serialize()
    s += encode_varint(len(self.tx_outs))
    for tx_out in self.tx_outs:
        s += tx_out.serialize()
    s += int_to_little_endian(self.locktime, 4)
    s += int_to_little_endian(SIGHASH_ALL, 4)
    h256 = hash256(s)
    return int.from_bytes(h256, 'big')

sig_hash의 전체 코드는 위와 같다. 

    def sig_hash(self, input_index, redeem_script=None):
    s = int_to_little_endian(self.version, 4)
    s += encode_varint(len(self.tx_ins))

version 정보를 4바이트 리틀엔디언으로 변환한다. 그리고 트랜잭션 입력(tx_ins)의 개수를 덧붙인다. 

    for i, tx_in in enumerate(self.tx_ins):
        if i == input_index:
            if redeem_script:
                script_sig = redeem_script
            else:
                script_sig = tx_in.script_pubkey(self.testnet)
        else:
            script_sig = None

입력을 순회하다가 만약 사용하고자 하는 입력과 일치하면서 리딤 스크립트가 존재하면

 

해제 스크립트 자리에 리딤 스크립트를 넣고, 리딤 스크립트가 없으면 이전 트랜잭션의 잠금 스크립트를 가져와서 

 

해제 스크립트의 자리를 대체한다. 

 

사용하고자 하는 입력이 아닐 경우, 잠금 스크립트는 비워둔다.

        s += TxIn(
            prev_tx=tx_in.prev_tx,
            prev_index=tx_in.prev_index,
            script_sig=script_sig,
            sequence=tx_in.sequence,
        ).serialize()

TxIn 객체를 직렬화하여 스트림에 덧붙인다. 

    s += encode_varint(len(self.tx_outs))
    for tx_out in self.tx_outs:
        s += tx_out.serialize()

TxOut도 직렬화하여 스트림에 추가한다. 

    s += int_to_little_endian(self.locktime, 4)
    s += int_to_little_endian(SIGHASH_ALL, 4)
    h256 = hash256(s)
    return int.from_bytes(h256, 'big')

locktime과 SIGHASH_ALL을 4바이트 리틀엔디언으로 변환하여 스트림에 추가한뒤

 

최종 스트림의 hash256값을 계산한다. 이 값의 빅엔디언 정수가 z 값이 된다. 

class Tx:
    def verify_input(self, input_index):
    	tx_in = self.tx_ins[input_index]
    	script_pubkey = tx_in.script_pubkey(testnet=self.testnet)
    	if script_pubkey.is_p2sh_script_pubkey():
        	cmd = tx_in.script_sig.cmds[-1]
        	raw_redeem = encode_varint(len(cmd)) + cmd
        	redeem_script = Script.parse(BytesIO(raw_redeem))
    	else:
        	redeem_script = None
    	z = self.sig_hash(input_index, redeem_script)
    	combined = tx_in.script_sig + script_pubkey
    	return combined.evaluate(z)

입력을 검증하는 함수인 verify_input 함수이다. 

    	tx_in = self.tx_ins[input_index]
    	script_pubkey = tx_in.script_pubkey(testnet=self.testnet)

사용하고자하는 입력을 tx_in으로 지정한다. 그리고 이전 트랜잭션의 잠금 스크립트를 가져온다. 

    if script_pubkey.is_p2sh_script_pubkey():
        cmd = tx_in.script_sig.cmds[-1]
        raw_redeem = encode_varint(len(cmd)) + cmd
        redeem_script = Script.parse(BytesIO(raw_redeem)    	
    else:
        redeem_script = None

잠금 스크립트가 p2sh 스크립트의 형태(특별한 패턴)를 띄고 있다면, 

 

입력의 해제 스크립트의 cmds의 가장 윗 값(raw 리딤 스크립트)을 가져와서 parsing한다. 

    z = self.sig_hash(input_index, redeem_script)
    combined = tx_in.script_sig + script_pubkey
    return combined.evaluate(z)

z값을 계산하고 전체 스크립트를 구성해서 검증한다.