Noir library to verify JWT tokens and prove claims. Currently only supports RS256 with 2048 bit keys.
- Supports arbitrary sized JWTs.
- Supports partial SHA hashing on the signed data to save constraints.
- Can extract and verify claims of string, number, and boolean types efficiently.
You can learn more about JWT here. But in short, JWT is a data structure that contains three parts:
- Header: contains metadata about the token (JSON object with algorithm and type of token)
- Payload: contains the claims (JSON key-value pairs)
- Signature: RSA signature of the header and payload (assuming RS256 algorithm)
JWT token is a string represented as base64(header).base64(payload).base64(signature)
.
This noir library takes the signed data (which is base64(header).base64(payload)
), signature and the public key and verifies the signature (RSA-SHA256 verification).
There are utility methods to extract or verify claims from the payload, which is powered by the string_search lib.
In your Nargo.toml file, add jwt
as a dependency with the version you want to install:
[dependencies]
jwt = { tag = "v0.4.2", git = "https://github.com/zkemail/noir-jwt" }
Assuming you installed the latest version, you can use it in your Noir program like this:
use jwt::JWT;
global MAX_DATA_LENGTH: u32 = 900; // max length of signed data (headerb64 + "." + payloadb64)
global MAX_NONCE_LENGTH: u32 = 32; // we are verifying `nonce` claim
fn main(
data: BoundedVec<u8, MAX_DATA_LENGTH>,
base64_decode_offset: u32,
pubkey_modulus_limbs: pub [Field; 18],
redc_params_limbs: [Field; 18],
signature_limbs: [Field; 18],
expected_nonce: pub BoundedVec<u8, MAX_NONCE_LENGTH>
) {
let jwt = JWT::init(
data,
base64_decode_offset,
pubkey_modulus_limbs,
redc_params_limbs,
signature_limbs,
);
jwt.verify();
// Verify `iss` claim value is "test"
jwt.assert_claim_string("iss".as_bytes(), BoundedVec::<u8, 4>::from_array("test".as_bytes()));
}
use jwt::JWT;
global MAX_PARTIAL_DATA_LENGTH: u32 = 640; // Max length of the remaining data after partial hash
global MAX_NONCE_LENGTH: u32 = 32;
fn main(
partial_data: BoundedVec<u8, MAX_PARTIAL_DATA_LENGTH>,
partial_hash: [u32; 8],
full_data_length: u32,
base64_decode_offset: u32,
pubkey_modulus_limbs: pub [Field; 18],
redc_params_limbs: [Field; 18],
signature_limbs: [Field; 18],
nonce: pub BoundedVec<u8, MAX_NONCE_LENGTH>,
) {
let jwt = JWT::init_with_partial_hash(
partial_data,
partial_hash,
full_data_length,
base64_decode_offset,
pubkey_modulus_limbs,
redc_params_limbs,
signature_limbs,
);
jwt.verify();
// Validate key value pair in payload JSON
jwt.assert_claim_string("nonce".as_bytes(), nonce);
}
Here is an explanation of the input parameters used in the circuit. Note that you can use the JS SDK to generate these inputs.
data
is the signed data (headerb64 + "." + payloadb64)- When using partial SHA:
partial_data
is the data after the partial SHA.partial_hash
is the partial hash of the data before the partial SHA [8 limbs of 32 bits each]full_data_length
is the length of the full signed data (before partial SHA).
pubkey_modulus_limbs
,redc_params_limbs
,signature_limbs
are the limbs of the RSA public key, redc params (this is required for bignum lib), and signature respectively.base64_decode_offset
is the index indata
from which the circuit will try to decode the base64- We only need to decode the payload (or a portion of it), as the claims we want to extract are in the payload.
- Normally, you can set
base64_decode_offset
to be the start index of payload data (index after first.
in the JWT string) - Or, any multiple of 4 (as base64 decodes chunks of 4) from the start of the payload if you want to skip the first few bytes of the payload. This can be used to optimize some constraints if the claims you want to verify are usually in the middle or towards the end of the payload.
- When using partial SHA, this should be 1, 2, or 3 to make the data after partial hash base64 decode-able (a valid base64). This should be the number of bytes that needs to be sliced from the data_remaining_after_partial_hash to make it a valid base64.
-
get_claim_string
- extracts a string claim from the payload and returns it as aBoundedVec<u8, MAX_VALUE_LENGTH>
let claim: BoundedVec<u8, MAX_VALUE_LENGTH> = jwt.get_claim_string("email".as_bytes());
-
assert_claim_string
- verifies that the claim is present in the payload and is a valid base64 encoded stringjwt.assert_claim_string("nonce".as_bytes(), nonce);
-
get_claim_number
- extracts a number claim from the payload and returns it as au64
let claim: u64 = jwt.get_claim_number("nonce".as_bytes());
-
assert_claim_number
- verifies that the claim is present in the payload and is a valid numberjwt.assert_claim_number("nonce".as_bytes(), nonce);
-
get_claim_bool
- extracts a boolean claim from the payload and returns it as abool
let claim: bool = jwt.get_claim_bool("nonce".as_bytes());
-
assert_claim_bool
- verifies that the claim is present in the payload and is a valid booleanjwt.assert_claim_bool("nonce".as_bytes(), nonce);
Please note that the keys of the claims need to be known at compile time. This library doesn't support runtime JSON keys.
A JS SDK is included in the repo that can help you with generating inputs required for the JWT circuit. Since this is only a library, you would need to combine it with other input used in your application circuit.
Install the dependency
npm install noir-jwt
const { generateInputs } = require("noir-jwt");
const inputs = generateInputs({
jwt,
pubkey,
maxSignedDataLength,
shaPrecomputeTillKeys,
});
where:
jwt
is the JWT token to process (string)pubkey
is the public key to verify the signature inJsonWebKey
formatmaxSignedDataLength
is the maximum length of signed data (with or without partial hash). This should be same asMAX_DATA_LENGTH
configured in your circuit.shaPrecomputeTillKeys
(optional) is the claim key(s) in the payload until which SHA should be precomputed. You can specify the claims you are extracting in the circuit, and the JS SDK will precompute SHA up to the first claim.