Open j-berman opened 3 years ago
Accounting for the locked outputs edge case is nice to consider, but it absolutely is an edge case.
How would selection occur if someone is trying to spend a previously-locked output?
How would selection occur if someone is trying to spend a previously-locked output?
The output would be placed in a bin of outputs created around the same time. So if you lock an output for 1 year and spend it right when it unlocks, then it will be placed in a bin of outputs created 1 year prior.
This is basically the same way previously-locked outputs are treated by the algorithm today: in the gamma selection, output ages are assumed based on when the outputs are created, not based on when they unlock (including coinbase outputs).
If alternatively outputs are placed in a bin together with outputs that unlock around the same time, then as @UkoeHB highlighted in this "possible poison attack", it introduces a vector for an attacker to gradually construct many outputs over time that unlock at a specific time in the future.
Accounting for the locked outputs edge case is nice to consider, but it absolutely is an edge case.
I don't think there is a way to implement the algorithm today without accounting for them though. Alternatively this implementation could be kept on hold until after custom timelocks are deprecated (since deprecation seems to have wide support), but I don't see a safe way around not accounting for it in a binning algorithm today, even if the algorithm were to hypothetically go live in the same fork that would deprecate timelocks.
Accounting for the locked outputs edge case is nice to consider, but it absolutely is an edge case.
I don't think there is a way to implement the algorithm today without accounting for them
Yes, the final implementation must account for locks (though I think you've clearly sketched an outline of how they would be handled, which suffices for your PoC). Even if the algorithm were only deployed wallet-side, it's too easy for someone to throw a wrench into the works by timelocking a bunch of outputs (current usage has no impact on propensity for future mischief). In fact, not accounting for locks in any algorithm would be a great way to get more timelocked outputs.
it's too easy for someone to throw a wrench into the works by timelocking a bunch of outputs
In what way is this more effective than simply spamming the same number of outputs, and then revealing these outputs publicly? The damage is already possible.
In what way is this more effective than simply spamming the same number of outputs, and then revealing these outputs publicly? The damage is already possible.
If the algorithm rejects locked outputs (which it must to avoid being possibly worse than existing ones in certain situations), then a bin could be "empty" and the whole construction might fail, depending on how much the implementer has ignored the issue. So, no, the plausible failure modes are not identical. I will simply add that often, for those devising or implementing an algorithm, accepting what seems like a simplifying assumption (or ignorable "corner case") actually complicates the work, as these sorts of assumptions tend to be leaky, making reasoning about the overall system more complex. For example, one could adopt all sorts of assumptions in making a wallet, but if they add up to "transaction failed" for the end user, figuring out which assumption broke and how to undo it can be much more complicated than not making the faulty assumption in the first place.
Edit: I should add that the wallet supports blacklisting outputs (in addition to auto-excluding locked ones), so if someone publishes a bunch of spent outputs, there's a mitigation (though certainly, as the current algorithm has various problems in normal operation, it wouldn't be surprising if the mitigation suffered from some).
Adding to the above: assume your real output is surrounded by many locked outputs. In order to construct a bin using the real, you would need to widen the bin in order to get unlocked outputs in the bin with your real in it.
Any observer would be able to tell from the final ring that one of the bins was widened, and can deduce which bin caused the widening based on which outputs in the ring are closer in proximity to locked outputs.
Therefore in order to avoid revealing that it’s the real output that caused widening of its bin, you need decoy outputs that can plausibly cause the bin to widen too.
Contrast this with today: if someone malicious surrounds an output with publicly known real spent outputs, it won't necessarily reveal when the surrounded output is spent because anyone else can still plausibly use that output as a decoy.
This thorn is why I included the "but" in this TODO in the algorithm:
// TODO: if there aren't enough unlocked outputs to construct the ring, 2x the bin_width, and try again starting at step 2
// but maintaining all unlocked outputs_for_bin_selection from step 3...
outputs_for_bin_selection
are the outputs that are also gamma selected. The algorithm needs to retain those outputs when considering to widen the bin if those outputs are unlocked too.
Plaintext timelocks add these kinds of thorny edge cases to binning that the algorithm needs to account for. If we were to wait to release a binning algorithm until after plaintext timelocks were deprecated, we could choose a bin width wide enough that is guaranteed to select enough unlocked outputs every time, rather than need to dynamically widen it.
UPDATE from 10-29-21: the flaw described below was patched with the solution described. The algorithm described in the OP implements the solution described below.
There is a flaw in the proposed algorithm: in the step where it randomly re-selects an output from a block (in order to mitigate a miner ordering a block to their advantage), it can potentially leak information that an output is a decoy in some circumstances.
In order to mitigate this completely, bins should be widened to reach block borders. This will be a relatively significant change to the proposed algorithm: instead of having bins span a set number of outputs, bins would span a set number of blocks.
Placing this proposal in WIP.
[ = the start of a block
] = the end of the block
[0,1,2][3,4,5]
| = bin partitions
[|0,1|2][3|4,5|]
Assume output 2 is real (or is gamma selected). The algorithm as initially proposed would complete the bin associated with output 2 by randomly picking an output from the 2nd block. Assume the algorithm randomly picks output 4. Thus, the final bin looks like: {2,4}
.
But if output 4 were real (or gamma selected), there would be no way for the algorithm to then select output 2 as a decoy. Thus, output 2 must be the starting output that was either real or gamma selected when the final bin is {2, 4}
, and output 4 must be a decoy.
This flaw stems from the issue that when bins span multiple blocks, it could cause a circumstance where some outputs are guaranteed decoys. And the only safe way I see around this issue is by ensuring bins span entire blocks.
Updated the algorithm to make bins a fixed width in blocks, rather than a fixed # of outputs to account for the above flaw. I believe it's a simpler, safer, and stronger approach than was originally proposed, and handles the corner case of locked outputs much better.
Regarding BIN_WIDTH_IN_BLOCKS , it seems it would be useful to know from historic data how wide a window of empty blocks can be expected to be. I would assume that during events such as network upgrades there may have been long series of empty blocks. I also wonder how wide a bin can be before it starts to weaken the benefit of temporal proximity. Is a bin width of e.g. 50 blocks acceptable? When you zoom out to look at the whole chain, 50 blocks difference is still relatively near.
I figure the narrower the BIN_WIDTH_IN_BLOCKS
:
PRO: the more effective binning will be in hindering timing analysis. CON: the greater the damage an attacker spamming the chain with locked outputs can do.
BIN_WIDTH_IN_BLOCKS
hinders timing analysisAn edge case circumstance laid out by xfang in a hackerone submission where the intuition that "narrower hinders timing analysis" is clear: when constructing a split transfer to e.g. 30+ destinations, the wallet will submit multiple transactions that land in the chain adjacently in a block and look fairly similar (16-output tx's with 1 tx with some odd-sized remainder). Later on, when a user combines the change from those adjacent transactions into a single transaction, an observer can note multiple rings include outputs from adjacent transactions that can plausibly be "split transfers". A narrower BIN_WIDTH_IN_BLOCKS
makes it more likely that people will select decoys from adjacent transactions, which provides cover for this edge case circumstance.
Here is another circumstance where "narrower" provides greater protection: if you start constructing multiple change outputs in transactions (as some wallets start to support this feature), and end up needing to combine outputs from the same transaction as inputs in a future single transaction, then again, a narrower BIN_WIDTH_IN_BLOCKS
makes it more likely that people will select decoys from the same transaction. This would provide better cover for this action.
In both of the above circumstances, the wallet warns the user when combining outputs in this way, and these circumstances will likely become more rare with time and increased usage, but I figure ideally the algorithm would provide greater protection from the circumstance if possible.
I can't think of a circumstance that demonstrates that a wider window makes binning more effective at hindering timing analysis. Could be I'm missing something.
So long as the unlock time feature remains as is, an attacker could spam the chain with blocks that are composed of many locked outputs (edit: clearer). Notably, the attacker can't control the unlock time on coinbase outputs, however, which always unlock after 60 blocks.
I chose a BIN_WIDTH_IN_BLOCKS
of 2 in my example to match NUM_BIN_MEMBERS
of 2, since this guarantees that all bins will have enough spendable outputs to construct rings after 60 blocks (when coinbase outputs unlock). I would think it may make sense to increase BIN_WIDTH_IN_BLOCKS
to 10 to increase the chances honest unlocked outputs end up in the chain. But not seeing why it would be beneficial to increase to something as high as 50.
EDIT: small language change
Overview
This is a simplified approach to "binning" done purely in the client. It's meant to demonstrate a PoC that can be implemented in
wallet2
today, while iterating toward a space-saving solution described in @UkoeHB's #84 (which will pair well with significantly larger ring sizes). I debated just implementing this inwallet2
and submitting a PR, but I figured a PoC would be easier to grok and critique.Refresher, binning is a strategy in the decoy selection algorithm to select "bins" of decoys, such that the outputs in each bin are temporally close. For example, with a ring size of 22, rather than compose the ring of 22 outputs that span the entire chain, binning instead composes the ring of (for example) 11 bins with 2 bin members in each bin (11 bins * 2 bin members = 22 outputs in the ring). The 11 bins would span the entire chain, but the members in each bin would be temporally close.
Pro: binning mitigates weaknesses of strictly using an estimated spend-time distribution (such as a gamma distribution) to select decoys. Con: reduces the number of selections that can span the entire chain.
Deeper analysis of the pros and cons of binning in general (and parameter choice) is left out of this description. For more on binning, see Möser et al section 6.2, #84, and #86.
The proposed algorithm
There are 3 main steps in the proposed algorithm:
When the algorithm runs, all outputs that are 10 blocks old and older are each members of a particular bin in the chain. There can be many outputs in a single bin, and every output belongs to a single bin. Bins span a fixed number of blocks (rather than a fixed number of outputs). And in the final step of the algorithm, decoys are randomly selected from bins. This approach has a number of benefits which will be explained later.
Walking through the algorithm
It's easiest to explain how the algorithm works in detail by walking through an example. Assume the chain has 5 blocks with 11 total outputs. Block 0 has 1 output in it, block 1 has 3 outputs in it (Output ID's 1, 2, and 3), block 2 has 1 output, block 3 has 3 outputs, block 4 has 3 outputs...
Assume a
BIN_WIDTH_IN_BLOCKS
of 2. The algorithm "re-arranges" its view of the chain as follows:Assume the real output is Output ID 3. The algorithm therefore makes sure to use
bin_index 1
in the ring.When selecting decoy bins, the algorithm gamma picks an output using the same approach currently used to select decoys, then determines the bin that output is in. For example, assume the algorithm gamma picks
Output ID 9
, thereforebin_index 0
will also be used in the ring, along withbin_index 1
.Continuing the example, stick with the assumption that
BIN_WIDTH_IN_BLOCKS
is 2, and assume the number of members per bin in the ring is 2 and the ring size is 4. This means the final ring will be composed of 2 bins with 2 bin members each.Taking stock: at this point in the algorithm, the wallet knows that output ID's 3 and 9 will be in the final ring, and therefore bins 1 and 0 will be the bins used in the ring. The wallet now needs to select 1 additional output from
bin_index 1
, and 1 additional output frombin_index 0
to complete the ring:the algorithm randomly picks 1 additional output from
bin_index 1
in order to complete the first bin (the picked output is equally probable to be either Output ID 4, 2, 1, 0). Assume the algorithm picksOutput ID 1
.the algorithm randomly picks 1 additional output from
bin_index 0
in order to complete the next bin (equally probable to be either Output ID 10, 8, 7, 6, 5). Assume the algorithm picksOutput ID 5
.Thus, the final ring is:
{{1, 3}, {5, 9}}
.Sample result with more realistic parameters
When using the following parameters:
BIN_WIDTH_IN_BLOCKS
: 2NUM_BIN_MEMBERS
: 2NUM_BINS
: 8and selecting decoys for Output ID 40261601 at height 2465609, a sample ring could look like this:
How this differs from Möser et al's approach
Möser et al's approach is functionally very similar to the above approach, with some crucial differences in their approach:
bin_width
is equal to the number of members per binUnfortunately Monero's custom timelocks complicate the Möser approach. As described by @UkoeHB here:
Therefore, since RingCT outputs can be locked into the future even if the timelock feature is deprecated in the near-term, the algorithm must allow for a
bin_width
wider than the number of members per bin that ultimately gets included in a ring, just in case the other outputs in the bin are locked preventing unlocked outputs from being spendable.In this proposed algorithm, it's suggested to use a
BIN_WIDTH_IN_BLOCKS >= NUM_BIN_MEMBERS
, to guarantee that there will be enough unlocked outputs to use in the ring. This way it's guaranteed the coinbase outputs can be used to construct a ring once they unlock after 60 blocks, even if all other outputs in the bin are locked.Another interesting thing they do in the Möser paper is use block headers to shuffle outputs in a bin in order to mitigate a miner ordering outputs in a block to the miner's benefit. The same effect is achieved by randomly selecting outputs from bins in the final step of this proposed algorithm.
Benefits of this approach compared to other binning approaches
The code
For anyone reading the code, I suggest to start from
binning_algorithm_demo
. In order to run this code, simply replace the contents ofblockchain_usage.cpp
with the code below (and run it the same way you'd runmonero-blockchain-usage
). It takes a bit to run the first time (it's reading the RingCT output distribution from the db), but subsequent runs are fast (since the RingCT output distribution gets written to disk as a file.. I'm sure there's a more clever way to do this, but it works alright here). I also included a sample output at the bottom of this description.Here is an output sample from running the above code:
EDIT: restructured the algorithm as initially proposed to select bins of a fixed number of blocks, rather than a fixed number of outputs to avoid the flaw described here