What are ZK Proofs
In simpler words, Zero Knowledge Proof help a prover verify a statement is true without revealing the details. Lets see some examples to understand
- You can prove that you know the number which is a pre-image of a value without revealing the number
- You can prove that you know the private key corresponding to a public key
- You can prove that you are one of 50 students in a class without revealing your details
We have some public and private inputs which fed into a ZK circuit will generate output(s). Verifier can be certain that prover knows the private inputs if the output matches the value which they are looking for
In circom circuits, inputs are private and outputs are public by default.
ZK login
Sui’s ZK login process takes users JWT and salt and generates input signals which the circuit requires. If circuit runs successfully then we know that user knows the JWT and the private salt. They have a very detailed documentation of the whole process here. Please check it out
To understand the circuit better, lets take the following JWT as input. This is the same JWT used in their tests
Circuit is broadly divided in 10 steps. Lets go through it one by one. Total list of inputs can be seen here. Before we go through the steps lets look at one of the helper functions which we will use to calculate hash values
HashBytesToField: (Poseidon Hash of string)
- Lets calculate poseidon hash of name field : “sub” with length set to 32 characters
- First convert sub to ascii array=[115,117,98];
- Pad it by 0 to total length of 32 bytes. [115, 117, 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
- Divide this array in chunks of 31 bytes [117, 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],[115] in little endian format (big integer in multiple of 8 less than 254 = 248 i.e. 31 bytes)
- Convert this array to big integer [“207397477721056441812414413661424614846155824613720115982633852925168844800”, “115”]
- Feed this to Poseidon hash function to get hash 9102752833182448263444250585012134730074321235810986230287216596098480554553
In addition to above we need to find starting index of few of the fields in base64 encoded JWT. Below is an illustration of steps to get there
Step 1: Parse out the JWT Header
This requires 2 input signals.
signal input padded_unsigned_jwt[inCount];
signal input payload_start_index; //header length + '.'.length
Steps to get padded_unsigned_jwt (SHA2 padded)
- Remove signature from JWT. We need only header + ‘.’ + payload (in base64)
- Convert the modified JWT to binary format
- Add “1” at end of this string
- We need to create a padded string with length in multiple of 512 (SHA2 block is 64 bytes in size). Fill the rest of the string except last 8 bits.
- In last 8 bits, store the length of modified JWT in binary format
- Pad the string to max length provided in circuit. For our case its 1600
- Convert the string to byte format
Below is js code for below. Github link
const getUnsignedPaddedJWT=({jwt,length,paddingValue}:{jwt: string,length: number,paddingValue:number}):
{paddedUnsignedJwt:number[],payloadLen:number,numSha2Blocks: number,payloadStartIndex:number}=>{
const jwtArray=jwt.split('.');
const s=jwtArray[0]+'.'+jwtArray[1];
//get binary
let jwtBinary = getBigNumber( getPaddedBase64Ascii( {base64:s,length:s.length,paddingValue:0})).toString(2).padStart(s.length*8,"0");
const len=jwtBinary.length;
//Add 1
jwtBinary=jwtBinary+"1";
//Fill rest of array with 0's except last byte
Array(512-64-jwtBinary.length%512).fill(0).map(_m=>jwtBinary+="0");
//Fill last byte with len
jwtBinary+=BigInt(len).toString(2).padStart(64,"0");
const numSha2Blocks=jwtBinary.length/512;
const unsignedPaddedJWT:number[]=[];
chunkString(jwtBinary,8)!.map(m=>unsignedPaddedJWT.push(parseInt(BigInt('0b'+m).toString(10))));
Array(length-unsignedPaddedJWT.length).fill(0).map(m=>unsignedPaddedJWT.push(paddingValue));
return {paddedUnsignedJwt:unsignedPaddedJWT,numSha2Blocks,payloadLen:jwtArray[1].length,
payloadStartIndex:jwtArray[0].length+1};
}
Apart from this, there is a check that ‘.’ appears at correct position in JWT. Header Field is also created using hash bytes to field function which we mentioned above.
Step 2: Check validity of SHA2 padding & Compute SHA2
Inputs for this step
signal input num_sha2_blocks; //number of sha2 blocks used to created SHA2 padded jwt. 10 for google's jwt 5120/512
signal input payload_len; //length of payload in jwt
Step 3: Check signature
In this step, signature and modulus are input signals. This part verifies if the RSA signature is valid or not.
signal input signature[32];
signal input modulus[32];
We need to do the following to convert signature and modulus in above format. We need to pass limbs (word of size of 8 bytes) as input. We have to follow below steps
- Siginature and modulus are 2^2048 size numbers. Convert the string to BigInt
- Convert the bigint to boolean and pad to 2048 chars.
- Chunk boolean in 32 char size and convert the chunk to bigint number of base 10 (decimal)
- Reverse the array to make it little endian
To get modulus for signature in google auth, we need to download the modulus data from here. Modulus updates once everyday or so. It usually has more than one set of value. In JWT header, there key id (‘kid’), which we need to match in json data to get correct modulus.
Step 4: Check extend key claim & extract key name & value
Inputs for this step:
signal input ext_kc[maxExtKCLen];
signal input ext_kc_length;
signal input kc_index_b64;
signal input kc_length_b64;
signal input kc_name_length;
signal input kc_colon_index;
signal input kc_value_index;
signal input kc_value_length;
This step of the circuit takes the above inputs and parses the original google jwt to get the extended claims name and value fields. It compares it with the above inputs. Apart from this two more temp signals are created for future use kc_name_F & kc_value_F. They are poseidon hashes for the fields. We calculate it using the helper function HashBytesToField mentioned above. Also below is illustration of how to calculate the above fields
Step 5: Check the nonce in JWT
Inputs for this step. Similar to above step but few less fields are required as name will always be “nonce” and length of nonce will always be 27.
signal input ext_nonce[maxExtNonceLength];
signal input ext_nonce_length;
signal input nonce_index_b64;
signal input nonce_length_b64;
signal input nonce_colon_index;
signal input kc_value_index;
signal input eph_public_key[2];
signal input max_epoch;
signal input jwt_randomness;
Similar to above step, circuit extracts nonce value and validates it in jwt. Second part of the step is to check if input signals to calculate nonce match or not
We calculate eph_public_key array by first creating Ed25519PublicKey. Then convert into BigInt(Big Endian). eph_public_key[0]= publicKey / 2^128, eph_public_key[1]= publicKey % 2^128
Nonce is poseidon hash of (eph_public_key[0],eph_public_key[1], max_epoch , jwt_randomness). Frontend creates these values before login is done so that nonce can be passed as argument in JWT. This confirms that user knows owns the JWT. Also nonce is always of 27 bytes because we take the last 40 chars of 64 char poseidon hash hex value. 40chars =20 bytes which in turn creates a base64 string of 28 bytes with last char as ‘=’ which we remove.
Step 6: Check email verified claim
In our case this will be nonce string as key claim is not email for our google JWT. Verification happens similar to that of step 4. Inputs for this step
signal input ext_ev[maxExtEVLength]
signal input ex_ev_length;
signal input ex_index_b64;
signal input ev_length_b64;
signal input name_length;
signal input ev_colon_index;
signal input ev_value_index;
signal input ev_value_length;
Step 7: Check and extract aud
Here are the input signals for this step. Similar to step 4, we extract aud and validate it and tt the end we store aud_value_F by hashing byte value to field.
signal input ext_aud[maxExtAudLength]
signal input ex_aud_length;
signal input aud_index_b64;
signal input aud_length_b64;
signal input aud_colon_index;
signal input aud_value_index;
signal input aud_value_length;
Step 8: Reveal the iss portion of base64 jwt
Here are the input signals for this step
signal input iss_index_b64;
signal input iss_length_b64;
Circuit slices the iss from jwt based on the above index and length. Then circuit calculates poseidon hash and position modulo 4 of iss in JWT.
Step 9: Compute the address seed
Here is the input signal for this step
signal input salt;
Circuit uses salt that there is no connect between JWT (web2) and Sender Address (web3) and no one without the salt can generate the address.
Address send = Poseidon Hash of ( kc_name_F (calculated in step 4), kc_value_F (calculated in step 4), aud_value_F (calculated in step 7), Poseidon_Hash(salt))
Note: Salt is hashed first and then hashed together with other fields
Step 10: Validate the public signal matches hash of the private signals
Here is the input signal for this step
signal input all_inputs_hash;
The above hash is hash of values listed below
- eph_public_key[0] //used to calculate nonce in frontend
- eph_public_key[1] //used to calculate nonce in frontend
- address_seed //calculated in step 9
- max_epoch //used to calculate nonce in frontend
- iss_b64_F //Poseidon hash of 32 byte fields of iss in base64 format.
- iss_index_in_payload_mod_4 //Position modulo 4 of starting index of iss in JWT. It can be 0,1,2,3
- header_F //Hash of 32 byte fields of header
- modulus_F //Hash of 32 bytes fields of modulus
If all constraints match zk circuits passes and login can proceed.