-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add "silentpayments" module implementing BIP352 (take 4, limited to full-node scanning) #1765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add "silentpayments" module implementing BIP352 (take 4, limited to full-node scanning) #1765
Conversation
|
Added the optimized version on top of this PR: For more context: |
|
Small supplementary update: I've created a corresponding Python implementation of the provided API functions based on secp256k1lab (https://github.com/theStack/secp256k1lab/blob/add_bip352_module_review_helper/src/secp256k1lab/bip352.py) (also linked in the PR description). The hope is that this makes reviewing this PR a bit easier by having a less noisy, "executable pseudo-code"-like description on what happens under the hood. The code passes the BIP352 test vectors and hence should be correct.
Thanks for rebasing on top of this PR, much appreciated! I will take a closer look within the next days. |
w0xlt
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Not related to optimization, but the diff below removes some redundant public-key serialization code:
diff --git a/src/modules/silentpayments/main_impl.h b/src/modules/silentpayments/main_impl.h
index 106da20..922433d 100644
--- a/src/modules/silentpayments/main_impl.h
+++ b/src/modules/silentpayments/main_impl.h
@@ -21,6 +21,19 @@
/** magic bytes for ensuring prevouts_summary objects were initialized correctly. */
static const unsigned char secp256k1_silentpayments_prevouts_summary_magic[4] = { 0xa7, 0x1c, 0xd3, 0x5e };
+/* Serialize a ge to compressed 33 bytes. Keeps eckey_pubkey_serialize usage uniform
+ * (expects non-const ge*), and centralizes the VERIFY_CHECK. */
+static SECP256K1_INLINE void secp256k1_sp_ge_serialize33(const secp256k1_ge* in, unsigned char out33[33]) {
+ size_t len = 33;
+ secp256k1_ge tmp = *in;
+ int ok = secp256k1_eckey_pubkey_serialize(&tmp, out33, &len, 1);
+#ifdef VERIFY
+ VERIFY_CHECK(ok && len == 33);
+#else
+ (void)ok;
+#endif
+}
+
/** Sort an array of silent payment recipients. This is used to group recipients by scan pubkey to
* ensure the correct values of k are used when creating multiple outputs for a recipient.
*
@@ -68,13 +81,11 @@ static int secp256k1_silentpayments_calculate_input_hash_scalar(secp256k1_scalar
secp256k1_sha256 hash;
unsigned char pubkey_sum_ser[33];
unsigned char input_hash[32];
- size_t len;
int ret, overflow;
secp256k1_silentpayments_sha256_init_inputs(&hash);
secp256k1_sha256_write(&hash, outpoint_smallest36, 36);
- ret = secp256k1_eckey_pubkey_serialize(pubkey_sum, pubkey_sum_ser, &len, 1);
- VERIFY_CHECK(ret && len == sizeof(pubkey_sum_ser));
+ secp256k1_sp_ge_serialize33(pubkey_sum, pubkey_sum_ser);
secp256k1_sha256_write(&hash, pubkey_sum_ser, sizeof(pubkey_sum_ser));
secp256k1_sha256_finalize(&hash, input_hash);
/* Convert input_hash to a scalar.
@@ -85,15 +96,13 @@ static int secp256k1_silentpayments_calculate_input_hash_scalar(secp256k1_scalar
* an error to ensure strict compliance with BIP0352.
*/
secp256k1_scalar_set_b32(input_hash_scalar, input_hash, &overflow);
- ret &= !secp256k1_scalar_is_zero(input_hash_scalar);
+ ret = !secp256k1_scalar_is_zero(input_hash_scalar);
return ret & !overflow;
}
static void secp256k1_silentpayments_create_shared_secret(const secp256k1_context *ctx, unsigned char *shared_secret33, const secp256k1_ge *public_component, const secp256k1_scalar *secret_component) {
secp256k1_gej ss_j;
secp256k1_ge ss;
- size_t len;
- int ret;
secp256k1_ecmult_const(&ss_j, public_component, secret_component);
secp256k1_ge_set_gej(&ss, &ss_j);
@@ -103,12 +112,7 @@ static void secp256k1_silentpayments_create_shared_secret(const secp256k1_contex
* impossible at this point considering we have already validated the public key and
* the secret key.
*/
- ret = secp256k1_eckey_pubkey_serialize(&ss, shared_secret33, &len, 1);
-#ifdef VERIFY
- VERIFY_CHECK(ret && len == 33);
-#else
- (void)ret;
-#endif
+ secp256k1_sp_ge_serialize33(&ss, shared_secret33);
/* Leaking these values would break indistinguishability of the transaction, so clear them. */
secp256k1_ge_clear(&ss);
@@ -585,7 +589,6 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_ge output_negated_ge, tx_output_ge;
secp256k1_gej tx_output_gej, label_gej;
unsigned char label33[33];
- size_t len;
secp256k1_xonly_pubkey_load(ctx, &tx_output_ge, tx_outputs[j]);
secp256k1_gej_set_ge(&tx_output_gej, &tx_output_ge);
@@ -595,7 +598,6 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_ge_neg(&output_negated_ge, &output_ge);
secp256k1_gej_add_ge_var(&label_gej, &tx_output_gej, &output_negated_ge, NULL);
secp256k1_ge_set_gej_var(&label_ge, &label_gej);
- ret = secp256k1_eckey_pubkey_serialize(&label_ge, label33, &len, 1);
/* Serialize must succeed because the point was just loaded.
*
* Note: serialize will also fail if label_ge is the point at infinity, but we know
@@ -603,7 +605,7 @@ int secp256k1_silentpayments_recipient_scan_outputs(
* Thus, we know that label_ge = tx_output_gej + output_negated_ge cannot be the
* point at infinity.
*/
- VERIFY_CHECK(ret && len == 33);
+ secp256k1_sp_ge_serialize33(&label_ge, label33);
label_tweak = label_lookup(label33, label_context);
if (label_tweak != NULL) {
found = 1;
@@ -617,7 +619,6 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_gej_neg(&label_gej, &tx_output_gej);
secp256k1_gej_add_ge_var(&label_gej, &label_gej, &output_negated_ge, NULL);
secp256k1_ge_set_gej_var(&label_ge, &label_gej);
- ret = secp256k1_eckey_pubkey_serialize(&label_ge, label33, &len, 1);
/* Serialize must succeed because the point was just loaded.
*
* Note: serialize will also fail if label_ge is the point at infinity, but we know
@@ -625,7 +626,7 @@ int secp256k1_silentpayments_recipient_scan_outputs(
* Thus, we know that label_ge = tx_output_gej + output_negated_ge cannot be the
* point at infinity.
*/
- VERIFY_CHECK(ret && len == 33);
+ secp256k1_sp_ge_serialize33(&label_ge, label33);
label_tweak = label_lookup(label33, label_context);
if (label_tweak != NULL) {
found = 1;
w0xlt
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: The following diff removes the implicit cast and clarifies that k is 4 bytes
diff --git a/src/modules/silentpayments/main_impl.h b/src/modules/silentpayments/main_impl.h
index 922433d..d94aed6 100644
--- a/src/modules/silentpayments/main_impl.h
+++ b/src/modules/silentpayments/main_impl.h
@@ -512,7 +512,8 @@ int secp256k1_silentpayments_recipient_scan_outputs(
secp256k1_xonly_pubkey output_xonly;
unsigned char shared_secret[33];
const unsigned char *label_tweak = NULL;
- size_t j, k, found_idx;
+ size_t j, found_idx;
+ uint32_t k;
int found, combined, valid_scan_key, ret;
/* Sanity check inputs */
jonasnick
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @theStack for the new PR. I can confirm that this PR is a rebased version of #1698, with the light client functionality removed and comments addressed, except for:
- #1698 (comment)
- #1698 (comment)
- #1698 (review) (only the last one, "elemement")
Is there a reason for serializing prevouts_summary without light client functionality? If not, I think the don't do this comment is sufficient. Right now, in contrast to the docs of all other opaque objects, this is missing, however:
|
c11d30c to
445f2e8
Compare
|
@w0xlt, @jonasnick: Thanks for the reviews! I've addressed the suggested changes:
Given that this compressed-pubkey-serialization pattern shows up repeatedly also in other modules (ellswift, musig), I think it would make the most sense to add a general helper (e.g. in eckey{,_impl}.h), which could be done in an independent PR. I've opened issue #1773 to see if there is conceptual support for doing this.
Good point, I can't think of a good reason for full nodes wanting to serialize prevouts_summary. |
445f2e8 to
9103229
Compare
|
To address the open questions, I’ve reviewed the proposed changes by @w0xlt on 8d16914. I'm going to focus more on the key aspects I extracted from the review and the merits of each change, rather on the big O improvement claims, because I didn't get that far. These are multiple different changes rather than a single one, so to make the review easier I suggest to brake it in multiple commits. I would state on each of them the purpose and the real case scenario where the change would be relevant. Also, I would use clearer names for the variables or at least document their purpose. The changes I've identified so far are the following:
In general I agree with @jonasnick that we should define a clear target to benchmark and improve. As I've said before, the base case should be a wallet with a single label for change. In conclusion, from the proposed commit and the discussion around it, the only changes I've found clear enough to consider are:
|
|
Thanks @nymius for reviewing the changes, addressing the main points, and proposing a simplification. I’m currently splitting the optimization commit into smaller pieces to make it easier to review. The only part of the discussion that still feels a bit ambiguous is the “base” or “usual” case. So the goal of this optimization would be to mitigate that scenario, not the collaborative one. |
|
I ran the Without the Answering the questions: The optimized receiver implementation relies on a heuristic (the
|
|
@w0xlt, @nymius: Thanks for investigating this deeper. I've now also had a chance to look at the suggested optimizations and came to similar conclusions as stated in #1765 (comment). I particularly agree with the stated points that the changes should not increase complexity significantly and that the most important optimization candidate to consider for mitigating the worst-case scanning attack is "skip outputs that we have already found" (as previously stated by @jonasnick, see #1698 (comment) and jonasnick@311b4eb). I don't think stabilizing the sorting helps at all, since this is something that happens at the sender side, and we can't rely on the attacker using a specific implementation (even if they did, it's trivial for them to shuffle the outputs after creation). For the proposed target to benchmark, I'm proposing the following modified example that exhibits the worst-case scanning time based on a labels cache with one entry (for change outputs), by creating a tx with 23255 outputs [1] all targeted for Bob: 1df4287 Shower-thought from this morning: what if we treat the Any thoughts on this? Maybe I'm still missing something. [1] that's an upper bound of maximum outputs per block: floor(1000000/43) = 23255 |
1df4287 is a good target.
I had to increase stack size to be able to fit all N_OUTPUT size allocations in the example. Initially I preferred the |
|
@theStack Yes — if we want to keep only the adversarial-scenario optimizations, we can drop sort stabilization and the extra heads. I like your idea of avoiding dynamic memory allocation. That’s a very interesting direction. On my machine, the scan completes in about 0.4s, which feels like a good balance between simplicity and the optimization needed for the labeled case. Below are the changes I had to make for your example to run on my machine and to record the scan time. diff --git a/examples/silentpayments.c b/examples/silentpayments.c
index 5e71e73..d43332f 100644
--- a/examples/silentpayments.c
+++ b/examples/silentpayments.c
@@ -10,6 +10,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+#include <time.h>
#include <secp256k1_extrakeys.h>
#include <secp256k1_silentpayments.h>
@@ -112,15 +113,21 @@ const unsigned char* label_lookup(
return NULL;
}
+static secp256k1_xonly_pubkey tx_inputs[N_INPUTS];
+static const secp256k1_xonly_pubkey *tx_input_ptrs[N_INPUTS];
+static secp256k1_xonly_pubkey tx_outputs[N_OUTPUTS];
+static secp256k1_xonly_pubkey *tx_output_ptrs[N_OUTPUTS];
+static secp256k1_silentpayments_found_output found_outputs[N_OUTPUTS];
+static secp256k1_silentpayments_found_output *found_output_ptrs[N_OUTPUTS];
+static secp256k1_silentpayments_recipient recipients[N_OUTPUTS];
+static const secp256k1_silentpayments_recipient *recipient_ptrs[N_OUTPUTS];
+/* 2D array for holding multiple public key pairs. The second index, i.e., [2],
+ * is to represent the spend and scan public keys. */
+static unsigned char (*sp_addresses[N_OUTPUTS])[2][33];
+
int main(void) {
unsigned char randomize[32];
unsigned char serialized_xonly[32];
- secp256k1_xonly_pubkey tx_inputs[N_INPUTS];
- const secp256k1_xonly_pubkey *tx_input_ptrs[N_INPUTS];
- secp256k1_xonly_pubkey tx_outputs[N_OUTPUTS];
- secp256k1_xonly_pubkey *tx_output_ptrs[N_OUTPUTS];
- secp256k1_silentpayments_found_output found_outputs[N_OUTPUTS];
- secp256k1_silentpayments_found_output *found_output_ptrs[N_OUTPUTS];
secp256k1_silentpayments_prevouts_summary prevouts_summary;
secp256k1_pubkey unlabeled_spend_pubkey;
struct labels_cache bob_labels_cache;
@@ -209,11 +216,6 @@ int main(void) {
{
secp256k1_keypair sender_keypairs[N_INPUTS];
const secp256k1_keypair *sender_keypair_ptrs[N_INPUTS];
- secp256k1_silentpayments_recipient recipients[N_OUTPUTS];
- const secp256k1_silentpayments_recipient *recipient_ptrs[N_OUTPUTS];
- /* 2D array for holding multiple public key pairs. The second index, i.e., [2],
- * is to represent the spend and scan public keys. */
- unsigned char (*sp_addresses[N_OUTPUTS])[2][33];
unsigned char seckey[32];
printf("Sending...\n");
@@ -340,6 +342,9 @@ int main(void) {
* `secp256k1_silentpayments_recipient_prevouts_summary_create`
* 2. Call `secp256k1_silentpayments_recipient_scan_outputs`
*/
+ clock_t start, end;
+ double cpu_time_used;
+
ret = secp256k1_silentpayments_recipient_prevouts_summary_create(ctx,
&prevouts_summary,
smallest_outpoint,
@@ -356,14 +361,20 @@ int main(void) {
/* Scan the transaction */
n_found_outputs = 0;
+
+ start = clock();
ret = secp256k1_silentpayments_recipient_scan_outputs(ctx,
found_output_ptrs, &n_found_outputs,
- (const secp256k1_xonly_pubkey * const *)tx_output_ptrs, N_OUTPUTS,
+ (const secp256k1_xonly_pubkey **)tx_output_ptrs, N_OUTPUTS,
bob_scan_key,
&prevouts_summary,
&unlabeled_spend_pubkey,
label_lookup, &bob_labels_cache /* NULL, NULL for no labels */
);
+ end = clock();
+ cpu_time_used = ((double) (end - start)) / CLOCKS_PER_SEC;
+ printf("Bob's scan took %f seconds\n", cpu_time_used);
+
if (!ret) {
printf("This transaction is not valid for Silent Payments, skipping.\n");
return EXIT_SUCCESS;
@@ -435,7 +446,7 @@ int main(void) {
n_found_outputs = 0;
ret = secp256k1_silentpayments_recipient_scan_outputs(ctx,
found_output_ptrs, &n_found_outputs,
- (const secp256k1_xonly_pubkey * const *)tx_output_ptrs, 1, /* dummy scan with one output (we only care about Bob) */
+ (const secp256k1_xonly_pubkey **)tx_output_ptrs, 1, /* dummy scan with one output (we only care about Bob) */
carol_scan_key,
&prevouts_summary,
&unlabeled_spend_pubkey, |
|
@nymius, @w0xlt: Thanks once again for the quick feedback and for benchmarking! Shortly after my previous comment, I've been notified about yet another approach to tackle the worst-case scanning time attack (kudos to @furszy for bringing up the idea!), that I think is even more elegant: we can use the pointers in the The only tiny drawback about these non-malloc approaches might be that something that is conceptually an "in" parameter is modified, which might be a bit unsound in a strict API design sense. On the other hand, it shouldn't matter for the user (I doubt that these lists passed in would ever be reused for anything else after by the callers), and we already do the same in the sending API for the recipients, so it's probably fine.
The way I see it currently, code paths for non-adversarial scenarios with increasing k values would be hit so rarely in practice, that I'm sceptical that it's worth it put much effort into those optimizations. When scanning, the vast majority of transactions won't have any matches in the first place. Out of those few that do have a match, the vast majority will very likely again not contain any repeated recipient (IMHO it doesn't make that much sense to do that, unless the recipient explicitly asks "I want to receive my payment split up in multiple UTXOs, but still in a single tx"?), so in the bigger picture those optimizations wouldn't matter all that much, and I'd assume that the dominant factor should be by far all the (unavoidable) ECDH computations per transaction. But that's still more of a guess and it's still good to already have optimization ideas at hand if we need them in the future. |
|
@theStack Thanks for continuing to refine the optimization. The deletion approach performs slightly better (0.40 s vs. 0.45 s), likely because deleting items shrinks the array and cuts the number of loop iterations by about 50% compared to nullifying them. |
assuming the labels cache has only one entry (for change) for now includes fixes by w0xlt in order to avoid running into a stack overflow and time measureing code, see bitcoin-core#1765 (comment)
assuming the labels cache has only one entry (for change) for now includes fixes by w0xlt in order to avoid running into a stack overflow and time measureing code, see bitcoin-core#1765 (comment)
9103229 to
650b2fb
Compare
|
To summarize, the following table shows the proposed mitigations for the worst-case scanning attack so far, with benchmark results from my machine. The previous baseline commit with the worst-case example has been updated to include @w0xlt's changes, in order to work without stack size limit changes.
The run-times of the fixes vary slightly (the removal approach "fix2" being the fastest, confirming #1765 (comment) above), but are all in the same ballpark. I don't think exact performance results matter much here, as the goal of the mitigation should be to IMHO roughly cut the run-time down from "minutes" to "seconds" (and remember, this is already for the absolute worst-case, one giant non-standard transaction filling out a whole block, and it can only slow down one specific SP recipient). Thus, I decided to pick the the simplest approach that avoids dynamic memory allocation, i.e. fix number 3 using With that tackled, I believe that all of the open questions and TODOs are addressed now (updated the PR description accordingly). The latest force-push also includes a rebase on master (to include the CI fix #1771). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
went through 2c07380 only and left a few comments. Will keep moving.
| /* Loads a group element from a label. Returns 1 unless the label wasn't properly initialized. */ | ||
| static int secp256k1_silentpayments_label_load(const secp256k1_context* ctx, secp256k1_ge* ge, const secp256k1_silentpayments_label* label) { | ||
| ARG_CHECK(secp256k1_memcmp_var(&label->data[0], secp256k1_silentpayments_label_magic, 4) == 0); | ||
| secp256k1_ge_from_bytes(ge, label->data + 4); | ||
| return 1; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In 2c07380:
Aren't we missing an infinity check here?
We ensure it during save through secp256k1_ge_to_bytes but the load internal secp256k1_ge_from_bytes doesn't seem to be checking it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
secp256k1_ge_from_bytes interprets a 64-byte array as affine coordinates (32 bytes for x,y each), so the notion of infinity doesn't exist here, the called function secp256k1_ge_from_storage always sets r->infinity = 0;.
As I currently understand it, we generally assume that user-facing opaque data types are only created and modified by public API functions, and the only protection against manual tampering are the magic bytes. Once that ARG_CHECK is passed in a _load function, we just assume that the object must have been created with the corresponding _save counterpart.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As I currently understand it, we generally assume that user-facing opaque data types are only created and modified by public API functions, and the only protection against manual tampering are the magic bytes. Once that
ARG_CHECKis passed in a_loadfunction, we just assume that the object must have been created with the corresponding_savecounterpart.
Indeed, and I don't think we want to make any guarantees about what happens if the caller passes a modified object. And the magic bytes are just a best-effort protection against the user passing an opaque object of the wrong type.
| /** Serialize a Silent Payments label | ||
| * | ||
| * Returns: 1 always | ||
| * Args: ctx: pointer to a context object | ||
| * Out: out33: pointer to a 33-byte array to store the serialized label | ||
| * In: label: pointer to the label | ||
| */ | ||
| SECP256K1_API int secp256k1_silentpayments_recipient_label_serialize( | ||
| const secp256k1_context *ctx, | ||
| unsigned char *out33, | ||
| const secp256k1_silentpayments_label *label | ||
| ) SECP256K1_ARG_NONNULL(1) SECP256K1_ARG_NONNULL(2) SECP256K1_ARG_NONNULL(3); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In 2c07380:
q: why not include the label magic number in the output? It seems like a good way to ensure we're parsing a label and not just an arbitrary compressed group element.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question. The primary use-case of serializing labels right now is creating the label cache (at least for the "BIP approach" of scanning implemented in this PR), and for that I'd say it makes sense to target the most dense serialization for space-efficiency; having all key entries in the cache repeatedly prefixed with the exact same four bytes (increasing its size by >10%) seems wasteful, same for e.g. a large number of labels are saved to disk. (Sure, the users could manually strip those four bytes away and add them again before calling _label_parse, but that doesn't seem great either).
Leaving this open for others to weigh in, maybe it's worth it to do. In any case it seems that label serialization should be specified somewhere, I only notice now that it's apparently not specified in the BIP.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In any case it seems that label serialization should be specified somewhere,
It's not entirely clear in this case, but I agree that this is rather a protocol-level concern: interoperability could indeed matter if you want to use the same label DB with different scanning implementations. And in this case, size indeed matters, so I think we should not add the magic bytes.
| /* ensure that the passed scan key is valid, in order to avoid creating unspendable labels */ | ||
| ret = secp256k1_ec_seckey_verify(ctx, scan_key32); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In 2c07380:
Should be able to remove this line, the _ec_pubkey_create_helper() call you do below performs the same sk validity check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the _ec_pubkey_create_helper() call below performs a generator point multiplication on a different scalar, namely hash(b_scan || m), so its success doesn't tell us anything on whether the scan key is valid or not.
(Ignorance warning: I have mostly been reading along here but I have not read all the context from the previous PRs and I have not reviewed all the code) I guess you are hinting at the same thought I had in your last sentence: I think one approach that would be most defensive (as in should be completely safe for users) would be to only go with LabelSet and also set a max value for the number of labels that can be used. That max number would be somewhat arbitrary but we could set a target time on a target hardware and arrive at some benchmark that we feel comfortable with allowing. This of course wouldn't prevent users from using more labels but in order to do so they would need to call scanning multiple times which should make sure they know what they are getting into in terms of performance. They would likely read the docs and then complain here that this restriction should be removed because they have a use-case. Then we could consider further actions based on this feedback. This still seems doable from a implementation POV and lifting the limit in a future version shouldn't cause any compatibility problems. Doing anything resembling that to mitigate the worst case for the BIP approach seems much worse UX wise and much harder to explain. Also, I guess if we stay with the API from LabelSet internally the BIP approach could still be used if the number of labels is high and the number of outputs is manageable. But there might be some thresholds that users run into that are much harder to explain in the docs. And I honestly have no clue if the label_context/label_lookup UX is something that users desire, lacking the historical context from the previous PRs. FWIW, my concerns with the worst case attack are somewhat mild but we should remember that this might be used in some of the most adversarial contexts imaginable: The users could a taker of donations with powerful political adversaries or an exchange with fierce competition for example. That's why I would have a hard time feeling comfortable with letting users just adapt it at this point. |
These benchmark results from #1765 (comment) are interesting. I expect the BIP scanning approach benchmark to report increasing scanning times as It is also surprising to see such a large difference between the BIP approach and LabelSet scanning times for I ran theStack@8eced64 on my machine and obtained the following results, which are more in line with my expectations: IIUC, the BIP scanning approach is designed to keep scanning times low as the number of labels increases. If we expect most transactions to have @w0xlt 's benchmark results with Batch Inversion look promising, but I expect it to be slower than the BIP scanning approach when we scan real blocks, even with a relatively low number of labels. |
|
@fjahr: Thanks for weighing in! After much back-and-forth over the last few weeks on the two approaches and their trade-offs, I've reached very similar conclusions and am now convinced that shipping the module with the BIP approach as-is would not be a good idea, and "LabelSet with limits" approach as you sketched out is the safest option. It seems that the worst-case scanning attack for large-number-of-labels use-cases could only be fixed at the protocol level anyways (by e.g. limiting the size of SP eligible transactions to 100kvB, or more concretely by bounding the number of taproot outputs), and until that is done the BIP approach should probably not be used at all. I've opened a PR following the proposed "LabelSet scanning with a limit on labels" path: #1792 (by now only suggesting a limit by documentation, but that can easily be changed), where the discussion can be continued.
I remember also seeing these large differences for low L/N values and believe this was a flaw in the benchmark itself (when it was hand-written in the example binary, rather than using our benchmark framework); for some reason the very first benchmark result line showed significantly longer run-times than later ones, even when the order L or N values was re-arranged. With the "actual benchmark" as you ran it, I couldn't reproduce this large gap anymore.
That matches my general understanding as well. The theoretical cross-over point should be at |
|
Tackled the latest review suggestions #1765 (comment), #1765 (comment), #1765 (comment). Also added |
4b53c44 to
2d4c8ff
Compare
Add a routine for the entire sending flow which takes a set of private keys,
the smallest outpoint, and list of recipients and returns a list of
x-only public keys by performing the following steps:
1. Sum up the private keys
2. Calculate the input_hash
3. For each recipient group:
3a. Calculate a shared secret
3b. Create the requested number of outputs
This function assumes a single sender context in that it requires the
sender to have access to all of the private keys. In the future, this
API may be expanded to allow for a multiple senders or for a single
sender who does not have access to all private keys at any given time,
but for now these modes are considered out of scope / unsafe.
Internal to the library, add:
1. A function for creating shared secrets (i.e., a*B or b*A)
2. A function for generating the "SharedSecret" tagged hash
3. A function for creating a single output public key
Add function for creating a label tweak. This requires a tagged hash function for labels. This function is used by the receiver for creating labels to be used for a) creating labeled addresses and b) to populate a labels cache when scanning. Add function for creating a labeled spend pubkey. This involves taking a label tweak, turning it into a public key and adding it to the spend public key. This function is used by the receiver to create a labeled silent payment address. Add tests for the label API.
Add routine for scanning a transaction and returning the necessary spending data for any found outputs. This function works with labels via a lookup callback and requires access to the transaction outputs. Requiring access to the transaction outputs is not suitable for light clients, but light client support is enabled in the next commit. Add an opaque data type for passing around the prevout public key sum and the input hash tweak (input_hash). This data is passed to the scanner before the ECDH step as two separate elements so that the scanner can multiply the scan_key * input_hash before doing ECDH. Finally, add test coverage for the receiving API.
This affects both the sending and scanning API functions: * Sending fails if any group is exceeding the limit. * Scanning doesn't look beyond the limit.
Demonstrate sending and scanning on full nodes.
Add a benchmark for a full transaction scan, both for the common case and worst-case (full-block sized tx) scenarios. Only benchmarks for scanning are added as this is the most performance critical portion of the protocol. Co-authored-by: Sebastian Falbesoner <[email protected]>
Add the BIP-352 test vectors. The vectors are generated with a Python script that converts the .json file from the BIP to C code: $ ./tools/tests_silentpayments_generate.py test_vectors.json > ./src/modules/silentpayments/vectors.h Co-authored-by: Ron <[email protected]> Co-authored-by: Sebastian Falbesoner <[email protected]> Co-authored-by: Tim Ruffing <[email protected]>
Co-authored-by: Jonas Nick <[email protected]> Co-authored-by: Sebastian Falbesoner <[email protected]>
Test midstate tags used in silent payments.
2d4c8ff to
7ce90dd
Compare
|
The PR has been updated to implement the suggested "limited-k" protocol restriction with an example value of K_max=1000: 66e41d0 (see discussion #1799 (comment) ff.) I'm keeping the PR as a draft, since the limit used (K_max=1000) is still just an example number and more discussion is needed before a concrete change in BIP-352 is applied. |
Description
This PR implements BIP352 with scanning limited to full-nodes. Light-client scanning is planned to be added in a separate PR in the future. The following 7 API functions are currently introduced:
Sender side [BIP description]:
secp256k1_silentpayments_sender_create_outputs: given a list ofReceiver side, label creation [BIP description]:
secp256k1_silentpayments_recipient_label_create: given a scan secret key and label integer, calculate the corresponding label tweak and label objectsecp256k1_silentpayments_recipient_label_serialize: given a label object, create the corresponding 33-byte serializationsecp256k1_silentpayments_recipient_label_parse: given a 33-byte label representation, create the corresponding label objectsecp256k1_silentpayments_recipient_create_labeled_spend_pubkey: given a spend public key and a label object, create the corresponding labeled spend public keyReceiver side, scanning [BIP description]:
secp256k1_silentpayments_recipient_prevouts_summary_create: given a list ofprevouts_summaryobject needed for scanningsecp256k1_silentpayments_recipient_scan_outputs: given aprevouts_summaryobject, a recipients scan secret key and spend public key, and the relevant transaction outputs (x-only public keys), scan for outputs belonging to the recipients and and return the tweak(s) needed for spending the output(s). Optionally, a label_lookup callback function can be provided to also scan for labels.For a higher-level overview on what these functions exactly do, it's suggested to look at a corresponding Python implementation that was created based on the secp256k1lab project (it passes the test vectors, so this "executable pseudo-code" should be correct).
Changes to the previous take
Based on the latest state of the previous PR #1698 (take 3), the following changes have been made:
_prevout_summary_{parse,serialize},__recipient_create_output_pubkeys), adapted tests and benchmark accordinglyThe scope reduction isn't immediately visible in commit count (only one commit was only introducing light-client relevant functionality and could be completely removed), but the review burden compared #1698 is still significantly lower in terms of LOC, especially in the receiving commit.
Open questions / TODOs
Recent proposals of reducing the worst-case scanning time (see posts by w0xlt and jonasnick, Add BIP352 module (take 3) #1698 (comment) ff.) are not taken into account yet.➡️ solved by marking already-found outputs, see Add "silentpayments" module implementing BIP352 (take 4, limited to full-node scanning) #1765 (comment) ✔️Not providing➡️ solved by mentioning a "don't do this" comment in the API header (same phrasing as in other modules), see Add "silentpayments" module implementing BIP352 (take 4, limited to full-node scanning) #1765 (comment) ✔️prevouts_summary(de)serialization functionality yet in the API poses the risk that users try to do it anyway by treating the opaque object as "serialized". How to cope with that? Is adding a "don't do this" comment in API header sufficient?