2

Original Thread: Why OP_DUP instead of providing the PubKey twice in P2PKH? I am struggling to understand the logic in the most popular answer and my "reputation" is < 50 so I cannot comment my response.

If the specification for a different single key type of p2pk transaction required only a single public key there would be no situation in which a miner was able to provide a second key. For example you have explained that the setup for the transaction looks like OP_HASH160 <hash(P1)> OP_EQUALVERIFY OP_CHECKSIG if this is true then the only way in which someone would be able to produce an output that looks like this <sig with P2> <P2> <P1> (with their own public key) is by modifying the number of public keys used in the transaction. The specification for a transaction with a single public key should not be able to be spent by any other public key, even one that can prove it is derived from the private key which owns the original. I understand that this may not align with the core logic of bitcoin script, if this is the case I would really appreciate an explanation into why. It seems cryptographically secure, is the security issue with the logical implementation of transactions?

I'm also not following how the core logic would not be able to tell that a different public key is not a valid duplicate of the original key (in the situation where we simply provided one or two public keys in a row)? Your answer seems to suggest that the vulnerability is within the ability for anyone to add a key to the transaction and sign for it. However if the key was anything different from the original public key it would not really be the result of a valid OP_DUP right? You would not be able to verify the signature of that transaction with the original public key, is that not enough to prove it is coming from another key and is therefore an invalid signature?

Overall it seems to me that the vulnerability scenario where a miner provided an alternative public key would rely on the ability for there to be more than one public key variable semantically in the transaction. The OP_DUP semantic in this situation causes this theoretical vulnerability since it is apparently implicitly duplicated and is not verifiably duplicated. If you remove the duplication entirely you have a transaction in which a public key references an output from which it owns and provides the signature proving this owner relationship. So while I understand why removing the OP_DUP could cause vulnerability with the current implementation, I struggle to understand why a much simpler single key transaction could not be possible to remove all of this overhead.

3 Answers 3

5

Re-reading your question, I realize my other answer may be besides the point.

It is certainly possible that the scripting language's semantics would recognize this scenario and cause failure if someone were to try spending transaction outputs of this form, if the two public keys don't match. After all, the script does see both these keys as input.

The point is simply that that is not what the scripting language does. Even though a large majority of transactions use scripts of this form, it is not the only thing the scripting language is designed for. It's a programming language, and the interpreter does not try to guess what the users' intent was when executing. It simply executes the script, opcode by opcode, with the semantics those opcodes have. And if the outcome is a true value on the stack at the end, the spend is valid.

If we're going to accept making changes to the scripting language with the purpose of making typical scripts shorter, we can go even further. For example, we could say that if the scriptPubKey is just a pubkey hash (and no opcodes whatsoever), then the scriptSig must be exactly a public key (matching that hash) and a signature (valid for the provided public key). That would save another 3 bytes in every output.

This is perfectly feasible with a softfork consensus change, but you have to recognize that this is arguably bypassing the point of the scripting language: allowing users to customize how their outputs can be spent, for the purpose of making a (very) common case shorter.

In fact, such softforks have happened: segwit (BIP141) and taproot (BIP341) both introduced new, very short, scriptPubKey types with special semantics that don't include any opcodes (or just one, which functions as a version number), because they envision users will very commonly want these.

7
  • This makes a ton of sense thank you for responding so quickly. Do you think it would it be a practical addition to bitcoin script to have this? Or as you said this has basically been implemented with segwit and taproot. I am pretty new to Bitcoin (2020) as you might possibly tell. It was hard for me to imagine how UTXO works without understanding the p2pk scripts. It also explains the added privacy of generating new address' which is often not explained in this context. So the transaction model that would save 3 bytes would be way less private which is just a poor trade-off I suppose.
    – Poseidon
    Commented Dec 26, 2022 at 22:38
  • 1
    I think there is no point to have something like this, no; segwit already addresses this use case already, in an even cheaper way. Commented Dec 26, 2022 at 22:45
  • That's what I figured, I have never heard of this point of view about segwit, it pretty much explains everything I was wondering.
    – Poseidon
    Commented Dec 26, 2022 at 22:46
  • It wasn't the main benefit/purpose of segwit, but as it had a need to introduce effectively a new type of script anyway, that opportunity was used to make some minor efficiency improvements too. Commented Dec 26, 2022 at 22:52
  • The purpose was to enable lightning correct? I suppose it also enables layer-2's that use a similar model to lightning, was this part of the consideration or was it focused towards working specifically for lightning network? Seems like a very good thing for interoperability with side chains.
    – Poseidon
    Commented Dec 26, 2022 at 23:09
4

The first thing to remember here is that the entire script that is executed comes in two parts. The first part comes in the output that is being spent. This script fragment is incomplete - if you run it in the script interpreter by itself, you will get an error. The second part comes in the input that spends the output. When verifying a transaction, the input script and the output script are essentially concatenated and executed together as a single script (they aren't actually concatenated but it's easier to think of it that way).

The next thing to remember is that the script interpreter is relatively dumb. It's a simple machine whose only memory is the stack and executes operations as it sees them. It does not know about standard script patterns and so cannot assign any special meaning or treatment to specific script patterns. It also does not know what kind of data a stack element is before it executes an opcode. It will take whatever is on the stack and interpret it as needed for the opcode, even if it's the wrong data type.

If your output script is OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG, this is just a script fragment that requires an input script to provide the necessary data to satisfy it. The input script you propose would be <push(sig with P1)> <push(P1)> <push(P1)> which results in a combined script of <push(sig with P1)> <push(P1)> <push(P1)> OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG.

To make it easier to follow the script execution, I'm going to notate the P1s as P1_a and P1_b.

We can walk through how this would verify:

Remaining Script Current Operation Resulting Stack (top is first)
<push(P1_a)> <push(P1_b)> OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(sig with P1)> <sig with P1>
<push(P1_b)> OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(P1_a)> <P1_a> <sig with P1>
OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(P1_b)> <P1_b> <P1_a> <sig with P1>
<push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG OP_HASH160 <hash(P1_b)> <P1_a> <sig with P1>
OP_EQUALVERIFY OP_CHECKSIG <push(hash(P1))> <hash(P1)> <hash(P1_b)> <P1_a> <sig with P1>
OP_CHECKSIG OP_EQUALVERIFY <P1_a> <sig with P1>
OP_CHECKSIG <true>

As you can see, we end with P1_a and sig with P1 as the two stack elements that go into the OP_CHECKSIG. These two are provided by the person trying to spend the transaction. As mentioned in the previous answer, an input script of <sig with P2> <P2> <P1> can satisfy this output script as well. Let's walk through that to see how it would work:

Remaining Script Current Operation Resulting Stack (top is first)
<push(P2)> <push(P1)> OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(sig with P2)> <sig with P2>
<push(P1)> OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(P2)> <P2> <sig with P2>
OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(P1)> <P1> <P2> <sig with P2>
<push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG OP_HASH160 <hash(P1)> <P2> <sig with P2>
OP_EQUALVERIFY OP_CHECKSIG <push(hash(P1))> <hash(P1)> <hash(P1)> <P2> <sig with P2>
OP_CHECKSIG OP_EQUALVERIFY <P2> <sig with P2>
OP_CHECKSIG <true>

This results in success as well, hence your script is vulnerable. The reason it works is because P2 is never verified to be the public key that is specified by the hash. The script interpreter does not know or care that this could be a standard script, so it does not know to enforce that P2 has to be the same as P1. The script itself needs to do that, and this script fails to do that because it does not tell the interpreter to check that the two stack elements are equal.

As for how this could be exploited, anyone who observes the transaction with your intended input script would learn what P1 is. Then anyone can copy that P1 and produce a transaction that has the second script with P2.

Lastly, let's look at how the actual script with OP_DUP works. The output script is OP_DUP OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG and the input script is <sig with P1> <P1>

Remaining Script Current Operation Resulting Stack (top is first)
<push(P1)> OP_DUP OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(sig with P1)> <sig with P1>
OP_DUP OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG <push(P1)> <P1> <sig with P1>
OP_HASH160 <push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG OP_DUP <P1_dup> <P1> <sig with P1>
<push(hash(P1))> OP_EQUALVERIFY OP_CHECKSIG OP_HASH160 <hash(P1_dup)> <P1> <sig with P1>
OP_EQUALVERIFY OP_CHECKSIG <push(hash(P1))> <hash(P1)> <hash(P1_dup)> <P1> <sig with P1>
OP_CHECKSIG OP_EQUALVERIFY <P1> <sig with P1>
OP_CHECKSIG <true>

This is secure because we duplicate P1 exactly and then hash it and check the hashes. Because we duplicated it, we know that it must be the same and if the hashes match, then the original also has the same hash. This ensures that the public key that we use in the OP_CHECKSIG is the public key that was intended.

Of course this assumes that OP_DUP duplicates exactly as we expect it to. This is a reasonable assumption - we're assuming that all of the other opcodes work as we expect them to as well. If we didn't, then any script could verify because the opcodes could be doing anything.

Lastly, the script interpreter could have been written to give your script special treatment. It could have been written to behave differently if it saw this particular script template and verify that the top two stack elements were identical. Then this would not be vulnerable. However it is not written that way, and so we cannot rely on this behavior. Furthermore, the script construction that you have described is also inefficient. It's a harder template to match, and it requires actually providing duplicated public keys, which for some transactions, can result in a significant portion of their size. This will then result in increased transaction fees and less block space. So overall, the DUP method is more concise and safer.

It should also be noted that Segwit does introduce a simple single key construction that doesn't actually rely on script and is really just template matching. This is likely more in line with what you are suggesting.

3
  • First of all thank you for the detailed and timely response. I suppose my follow up is to question WHY the interpreter is written to be so dumb? We do a lot of work in bitcoin to generate valid outputs, verification of those outputs might be more expensive to do with a smarter interpreter but it would be very secure would it not?
    – Poseidon
    Commented Dec 26, 2022 at 17:44
  • 1
    Well it's an interpreter - its job is to interpret. As for why Satoshi chose this design in the initial Bitcoin protocol: you'll have to ask him to be sure, but it makes sense: it's very flexible, because anyone can design their own spending conditions, rather than be restricted to perhaps just a few standard constructions. Do not however, that this is not how Bitcoin commonly works anymore. Since the introduction of segwit, there are specialized constructions for just-a-pubkey-hash that don't involve any explicit opcodes. Commented Dec 26, 2022 at 21:28
  • It's very interesting to think about the evolution of the design behind transactions, I agree it makes tons of sense but it also just makes me wonder how this design was produced and if there is some key concepts behind it that I might be misunderstanding. The cryptography makes tons of sense to me but when it gets into the opcode behaviors I start to have many of these questions, perhaps because computers and computer science was explained very differently at the time of bitcoin's genesis than it is in the modern day of flashy titles on yt videos. Thanks anyways, for all your work!
    – Poseidon
    Commented Dec 26, 2022 at 22:42
3

Let's work out the hypothetical scenario. Alice sends coins to Bob, and then to Carol, and the malicious miner Mallory tries to steal the last sent coins.

The honest scenario is this:

  • Bob generates a keypair privB / pubB.
  • Bob creates a script OP_HASH160 <hash(pubB)> OP_EQUALVERIFY OP_CHECKSIG.
  • Bob gives this script to Alice by encoding it in an address (in this hypothetical world, there is an address format for such scripts).
  • Alice constructs a transaction tx1 which sends 0.1 BTC to that OP_HASH160 <hash(pubB)> OP_EQUALVERIFY OP_CHECKSIG script.
  • The transaction confirms.
  • Now Bob creates a transaction tx2 that spends the received 0.1 BTC UTXO, using his private key peivB, and sends it Carol (by giving the transaction an output with scriptPubKey equal to Carol's script). The scriptSig for tx2's input would be <sigB> <pubB> <pubB>. Running the spent output's scriptPubKey after this would yield success: the hashed key matched the hash in the script, and the signature matches the public key.

Now, Mallory the miner sees this transaction, and steals it:

  • Mallory generates their own keypair privM / pubM.
  • Mallory sees tx2, but creates an alternative tx2, where the input spending Bob's UTXO is replaced with <sigM> <pubM> <pubB>, and an output sending to himself.

This input is also valid! If you work it out, the stack will be:

  • Initially: [sigM, pubM, pubB].
  • Execute OP_HASH160: [sigM, pubM, hash(pubB)].
  • Execute <hash(pubB)>: [sigM, pubM, hash(pubB), hash(pubB)].
  • Execute OP_EQUALVERIFY: [sigM, pubM] (the two last stack elements were equal, so no failure).
  • Execute OP_CHECKSIG: [1] (the signature sigM is a valid signature made with the private key corresponding to public key pubM, so the signature check succeeds).
  • Success because end of script is reached and there is a 1 on top of the stack.

So despite the fact that the signature and key don't match the hash of pubB, the signature was verified against the miner's own public key, spending the coins.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.