Skip to content
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

rangeproof: clean up legacy rangeproof code #160

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

apoelstra
Copy link
Contributor

This is the first round of cleanups with the goal of reducing the stack space usage of the rangeproof proving and verification code. This is needed for many embedded applications, and even on hosted machines it's a bit obscene that we're throwing tens of kilobytes of stuff onto the stack unnecessarily.

There is some stack reduction in this PR itself (dropping the 4k prep array from rangeproof_genrand_sign and the 1k k array from rangeproof_sign_impl but that isn't the main goal of this PR. The goal is to get the code into a state where it's easier to read and reason about.

This PR does not include the addition of closures or other complex APIs that might be controversial. It is a collection of cherry-picks from my work in November on stack space reduction, where I excluded everything I thought was "experimental" or unlikely to work out.

tmp[b] ^= message[(i * 4 + j) * 32 + b];
}
}
} else if (i == header->n_rings - 1) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a change in signing behavior here, which is that we no longer encode data in the (potentially two) s-values in the final ring.

The tests don't catch this, and I hate that this behavior exists, but I think I should restore it so that there is no potential change in behavior. I will come up with a fixed test vector for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we are okay. In e7a8a5f (July 2016) I made it illegal (fails rangeproof_sign) to produce signatures that used these last two s values for sidechannel purposes, even though it looks like gmax did actually implement the logic for it in 2015.

So it has never been possible, using the libsecp-zkp API, to produce a rangeproof that embedded data in this space, so I'm comfortable removing the ability to as part of these cleanups.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this explicit in the new commit 0db19e5

@real-or-random
Copy link
Collaborator

Is this ready for review or are you working on further changes?

@apoelstra
Copy link
Contributor Author

@real-or-random ready for review. I am building stuff on top of this, which seem to be working now (I eliminated all the massive arrays in rangeproof_rewind_inner :)) so I think this is the right foundation.

@apoelstra
Copy link
Contributor Author

My current tentative roadmap:

  1. This "cleanup"/testing PR
  2. A followup which moves some rewinding logic into borromean_verify, decreases the speed of rewinding a fair bit (but speeds up failed rewinding by letting us bail out before doing any EC ops), but eliminates all the huge stack objects for rewinding
  3. A followup which does the same for verification (all that is left is the pubs array)
  4. A followup(s) which makes similar changes for rangeproof signing.
  5. (Maybe) an overall followup which tries to do microoptimizations and other stuff, once everything else has settled out.

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First (shallow) review pass. Nice to see all those cleanups!

src/modules/rangeproof/main_impl.h Outdated Show resolved Hide resolved
src/modules/rangeproof/rangeproof_impl.h Show resolved Hide resolved
src/modules/rangeproof/rangeproof_impl.h Show resolved Hide resolved
src/modules/rangeproof/tests_impl.h Outdated Show resolved Hide resolved
src/modules/rangeproof/rangeproof_impl.h Show resolved Hide resolved
src/modules/rangeproof/rangeproof.h Outdated Show resolved Hide resolved
Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't fully investigated this but

./tests 64 106275f9d56e923d4698c32cae3b1634

fails with

src/modules/rangeproof/tests_impl.h:517: test condition failed: secp256k1_rangeproof_sign(ctx, proof, &len, vmin, &commit, blind, commit.data, exp, min_bits, v, NULL, 0, NULL, 0, secp256k1_generator_h)

It seems like secp256k1_rangeproof_header_set_for_value fails because secp256k1_rangeproof_header_expand fails because header->max_value > UINT64_MAX - header->min_value.

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added three more fixed test vectors to my branch (https://github.com/jonasnick/secp256k1-zkp/commits/rangeproof-cleanups-jn). Feel free to cherry-pick (and modify).

@apoelstra
Copy link
Contributor Author

Thanks! Pulled it into my branch.

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about the implications of removing the 64-byte extra space for Elements.

Elements has this reject condition msg_size != SIDECHANNEL_MSG_SIZE where msg_size is the m_len argument of rewind, msg_size = 64 and SIDECHANNEL_MSG_SIZE = 64. If one creates a rangeproof with a single ring of size 4, non-updated nodes will get msg_size = 64 and won't reject the transaction. Updated nodes however will have msg_size = 0 and therefore reject the tx.

src/modules/rangeproof/rangeproof_impl.h Show resolved Hide resolved
@apoelstra
Copy link
Contributor Author

@jonasnick regarding Elements, that check is actually wrong right now because the returned msg_size from rangeproof_rewind is 64 bytes larger than the actual capacity available from rangeproof_sign.

It is true that currently wallets will accept 2-bit rangeproofs that have embedded messages, and this change will cause those to be rejected, but producing such rangeproofs would require a custom rangeproof signing implementation. I'm not aware of any such implementations, nor am I aware of 2-bit rangeproofs being used in practice.

There is a sorta-attack here where somebody could make such a rangeproof, have it be accepted by an Elements 22 wallet, then the user updates to Elements 23 and loses her backup and needs to recover the data from the rangeproof again, and now can't. But this is a really long chain of implausible scenarios, which results in a situation which is recoverable (with some extra crypto work from us) if anybody actually encounters it.

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs rebase but LGTM. header_set_for_value makes sense to me but I can't fully verify that it's exactly equivalent to the proveparams function it replaces. Luckily the tests are quite comprehensive.

I've added another test to my branch to exercise the following condition in proveparams which wasn't hit by the existing tests:

        if ((*min_value && value > INT64_MAX) || (value && *min_value >= INT64_MAX))
            return 0;

See commit rangeproof: add test for large value and minimum value and fixup! rangeproof: use rangeproof_header structure for proving here.

@apoelstra
Copy link
Contributor Author

Rebased and pulled both your commits into rangeproof: use rangeproof_header structure for proving

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK d1a3479

Copy link
Collaborator

@real-or-random real-or-random left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only reviewed 3/10 commits so far

CHECK(min_val_out == val);
CHECK(max_val_out == val);
CHECK(m_len_out == 0);
CHECK(memcmp(blind, blind_out, 32) == 0);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
CHECK(memcmp(blind, blind_out, 32) == 0);
CHECK(secp256k1_memcmp_var(blind, blind_out, 32) == 0);

unsigned char blind_out[32];
unsigned char nonce[32];
const unsigned char message[1] = " "; /* no message will fit into a single-value proof */
unsigned char message_out[sizeof(proof)] = { 0 };
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe this could use a more arbitrary default value than 0. (It doesn't really matter for this test, but rather in the tests below)

By the way, nice use of information-theory here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave this be -- I did consider using a nonzero value, but decided against it, because

  • 0 is very easy to notice in debug output (though I suppose so would any constant value)
  • the failure mode in message decoding will be to output random-looking data (since rewinding extracts from a RNG and then xors the output). So it's hard to see how rewinding could fail in a way that would be confused with constant 0s

secp256k1_generator_h
));

CHECK(memcmp(blind, blind_1, 32) == 0);
Copy link
Collaborator

@real-or-random real-or-random Jan 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
CHECK(memcmp(blind, blind_1, 32) == 0);
CHECK(memcmp(blind, blind_1, 32) == 0);

I feel bad for point out this on many PR. Please tell me if I should stop. The point is that there's a plan upstream to check that the code is free of direct calls to memcmp. (There are more calls below, may be a good idea to grep for it.)

edit: In fact, it seems that we never replaced the memcmp in our non-test code here (equivalent to bitcoin-core/secp256k1@6173839). We should just do this.

};

/* Maximum length of a message that can be embedded into a rangeproof */
#define MAX_MESSAGE_LEN 3968
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Can you document this in the API docs? It will solve #42.

/* Like in the rsize[] == 1 case, Having figured out which s is the one which was not forged, we can recover the blinding factor. */
secp256k1_rangeproof_recover_x(&stmp, &s_orig[skip2], &ev[skip2], &s[skip2]);
final_x_pos = 4 * (rings - 1) + ((*v >> (2 * (rings - 1))) & 3);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you use *v instead of value. I believe the entire function is somewhat inconsistent here (even before your PR): Some uses of *v are covered by if (v) but *v = UINT64_MAX; and yours here is not. This made me think that this can be called with NULL but it cannot.

Copy link
Collaborator

@real-or-random real-or-random left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5/10...

Comment on lines 28 to 31
/** Number of bits used to represent the proven value
*
* Encoded in the header. */
int mantissa;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Say that this is 0 for exact value proofs?

Comment on lines -379 to -381
VERIFY_CHECK(npub <= 128);
VERIFY_CHECK(npub >= 1);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure this holds, but I can't be wrong to bring those back (to _expand then).

@@ -394,11 +482,10 @@ SECP256K1_INLINE static int secp256k1_rangeproof_rewind_inner(secp256k1_scalar *
}
return 1;
}
npub = (rings - 1) << 2;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh, re-setting npub was really bad if you want to read the code. good to remove this.

for (j = 0; j < 2; j++) {
size_t idx;
/* Look for a value encoding in the last ring. */
idx = npub + rsizes[rings - 1] - 1 - j;
idx = 4 * (header->n_rings - 1) + header->rsizes[header->n_rings - 1] - 1 - j;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
idx = 4 * (header->n_rings - 1) + header->rsizes[header->n_rings - 1] - 1 - j;
idx = header->n_pubs - 1 - j;

equivalent but cleaner

Copy link
Collaborator

@real-or-random real-or-random left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7/10

Comment on lines +131 to +135
* the 2015-era API, and all of these issues will go away when we merge
* Bulletproofs. */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I wouldn't make promises in comments. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one is fine :) BPs simply have none of these insane options so there is no room for complex adjustment logic.

*
* Returns 1 on success, 0 if the `max_value` field would exceed UINT64_MAX */
static int secp256k1_rangeproof_header_expand(secp256k1_rangeproof_header* header) {
if (header->mantissa == 0) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: May be slightly cleaner check for header->exp == -1 instead (making this the "canonical" check: a proof is single-value iff header->exp == -1 and all others values are derived.)

Comment on lines -221 to -132
if ((*min_value && value > INT64_MAX) || (value && *min_value >= INT64_MAX)) {
/* If either value or min_value is >= 2^63-1 then the other must by zero to avoid overflowing the proven range. */
return 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find the equivalent check in the new code. (May be intentional but then please explain in the commit message or in a comment.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the old check was just wrong, in multiple ways.

  • First, if min_value is greater than INT64_MAX or indeed greater than 0, then value must also be greater than 0, since it must be >= min_value. In the old code this was checked before even calling proveparams.
  • If this instead intended to check that min_value == value in the case that either one was > INT64_MAX, this still makes no sense. There is no reason I shouldn't be able to have min_value be INT64_MAX + 1 and value be INT64_MAX + 2, say.

if (!secp256k1_rangeproof_header_expand(header)) {
return 0;
}
*proven_value = (value - header->min_value) / header->scale;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this is equivalent to the old code. I think the equivalent to the old code would be to divide by max and not by header->scale (see the different loop conditions in the old code that compute *v2 (= max) and *scale.

I think conceptually we could also first reexpand, which will compute header->max_value (=max, I think) then compute `proven_value and reexpand again? (Not a real suggestion, just to help me and you understand.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dividing by max is definitely wrong, and I don't think the old code does anything like that.

Comment on lines 174 to 190
/* Increasing the mantissa will have increased the number of rings etc
* so re-expand the header to recompute the other derived values. */
return secp256k1_rangeproof_header_expand(header);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also mention that we may have changed header->exp and header->min_value.

Comment on lines -266 to -176
VERIFY_CHECK(*mantissa>0);
VERIFY_CHECK((*v & ~(UINT64_MAX>>(64-*mantissa))) == 0); /* Did this get all the bits? */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be worth to recycle these VERIFY_CHECKs

Comment on lines 342 to 437
VERIFY_CHECK(header.n_rings > 0);
VERIFY_CHECK(header.n_rings <= 32);
VERIFY_CHECK(header.n_pubs <= 128);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these three be in the header_expand function? I think they holds for any valid proof (i.e., they won't fail in the verification code)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. In fact, it looks like they were already there, and these VERIFY_CHECKs are redundant.

const unsigned int max_bits = min_value ? secp256k1_clz64_var(min_value) : 64;
if (min_bits > max_bits) {
header->mantissa = max_bits;
min_bits = max_bits;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead store.

You can leave min_bits const because you use header->mantissa below. Also update the comment above this scope to mention mantissa instead of min_bits.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this change. Hopefully the resulting range-diff isn't too messy (this was a pretty short patch so changing only a few lines pretty-much rewrote it).

Comment on lines -50 to -62
unsigned char nonce32[32];
int done;
ret = secp256k1_nonce_function_default(nonce32, msg32, seckey32, NULL, NULL, count);
if (!ret) {
break;
}
secp256k1_scalar_set_b32(&non, nonce32, &overflow);
memset(nonce32, 0, 32);
if (overflow || secp256k1_scalar_is_zero(&non)) {
count++;
continue;
}
done = 1;
Copy link
Collaborator

@real-or-random real-or-random Feb 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I believe this change is fine. But in general, I'm not sure it's worth making similar changes if the only short term goal is to reduce stack usage and and if we're going to replace this anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This removed logic is incompatible with moving the nonce-computation logic into borromean_sign, which is (a) necessary to do any useful amount of stack space reduction; (b) reduces the reviewing burden by no longer requiring that array indices of secret data in far-away files be compatible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also unsure what you mean by "we're going to replace this anyway"

@real-or-random
Copy link
Collaborator

10/10

Add two new fixed rangeproof vectors; check that various extracted
values are correct; add a test for creating and verifying single-value
proofs.
@apoelstra apoelstra force-pushed the 2022-01--rangeproof-cleanups branch from d1a3479 to f25f555 Compare March 6, 2022 17:38
@apoelstra
Copy link
Contributor Author

(Pushed rebase onto current master, resulting in f25f555. Will followup with fixes for Tim's comments.)

…f sidechannel space

This space has never been usable, in the sense that rangeproof_sign would
refuse to use it, and it complicates the rewinding logic a fair bit to
retain the ability of the rewinder to access it.

This does result in a minor API change, which is that the returned `m_len`
variable, which indicates the total size of the rangeproof sidechannel,
is reduced by 64 bytes for any proof that covers a multiple-of-4 number
of bit. This change is reflected as a change in the unit tests.
Copied some more logic from the 2015-era code.
This is purely to reduce the number of arguments being passed into one
function at once. Also improves const-correctness.
Also eliminate `prep` array from genrand_sign
Reduces stack usage of rangeproof_sign by 1056 bytes, is a bit safer as
it doesn't require the caller of borromean_sign to know which indices are
going to be overwritten, is a net-negative code diff, and reduces
the amount of shared data between the borrom ean logic and its callers.
@apoelstra apoelstra force-pushed the 2022-01--rangeproof-cleanups branch from f25f555 to 5e36b32 Compare March 6, 2022 18:26
@apoelstra
Copy link
Contributor Author

Addressed all comments in 5e36b32 (which I just pushed). I left a 👍 emoji on the ones that I accepted, and replied to all the ones that I didn't.

Copy link
Contributor

@jonasnick jonasnick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good to me.

@apoelstra
Copy link
Contributor Author

Is this good to merge actually? (Other than needing a rebase?)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants