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

BLAKE2b gadget #1767

Open
wants to merge 42 commits into
base: v2
Choose a base branch
from
Open

BLAKE2b gadget #1767

wants to merge 42 commits into from

Conversation

boray
Copy link
Member

@boray boray commented Jul 23, 2024

Closes #1754

Ingredients:

  • BLAKE2b hash function implementation with arbitrary digest length https://www.blake2.net/blake2.pdf
  • divMod64 and addMod64 needed for compression function
  • Gadgets.or()
  • UInt64.or()
  • Add zero and one accessors to UInt8

@boray boray marked this pull request as ready for review July 24, 2024 15:19
boray and others added 2 commits July 28, 2024 15:33
Co-authored-by: Martin Minkov <minkovlmartin@gmail.com>
@boray boray requested a review from Shigoto-dev19 July 28, 2024 18:08
Copy link
Member

@Trivo25 Trivo25 left a comment

Choose a reason for hiding this comment

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

Great job! I left some initial comments after a first pass

src/examples/crypto/blake2b/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/crypto/hash.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/gadgets.ts Outdated Show resolved Hide resolved
}


function testVectors() {
Copy link
Member

Choose a reason for hiding this comment

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

where are some of these test vectors from? we should use some official ones also (if they arent the official ones!)

Copy link
Member Author

Choose a reason for hiding this comment

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

Official test vectors have key parameter which I didn't implement. Therefore, I had to create test vectors manually.

Copy link
Member

Choose a reason for hiding this comment

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

I would in any case include tests with longer preimages, to make sure the code works when two blocks are required (meaning t1 is nonzero). I believe the longer preimage in these tests was 43 bytes. Let's try with 128 for example.

Copy link
Member

Choose a reason for hiding this comment

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

Therefore, I had to create test vectors manually.

How?

tests/vk-regression/vk-regression.json Outdated Show resolved Hide resolved
@mitschabaude
Copy link
Member

@boray since this relies on the changes in #1763, you should change the base branch to feature/divmod32-quotientbits-bound to make this easier to review

@mitschabaude mitschabaude changed the base branch from main to feature/divmod32-quotientbits-bound July 30, 2024 09:53
@mitschabaude
Copy link
Member

mitschabaude commented Jul 30, 2024

I changed the base branch, which reveals that you didn't merge that branch but added it as a single squashed commit.

I highly recommend to never do that! it means we have the same changes in two different commits, which git can't resolve and instead will mark them as conflicts. (see "This branch has conflicts that must be resolved" on this PR now)

the preferred way to use git at o1Labs is to basically always join branches using git merge, which reuses the original commits and so doesn't create any fake conflicts

Copy link
Member

@mitschabaude mitschabaude left a comment

Choose a reason for hiding this comment

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

a few quick comments on the gadgets additions!

src/lib/provable/gadgets/arithmetic.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/arithmetic.ts Outdated Show resolved Hide resolved
Comment on lines 91 to 95
if (quotientBits === 1) {
quotient.assertBool();
} else {
rangeCheckN(quotientBits, quotient);
}
Copy link
Member

Choose a reason for hiding this comment

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

for efficiency this should use rangeCheck64() instead of rangeCheckN() if quotientBits === 64!

Copy link
Member Author

Choose a reason for hiding this comment

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

quotientBits is not necessarily 64 since it's calculated from nBits. So, I think it's better to keep rangeCheckN().

Copy link
Member

Choose a reason for hiding this comment

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

I suggested to use rangeCheck64 if quotientBits === 64. so what I mean is, test for that condition, and use the efficient range check in that case

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, got it. This improvement is relevant for divMod32 too!

Copy link
Member

Choose a reason for hiding this comment

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

no it's not! there is no dedicated single-gate 32-bit range check, like there is for 64 bits

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't know that! I removed divMod32 change with the latest commit. AFAIU from the kimchi page of the proof systems book, only 64-bit range check has a dedicated single-gate. Quoting from the book:

The RangeCheck0 gate can also be used on its own to perform 64-bit range checks by constraining witness cells 1-2 to zero.

Copy link
Member

Choose a reason for hiding this comment

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

It is true that only range checks for 64bits have a dedicated Kimchi gate. However, one can also perform 32bit range checks just less efficiently. Maybe let's not remove the o1js function for it as it might be better from a ux point of view, instead of asking the zkapp developer to use a rc64 for x and another one for x * 2^32

Copy link
Member

Choose a reason for hiding this comment

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

For 32 bits there are two different ways to do it in 2.5 gates, and one of them is already used -- the one that doesn't use the range check gate.
Using that one is better because the range check gate and lookup table are only optionally included in the proof system, and the proof gets slower by including them. So it only pays off if you need a lot of range checks.

src/lib/provable/gadgets/bitwise.ts Show resolved Hide resolved
Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Great PR 😄

For today I am leaving some comments regarding the arithmetic side of this PR. Tomorrow I will look deeper into the actual hash function and leave feedback there as well.

src/lib/provable/gadgets/gadgets.ts Outdated Show resolved Hide resolved
divMod64,

/**
* Addition modulo 2^64. The operation adds two {@link Field} elements in the range [0, 2^128] and returns the result modulo 2^64.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* Addition modulo 2^64. The operation adds two {@link Field} elements in the range [0, 2^128] and returns the result modulo 2^64.
* Addition modulo 2^64. The operation adds two {@link Field} elements in the range [0, 2^128) and returns the result modulo 2^64.

Copy link
Member Author

Choose a reason for hiding this comment

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

TBH, I am not sure which one is right. The ranges in addMod32 comment block doesn't match too. I took the comment for addMod64 from addMod32 since it's the exact same function but modulo 2^64.

Copy link
Member

@mitschabaude mitschabaude Sep 20, 2024

Choose a reason for hiding this comment

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

first of all, all ranges are always of the form [a, b) i.e. open on the upper end

second, 128 bits is wrong. it uses a 1-bit range check on the quotient. for that to work, the addition result has to be at most 65 bits

also, you say further below that "the gadget assumes both inputs to be in the range [0, 2^64)". that will definitely work and is the intended use case, so stick with that here in the description as well

src/lib/provable/gadgets/gadgets.ts Show resolved Hide resolved
src/lib/provable/gadgets/gadgets.ts Show resolved Hide resolved
src/lib/provable/gadgets/gadgets.ts Outdated Show resolved Hide resolved
(x, y) => {
if (x >= 2n ** 64n || y >= 2n ** 64n)
throw Error('Does not fit into 64 bits');
return x & y;
Copy link
Member

Choose a reason for hiding this comment

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

why do you return & in this case but Bitwise.or in the next?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch! That should be | not &. Interestingly, it doesn't fail the unit test. Any ideas, @mitschabaude?

Copy link
Member

@mitschabaude mitschabaude Sep 5, 2024

Choose a reason for hiding this comment

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

my only idea is that you use such a small number of runs that with high likelyhood, one of the two inputs is bigger than $2^{64}$ in every single run, so you get an error every time.

try it with uint(64) as the random generator instead of maybeField!

Copy link
Member

Choose a reason for hiding this comment

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

I think you can actually remove this unit test that creates a proof, and stick with the ones above that only run a circuit and check constraints

The proof one will make CI slower and provides hardly any additional value

Copy link
Member Author

Choose a reason for hiding this comment

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

While developing smart contracts, sometimes it works without proofs but fails with them. Isn't it beneficial to test with proofs. Is it different for primitives?

Copy link
Member

Choose a reason for hiding this comment

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

It's different here yeah, for various reasons.

For one, we are already testing many extremely similar methods with proofs in this file, the additional surface covered is funny

src/lib/provable/int.ts Show resolved Hide resolved
function divMod64(n: Field, nBits = 128) {
assert(
nBits >= 0 && nBits < 255,
`nBits must be in the range [0, 255), got ${nBits}`
Copy link
Member

Choose a reason for hiding this comment

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

Is 255 not included because of Pasta curves?

Copy link
Member Author

Choose a reason for hiding this comment

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

I am not sure why we don't include 255. I followed the range checking practice used in divMod32. Maybe @mitschabaude knows the reason?

Copy link
Member

Choose a reason for hiding this comment

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

Because the field size is less than 2^255, there are 255 bit integers that have two different representations in the field (in fact that's true for almost all 255 bit integers since the field size is just slightly over 2^254)

That non-uniqueness would make the divmod gadget unsound: the prover could pick between two different quotient/remainder splits, one of which is wrong.

src/lib/provable/gadgets/arithmetic.ts Show resolved Hide resolved
}

function addMod64(x: Field, y: Field) {
return divMod64(x.add(y), 128).remainder;
Copy link
Member

Choose a reason for hiding this comment

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

What's the reasoning behind nBits being 128 in this case? (Instead of a larger or smaller value perhaps). Is it to prevent the quotient from being larger than 64 bits upon reduction?

Copy link
Member

Choose a reason for hiding this comment

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

yeah if both inputs are 64 bits then the result is 65 bits, that would cause an efficient (boolean) check for the quotient

Copy link
Member

Choose a reason for hiding this comment

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

so the question: can't we assume that the inputs are 64 bits and therefore use nBits=65?

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, it should be 65. It used to be 128 at some point. I adapted the changes made in #1763 and forgot to update nBits.

Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Finished my first pass, left some comments and questions 👍🏻

src/lib/provable/gadgets/blake2b.ts Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
for (let i = 0; i < input.length; i++) {
if (state.buflen === 128) {
state.t[0] = state.t[0].add(128);
if (state.t[0].equals(UInt64.zero)) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you explain in what situations this conditional would succeed? I am asking because it seems like the counter t[0] gets updated using a normal add, whereas the t[1] uses a modular addition instead. Are you thinking of a UInt64 overflow? If so, maybe it's better to use addMod64 here as well.

Copy link
Member

Choose a reason for hiding this comment

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

btw @boray this is a bug. you can't use a Bool as condition, it's an object, always truthy!

I highly recommend to do out-of-circuit logic on plain JS values, like bigint to avoid mistakes like this, i.e. here I would use

if (state.t[0].toBigint() === 0n) {

Copy link
Member

Choose a reason for hiding this comment

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

ah but wait, is this supposed to be circuit code and state.t[0] is a variable? then you fundamentally can't use a JS if condition anyway

Copy link
Member Author

Choose a reason for hiding this comment

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

state.t[0] is a variable but I am not sure if that logic should be in-circuit or not. I added the in-circuit version as a comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

I implemented this part with reference to the snippet below from the BLAKE2B RFC.

      ctx->t[0] += ctx->c;                // mark last block offset
       if (ctx->t[0] < ctx->c)             // carry overflow
           ctx->t[1]++;                    // high word

src/lib/provable/gadgets/blake2b.ts Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Outdated Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Show resolved Hide resolved
src/lib/provable/gadgets/blake2b.ts Show resolved Hide resolved
@querolita querolita self-assigned this Aug 29, 2024
Comment on lines 152 to 161
UInt64.from(
buf[i * 8]
.toUInt64()
.or(buf[i * 8 + 1].toUInt64().leftShift(8))
.or(buf[i * 8 + 2].toUInt64().leftShift(16))
.or(buf[i * 8 + 3].toUInt64().leftShift(24))
.or(buf[i * 8 + 4].toUInt64().leftShift(32))
.or(buf[i * 8 + 5].toUInt64().leftShift(40))
.or(buf[i * 8 + 6].toUInt64().leftShift(48))
.or(buf[i * 8 + 7].toUInt64().leftShift(56))
Copy link
Member

Choose a reason for hiding this comment

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

this should be done with addition and multiplication instead of or() and leftShift()

e.g.

- .or(buf[i * 8 + 1].toUInt64().leftShift(8))
+ .add(buf[i * 8 + 1].value.mul(1n << 8n))

in a circuit, addition and multiplication is cheap while bitwise operations are expensive!

you can also leave it and we do a second pass for efficiency, and you focus on correctness for now?

Copy link
Member Author

Choose a reason for hiding this comment

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

I wasn't aware that add/mul is cheaper than bitwise ops. Let's focus on correctness now and leave this improvement to the efficiency pass.

Copy link
Member

@querolita querolita Sep 17, 2024

Choose a reason for hiding this comment

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

Basically, as a rule of thumb, binary operations which are cheap on CPU are expensive in cryptography (where arithmetic operations in a field are the "cheap" operations instead). Modeling binary operations in a circuit becomes a big overhead, so anytime where binary ops can be replaced with arithmetic counterparts, is preferred for efficiency reasons.

Either way, let's not forget that second pass for efficiency in a later PR.

Copy link
Member

@mitschabaude mitschabaude Sep 20, 2024

Choose a reason for hiding this comment

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

@boray you can think of add(), mul() and assertEquals() as the primitive operations out of which everything else is built. nothing else is as efficient, and most things are far less efficient

Copy link
Member

@mitschabaude mitschabaude Sep 20, 2024

Choose a reason for hiding this comment

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

in the end, all of this turns into polynomial equations, and polynomials are made up of addition and multiplication

Copy link
Member Author

Choose a reason for hiding this comment

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

I refactored in the last commit!

Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

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

Leaving two more comments before acceptance. These are related to the actual constraints being created behind the scenes.

let nBigInt = n.toBigInt();
let q = nBigInt >> 64n;
let r = nBigInt - (q << 64n);
return {
Copy link
Member

Choose a reason for hiding this comment

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

I believe that when n is a constant, there are no constraints being created (the fields are returned before the rangechecks and division constraint are called). Is that the desired behavior?

Copy link
Member

Choose a reason for hiding this comment

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

Yes! For constant cases (where all variables of a computation are constant), we calculate the result without creating constraints and return another constant value

/*
state.t[1] = state.t[1].add(
Provable.if(
state.t[0].lessThan(UInt64.from(state.buflen)),
Copy link
Member

Choose a reason for hiding this comment

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

If this needs to go in the circuit, then the same lines of the update part should also be written in circuit manner. In any case, would it make sense to have a final constrain checking t[1]*2^64+t[0]=total_length? That, together with the modular additions gives you a correct decomposition.

Now, going back to what @mitschabaude said about the state.t[0] being a variable, one cannot decide the design of the circuit depending on the value of a witness cell. The circuit must have the same "universal shape", regardless of the concrete values of the variables.

So putting all the above together, it's not a matter of whether that logic needs to go into the circuit or not. It's about not being able to decide upon the concrete value a variable takes. Thus, I believe this part should be rewritten, perhaps with the use of Provable.if (but I might be missing the right o1js know-how, so maybe ask another team member for further insights).

At least in Kimchi, one would create a circuit that is already "bifurcated". Meaning, all the constraints hold and they are the same, no matter what concrete values are passed to the prover. So we play with multiplications to "select" one branch or another, but both sides should eventually evaluate to 0 to satisfy the constraint system.

Comment on lines +15 to +18
for (let { digest_length ,preimage, hash } of testVectors()) {
let actual = Gadgets.BLAKE2B.hash(Bytes.fromString(preimage), digest_length);
expect(actual.toHex()).toEqual(hash);
}
Copy link
Member

Choose a reason for hiding this comment

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

these tests should run in a circuit!


type State = {
h: UInt64[];
t: UInt64[];
Copy link
Member

Choose a reason for hiding this comment

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

as far as I can tell, t never contains variables, just constants that change depending on the input size (which is also constant)

therefore, it's better to change the type to bigint or number, to avoid confusion with variables (see other comments where we were confused):
Also, nicer to use a fixed-size array since it's just two elements.

Suggested change
t: UInt64[];
t: [bigint, bigint]

Comment on lines +261 to +269
/*
state.t[1] = state.t[1].add(
Provable.if(
state.t[0].lessThan(UInt64.from(state.buflen)),
UInt64.one,
UInt64.zero
)
);
*/
Copy link
Member

Choose a reason for hiding this comment

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

remove

divMod64,

/**
* Addition modulo 2^64. The operation adds two {@link Field} elements in the range [0, 2^128] and returns the result modulo 2^64.
Copy link
Member

@mitschabaude mitschabaude Sep 20, 2024

Choose a reason for hiding this comment

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

first of all, all ranges are always of the form [a, b) i.e. open on the upper end

second, 128 bits is wrong. it uses a 1-bit range check on the quotient. for that to work, the addition result has to be at most 65 bits

also, you say further below that "the gadget assumes both inputs to be in the range [0, 2^64)". that will definitely work and is the intended use case, so stick with that here in the description as well

Comment on lines 196 to 207
await equivalentAsync({ from: [maybeField, maybeField], to: field }, { runs })(
(x, y) => {
if (x >= 2n ** 64n || y >= 2n ** 64n)
throw Error('Does not fit into 64 bits');
return x | y;
},
async (x, y) => {
let proof = await Bitwise.or(x, y);
return proof.publicOutput;
}
);

Copy link
Member

Choose a reason for hiding this comment

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

I'd say remove this, the in-circuit equivalence tests above are much more thorough and creating a proof here just makes CI slower

@@ -204,6 +208,10 @@
"Poseidon": {
"rows": 208,
"digest": "afa1f9920f1f657ab015c02f9b2f6c52"
},
"BLAKE2B": {
"rows": 6012,
Copy link
Member

Choose a reason for hiding this comment

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

nice!

state.t[1] = state.t[1].addMod64(UInt64.one); // high word
}
state = compress(state, false); // compress (not last)
state.buflen = 0; // counter to zero
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this also reset state.buf to an empty array? If you don't do this, you'll start overwriting buf at the start but the rest of it will still contain the old entries.

and if yes, wouldn't it be less confusing in general to always use state.buf.length instead of an extra parameter buflen, so that they can't get out of sync?

Comment on lines +277 to +278
out[i] = UInt8.from(
state.h[i >> 3].rightShift(8 * (i & 7)).and(UInt64.from(0xff))
Copy link
Member

Choose a reason for hiding this comment

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

if you aim for efficiency: here you can also use arithmetic instead of bitwise operations

Copy link
Member Author

Choose a reason for hiding this comment

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

I tried to replace this line with state.h[i >> 3].div(2 ** (8 * (i & 7))).mod(0xff) but it increased the row number a lot. I am not sure if I'm missing something.

Copy link
Member

Choose a reason for hiding this comment

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

You can check out the bytesToWord function that's used in the keccak and sha2 implementations!
Probably a good idea to use that function

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.

[Prototype] Implement Blake Hashing for Mina Consensus Selection in o1js Circuit
5 participants