feat(circuits): add GCP JWT verifier with TEE attestation claims extraction (#1317)

* chore(scripts): update Power of Tau download source URL

* fix(build): use correct node_modules paths in circom compile step

* chore(deps): add dependencies for GCP JWT verifier

* feat(circuits): add extractAndValidatePubkey utility circuit for GCP JWT

* feat(circuits): add verifyCertificateSignature utility circuit for GCP JWT

* feat(circuits): add verifyExtractedString utility circuit for GCP JWT

* feat(circuits): implement GCP JWT verifier circuit

* feat(scripts): add build script for GCP JWT verifier circuit

* chore(deps): pin crypto-circuit dependencies to exact versions

* build(circuits): bump jwt_verifier.circom version to 2.1.9

* build(scripts): improve gcp_jwt_verifier error handling

* chore(gcp_jwt_verifier): remove string verifier

* refactor: optimize and improve GCP JWT Verifier

* fix(circuits): enforce colon after JSON key name

* fix(circuits): add JSON parsing offset validation constraints

* fix: enforce JSON array structure validation in field extraction

* fix: add value_length validation to prevent partial extraction

---------

Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
d0x471b
2025-11-10 14:27:36 +00:00
committed by GitHub
parent 1282cb5520
commit 90ccfe4f26
12 changed files with 1839 additions and 36 deletions

View File

@@ -0,0 +1,96 @@
# GCP JWT Verifier Circuit
Zero-knowledge circuits for verifying [Google Cloud Platform Confidential Space JWT attestations](https://cloud.google.com/confidential-computing/confidential-space/docs/confidential-space-overview) with complete X.509 certificate chain validation.
> **Warning**: This circuit uses [`zk-jwt`](https://github.com/zkemail/zk-jwt) which is not yet audited (as of October 2025).
## Overview
This circuit verifies GCP Confidential Space JWT attestations by validating the complete chain of trust:
1. JWT Signature: Verifies the JWT was signed by the leaf certificate (x5c[0])
2. Leaf Certificate: Verifies x5c[0] was signed by the intermediate CA (x5c[1])
3. Intermediate Certificate: Verifies x5c[1] was signed by the root CA (x5c[2])
## Public Outputs
The circuit exposes the following data as public outputs:
- `publicKeyHash`, Poseidon hash of leaf certificate public key
- `header`, Decoded JWT header
- `payload`, Decoded JWT payload
- `eat_nonce_0_b64_output`, Base64URL encoded EAT nonce
- `image_hash`, Container image SHA256 hash
## Architecture
The main circuit does:
1. JWT signature verification (using x5c[0] pubkey)
2. x5c[0] pubkey extraction and validation
3. x5c[0] certificate signature verification (using x5c[1] pubkey)
4. x5c[1] pubkey extraction and validation
5. x5c[1] certificate signature verification (using x5c[2] pubkey)
6. x5c[2] pubkey extraction and validation
7. EAT nonce extraction and validation
8. Container image digest extraction and validation
## Usage
### 1. Prepare Circuit Inputs
Extract data from a GCP JWT attestation:
```bash
cd circuits
yarn tsx circuits/gcp_jwt_verifier/prepare.ts \
circuits/gcp_jwt_verifier/example_jwt.txt \
circuit_inputs.json
```
The `prepare.ts` script:
- Parses the JWT header and payload
- Extracts all 3 x5c certificates from the header
- Extracts public keys and signatures from each certificate
- Computes certificate hashes with proper padding
- Locates EAT nonce and image digest in the payload
- Converts all data to circuit-compatible format
### 2. Build Circuit
Compile the circuit and generate proving/verification keys:
```bash
yarn build-gcp-jwt-verifier
```
This runs `scripts/build/build_gcp_jwt_verifier.sh` which:
- Compiles the circuit to R1CS and WASM
- Generates zkey (proving key)
- Exports verification key
### 3. Generate & Verify Proof
```bash
# Generate witness
node build/gcp/gcp_jwt_verifier/gcp_jwt_verifier_js/generate_witness.js \
build/gcp/gcp_jwt_verifier/gcp_jwt_verifier_js/gcp_jwt_verifier.wasm \
circuit_inputs.json \
witness.wtns
# Generate proof
snarkjs groth16 prove \
build/gcp/gcp_jwt_verifier/gcp_jwt_verifier_final.zkey \
witness.wtns \
proof.json \
public.json
# Verify proof
snarkjs groth16 verify \
build/gcp/gcp_jwt_verifier/gcp_jwt_verifier_vkey.json \
public.json \
proof.json
```
## References
- [GCP Confidential Space Documentation](https://cloud.google.com/confidential-computing/confidential-space/docs/confidential-space-overview)
- [GCP Token Claims Reference](https://cloud.google.com/confidential-computing/confidential-space/docs/reference/token-claims)
- [EAT Nonce Specification](https://cloud.google.com/confidential-computing/confidential-space/docs/connect-external-resources)
- [zk-jwt Library](https://github.com/zkemail/zk-jwt)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,252 @@
pragma circom 2.1.9;
include "./jwt_verifier.circom";
include "../utils/passport/signatureAlgorithm.circom";
include "../utils/passport/customHashers.circom";
include "../utils/gcp_jwt/extractAndValidatePubkey.circom";
include "../utils/gcp_jwt/verifyCertificateSignature.circom";
include "../utils/gcp_jwt/verifyJSONFieldExtraction.circom";
include "circomlib/circuits/comparators.circom";
include "@openpassport/zk-email-circuits/utils/array.circom";
/// @title GCPJWTVerifier
/// @notice Verifies GCP JWT signature and full x5c certificate chain
/// @dev Complete chain-of-trust verification in-circuit:
/// x5c[0]: Leaf certificate (signs JWT)
/// x5c[1]: Intermediate CA (signs x5c[0])
/// x5c[2]: Root CA (signs x5c[1])
template GCPJWTVerifier(
signatureAlgorithm, // 1 for RSA-SHA256
n, // RSA chunk size (120)
k // Number of chunks (35)
) {
// JWT parameters
var maxMessageLength = 11776;
var maxB64HeaderLength = 8832;
var maxB64PayloadLength = 2880;
// Certificate parameters
var MAX_CERT_LENGTH = 2048; // Max DER-encoded certificate size
var MAX_PUBKEY_PREFIX = 33; // ASN.1 prefix length (from DSC)
var MAX_PUBKEY_LENGTH = n * k / 8; // Max RSA pubkey length in bytes
var kLengthFactor = getKLengthFactor(signatureAlgorithm);
var kScaled = k * kLengthFactor;
var hashLength = getHashLength(signatureAlgorithm);
var suffixLength = kLengthFactor == 1 ? getSuffixLength(signatureAlgorithm) : 0;
// JWT inputs
signal input message[maxMessageLength]; // JWT header.payload
signal input messageLength;
signal input periodIndex;
// x5c[0] - Leaf certificate (DER encoded, padded for SHA)
signal input leaf_cert[MAX_CERT_LENGTH];
signal input leaf_cert_padded_length; // Padded length for SHA256
signal input leaf_pubkey_offset; // Offset to pubkey in cert
signal input leaf_pubkey_actual_size; // Actual pubkey size in bytes
// x5c[1] - Intermediate CA certificate
signal input intermediate_cert[MAX_CERT_LENGTH];
signal input intermediate_cert_padded_length;
signal input intermediate_pubkey_offset;
signal input intermediate_pubkey_actual_size;
// x5c[2] - Root CA certificate
signal input root_cert[MAX_CERT_LENGTH];
signal input root_cert_padded_length;
signal input root_pubkey_offset;
signal input root_pubkey_actual_size;
// Public keys (extracted from certificates)
signal input leaf_pubkey[kScaled]; // From x5c[0]
signal input intermediate_pubkey[kScaled]; // From x5c[1]
signal input root_pubkey[kScaled]; // From x5c[2]
// Signatures
signal input jwt_signature[kScaled]; // JWT signature
signal input leaf_signature[kScaled]; // x5c[0] signature
signal input intermediate_signature[kScaled]; // x5c[1] signature
// GCP spec: nonce must be 10-74 bytes decoded
// Base64url encoding: 10 bytes = 14 chars, 74 bytes = 99 chars
// https://cloud.google.com/confidential-computing/confidential-space/docs/connect-external-resources
// EAT nonce (payload.eat_nonce[0])
var MAX_EAT_NONCE_B64_LENGTH = 99; // Max length for base64url string (74 bytes decoded = 99 b64url chars)
var MAX_EAT_NONCE_KEY_LENGTH = 10; // Length of "eat_nonce" key (without quotes)
signal input eat_nonce_0_b64_length; // Length of base64url string
signal input eat_nonce_0_key_offset; // Offset in payload where "eat_nonce" key starts (after opening quote)
signal input eat_nonce_0_value_offset; // Offset in payload where eat_nonce[0] value appears
// Container image digest (payload.submods.container.image_digest)
var MAX_IMAGE_DIGEST_LENGTH = 71; // "sha256:" + 64 hex chars
var IMAGE_HASH_LENGTH = 64; // Just the hex hash portion
var MAX_IMAGE_DIGEST_KEY_LENGTH = 12; // Length of "image_digest" key (without quotes)
signal input image_digest_length; // Length of full string (should be 71)
signal input image_digest_key_offset; // Offset in payload where "image_digest" key starts (after opening quote)
signal input image_digest_value_offset; // Offset in payload where image_digest value appears
var maxHeaderLength = (maxB64HeaderLength * 3) \ 4;
var maxPayloadLength = (maxB64PayloadLength * 3) \ 4;
signal output rootCAPubkeyHash; // Root CA (x5c[2]) pubkey, trust anchor
signal output eat_nonce_0_b64_output[MAX_EAT_NONCE_B64_LENGTH]; // eat_nonce[0] base64url string
signal output image_hash[IMAGE_HASH_LENGTH]; // Container image SHA256 hash (without "sha256:" prefix)
// Verify JWT Signature (using x5c[0] public key)
component jwtVerifier = JWTVerifier(n, k, maxMessageLength, maxB64HeaderLength, maxB64PayloadLength);
jwtVerifier.message <== message;
jwtVerifier.messageLength <== messageLength;
jwtVerifier.pubkey <== leaf_pubkey;
jwtVerifier.signature <== jwt_signature;
jwtVerifier.periodIndex <== periodIndex;
// Poseidon hash of root CA pubkey (x5c[2])
rootCAPubkeyHash <== CustomHasher(kScaled)(root_pubkey);
signal payload[maxPayloadLength];
payload <== jwtVerifier.payload;
// Extract and validate x5c[0] Public Key
ExtractAndValidatePubkey(signatureAlgorithm, n, k, MAX_CERT_LENGTH, MAX_PUBKEY_PREFIX, MAX_PUBKEY_LENGTH)(
leaf_cert,
leaf_pubkey_offset,
leaf_pubkey_actual_size,
leaf_pubkey
);
// Extract and validate x5c[1] public key
ExtractAndValidatePubkey(signatureAlgorithm, n, k, MAX_CERT_LENGTH, MAX_PUBKEY_PREFIX, MAX_PUBKEY_LENGTH)(
intermediate_cert,
intermediate_pubkey_offset,
intermediate_pubkey_actual_size,
intermediate_pubkey
);
// Verify x5c[0] signature using x5c[1] public key
VerifyCertificateSignature(signatureAlgorithm, n, k, MAX_CERT_LENGTH)(
leaf_cert,
leaf_cert_padded_length,
intermediate_pubkey,
leaf_signature
);
// Extract and validate x5c[2] public key
ExtractAndValidatePubkey(signatureAlgorithm, n, k, MAX_CERT_LENGTH, MAX_PUBKEY_PREFIX, MAX_PUBKEY_LENGTH)(
root_cert,
root_pubkey_offset,
root_pubkey_actual_size,
root_pubkey
);
// Verify x5c[1] signature using x5c[2] public key
VerifyCertificateSignature(signatureAlgorithm, n, k, MAX_CERT_LENGTH)(
intermediate_cert,
intermediate_cert_padded_length,
root_pubkey,
intermediate_signature
);
// Make sure nonce is not empty
component length_nonzero = IsZero();
length_nonzero.in <== eat_nonce_0_b64_length;
length_nonzero.out === 0; // Must NOT be zero
// Validate nonce minimum length (10 bytes decoded = 14 base64url chars)
component length_min_check = GreaterEqThan(log2Ceil(MAX_EAT_NONCE_B64_LENGTH));
length_min_check.in[0] <== eat_nonce_0_b64_length;
length_min_check.in[1] <== 14;
length_min_check.out === 1;
// Validate nonce maximum length (74 bytes decoded = 99 base64url chars)
component length_max_check = LessEqThan(log2Ceil(MAX_EAT_NONCE_B64_LENGTH));
length_max_check.in[0] <== eat_nonce_0_b64_length;
length_max_check.in[1] <== 99;
length_max_check.out === 1;
// Validate nonce offset bounds (prevent reading beyond payload)
signal eat_nonce_end_position <== eat_nonce_0_value_offset + eat_nonce_0_b64_length;
component offset_bounds_check = LessEqThan(log2Ceil(maxPayloadLength));
offset_bounds_check.in[0] <== eat_nonce_end_position;
offset_bounds_check.in[1] <== maxPayloadLength;
offset_bounds_check.out === 1;
// Extract and verify EAT nonce field
signal expected_eat_nonce_key[MAX_EAT_NONCE_KEY_LENGTH];
// "eat_nonce", ASCII
expected_eat_nonce_key[0] <== 101; // 'e'
expected_eat_nonce_key[1] <== 97; // 'a'
expected_eat_nonce_key[2] <== 116; // 't'
expected_eat_nonce_key[3] <== 95; // '_'
expected_eat_nonce_key[4] <== 110; // 'n'
expected_eat_nonce_key[5] <== 111; // 'o'
expected_eat_nonce_key[6] <== 110; // 'n'
expected_eat_nonce_key[7] <== 99; // 'c'
expected_eat_nonce_key[8] <== 101; // 'e'
expected_eat_nonce_key[9] <== 0; // padding
component eatNonceExtractor = ExtractAndVerifyJSONField(maxPayloadLength, MAX_EAT_NONCE_KEY_LENGTH, MAX_EAT_NONCE_B64_LENGTH);
eatNonceExtractor.json <== payload;
eatNonceExtractor.key_offset <== eat_nonce_0_key_offset;
eatNonceExtractor.key_length <== 9; // actual key length "eat_nonce"
eatNonceExtractor.value_offset <== eat_nonce_0_value_offset;
eatNonceExtractor.value_length <== eat_nonce_0_b64_length;
eatNonceExtractor.expected_key_name <== expected_eat_nonce_key;
// Output the extracted base64url string
eat_nonce_0_b64_output <== eatNonceExtractor.extracted_value;
// Validate length is exactly 71 ("sha256:" + 64 hex chars)
image_digest_length === 71;
// Validate offset bounds
signal image_digest_end_position <== image_digest_value_offset + image_digest_length;
component image_digest_bounds_check = LessEqThan(log2Ceil(maxPayloadLength));
image_digest_bounds_check.in[0] <== image_digest_end_position;
image_digest_bounds_check.in[1] <== maxPayloadLength;
image_digest_bounds_check.out === 1;
// Extract and verify image digest field
signal expected_image_digest_key[MAX_IMAGE_DIGEST_KEY_LENGTH];
// "image_digest", ASCII
expected_image_digest_key[0] <== 105; // 'i'
expected_image_digest_key[1] <== 109; // 'm'
expected_image_digest_key[2] <== 97; // 'a'
expected_image_digest_key[3] <== 103; // 'g'
expected_image_digest_key[4] <== 101; // 'e'
expected_image_digest_key[5] <== 95; // '_'
expected_image_digest_key[6] <== 100; // 'd'
expected_image_digest_key[7] <== 105; // 'i'
expected_image_digest_key[8] <== 103; // 'g'
expected_image_digest_key[9] <== 101; // 'e'
expected_image_digest_key[10] <== 115; // 's'
expected_image_digest_key[11] <== 116; // 't'
component imageDigestExtractor = ExtractAndVerifyJSONField(maxPayloadLength, MAX_IMAGE_DIGEST_KEY_LENGTH, MAX_IMAGE_DIGEST_LENGTH);
imageDigestExtractor.json <== payload;
imageDigestExtractor.key_offset <== image_digest_key_offset;
imageDigestExtractor.key_length <== 12; // actual key length "image_digest"
imageDigestExtractor.value_offset <== image_digest_value_offset;
imageDigestExtractor.value_length <== image_digest_length;
imageDigestExtractor.expected_key_name <== expected_image_digest_key;
signal extracted_image_digest[MAX_IMAGE_DIGEST_LENGTH];
extracted_image_digest <== imageDigestExtractor.extracted_value;
// "sha256:", ASCII
extracted_image_digest[0] === 115; // 's'
extracted_image_digest[1] === 104; // 'h'
extracted_image_digest[2] === 97; // 'a'
extracted_image_digest[3] === 50; // '2'
extracted_image_digest[4] === 53; // '5'
extracted_image_digest[5] === 54; // '6'
extracted_image_digest[6] === 58; // ':'
// Extract and output only the 64-char hash (skip "sha256:" prefix)
for (var i = 0; i < IMAGE_HASH_LENGTH; i++) {
image_hash[i] <== extracted_image_digest[7 + i];
}
}
component main = GCPJWTVerifier(1, 120, 35);

View File

@@ -0,0 +1,203 @@
pragma circom 2.1.9;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/bitify.circom";
include "@openpassport/zk-email-circuits/utils/array.circom";
include "@openpassport/zk-email-circuits/utils/hash.circom";
include "@openpassport/zk-email-circuits/lib/sha.circom";
include "@openpassport/zk-email-circuits/lib/base64.circom";
include "../utils/passport/signatureVerifier.circom";
include "../utils/passport/customHashers.circom";
include "../utils/crypto/bitify/bytes.circom";
/// @title SelectSubArrayBase64
/// @notice Select sub array from an array and pad with 'A' for Base64
/// @notice This is similar to `SelectSubArray` but pads with 'A' (ASCII 65) instead of zero
/// @notice Useful for preparing Base64 encoded data for decoding
/// @param maxArrayLen: the maximum number of bytes in the input array
/// @param maxSubArrayLen: the maximum number of integers in the output array
/// @input in: the input array
/// @input startIndex: the start index of the sub array; assumes a valid index
/// @input length: the length of the sub array; assumes to fit in `ceil(log2(maxArrayLen))` bits
/// @output out: array of `maxSubArrayLen` size, items starting from `startIndex`, and items after `length` set to 'A' (ASCII 65)
/// @see https://github.com/zkemail/zk-jwt/blob/3a50a9b/packages/circuits/utils/array.circom#L15
template SelectSubArrayBase64(maxArrayLen, maxSubArrayLen) {
assert(maxSubArrayLen <= maxArrayLen);
signal input in[maxArrayLen];
signal input startIndex;
signal input length;
signal output out[maxSubArrayLen];
component shifter = VarShiftLeft(maxArrayLen, maxSubArrayLen);
shifter.in <== in;
shifter.shift <== startIndex;
component gts[maxSubArrayLen];
for (var i = 0; i < maxSubArrayLen; i++) {
gts[i] = GreaterThan(log2Ceil(maxSubArrayLen));
gts[i].in[0] <== length;
gts[i].in[1] <== i;
// Pad with 'A' (ASCII 65) instead of zero
out[i] <== gts[i].out * shifter.out[i] + (1 - gts[i].out) * 65;
}
}
/// @title FindRealMessageLength
/// @notice Finds the length of the real message in a padded array by locating the first occurrence of 128
/// @dev This template is specifically designed for Base64 encoded strings followed by SHA-256 padding.
/// It works because:
/// 1. Base64 uses characters with ASCII values < 128
/// 2. SHA-256 padding starts with 128 (10000000 in binary)
/// 3. The first 128 encountered marks the end of the Base64 string and start of padding
/// @input in[maxLength] The padded message array
/// @input maxLength The maximum possible length of the padded message
/// @output realLength The length of the real message (before padding)
/// @see https://github.com/zkemail/zk-jwt/blob/3a50a9b/packages/circuits/utils/bytes.circom#L15
template FindRealMessageLength(maxLength) {
signal input in[maxLength];
signal output realLength;
// Signal to track if we've found 128
signal found[maxLength + 1];
found[0] <== 0;
// Signal to accumulate the length
signal lengthAcc[maxLength + 1];
lengthAcc[0] <== 0;
signal is128[maxLength];
// Iterate through the array
for (var i = 0; i < maxLength; i++) {
// Check if current element is 128
is128[i] <== IsEqual()([in[i], 128]);
// Update found signal
found[i + 1] <== found[i] + is128[i] - found[i] * is128[i];
// If 128 not found yet, increment length
lengthAcc[i + 1] <== lengthAcc[i] + 1 - found[i + 1];
}
// The final accumulated length is our real message length
realLength <== lengthAcc[maxLength];
// Constraint to ensure 128 was really found
found[maxLength] === 1;
}
/// @title CountCharOccurrences
/// @notice Counts the number of occurrences of a specified character in an array
/// @dev This template iterates through the input array and counts how many times the specified character appears.
/// @input in[maxLength] The input array in which to count occurrences of the character
/// @input char The character to count within the input array
/// @output count The number of times the specified character appears in the input array
/// @see https://github.com/zkemail/zk-jwt/blob/3a50a9b/packages/circuits/utils/bytes.circom#L54
template CountCharOccurrences(maxLength) {
signal input in[maxLength];
signal input char;
signal output count;
signal match[maxLength];
signal counter[maxLength];
match[0] <== IsEqual()([in[0], char]);
counter[0] <== match[0];
for (var i = 1; i < maxLength; i++) {
match[i] <== IsEqual()([in[i], char]);
counter[i] <== counter[i-1] + match[i];
}
count <== counter[maxLength-1];
}
/// @title JWTVerifier
/// @notice Verifies JWT signatures and extracts header/payload components
/// @dev This template verifies RSA-SHA256 signed JWTs and decodes Base64 encoded components.
/// It works by:
/// 1. Verifying message length and padding
/// 2. Computing SHA256 hash of `header.payload`
/// 3. Verifying RSA signature against public key
/// 4. Extracting and decoding Base64 header/payload
/// 5. Computing public key hash for external reference
/// @param n RSA chunk size in bits (n < 127 for field arithmetic)
/// @param k Number of RSA chunks (n*k > 2048 for RSA-2048)
/// @param maxMessageLength Maximum JWT string length (must be multiple of 64 for SHA256)
/// @param maxB64HeaderLength Maximum Base64 header length (must be multiple of 4)
/// @param maxB64PayloadLength Maximum Base64 payload length (must be multiple of 4)
/// @input message[maxMessageLength] JWT string (header.payload)
/// @input messageLength Actual length of JWT string
/// @input pubkey[k] RSA public key in k chunks
/// @input signature[k] RSA signature in k chunks
/// @input periodIndex Location of period separating header.payload
/// @output publicKeyHash Poseidon hash of public key
/// @output header[maxHeaderLength] Decoded JWT header
/// @output payload[maxPayloadLength] Decoded JWT payload
/// @notice Modified version of ZK-Email's `JWTVerifier`, adapted to use Self's `SignatureVerifier` circuit for RSA verification
/// @see https://github.com/zkemail/zk-jwt/blob/3a50a9b/packages/circuits/jwt-verifier.circom#L35
template JWTVerifier(
n,
k,
maxMessageLength,
maxB64HeaderLength,
maxB64PayloadLength
) {
signal input message[maxMessageLength]; // JWT message (header + payload)
signal input messageLength; // Length of the message signed in the JWT
signal input pubkey[k]; // RSA public key split into k chunks
signal input signature[k]; // RSA signature split into k chunks
signal input periodIndex; // Index of the period in the JWT message
var maxHeaderLength = (maxB64HeaderLength * 3) \ 4;
var maxPayloadLength = (maxB64PayloadLength * 3) \ 4;
signal output publicKeyHash;
signal output header[maxHeaderLength];
signal output payload[maxPayloadLength];
// Assert message length fits in ceil(log2(maxMessageLength))
component n2bMessageLength = Num2Bits(log2Ceil(maxMessageLength));
n2bMessageLength.in <== messageLength;
// Assert message data after messageLength are zeros
AssertZeroPadding(maxMessageLength)(message, messageLength);
// Calculate SHA256 hash of the JWT message
signal sha[256] <== Sha256Bytes(maxMessageLength)(message, messageLength);
SignatureVerifier(1, n, k)(
sha,
pubkey,
signature
);
// Calculate the pubkey hash
publicKeyHash <== CustomHasher(k)(pubkey);
// Assert that period exists at periodIndex
signal period <== ItemAtIndex(maxMessageLength)(message, periodIndex);
period === 46;
// Assert that period is unique
signal periodCount <== CountCharOccurrences(maxMessageLength)(message, 46);
periodCount === 1;
// Find the real message length
signal realMessageLength <== FindRealMessageLength(maxMessageLength)(message);
// Calculate the length of the Base64 encoded header and payload
signal b64HeaderLength <== periodIndex;
signal b64PayloadLength <== realMessageLength - b64HeaderLength - 1;
// Extract the Base64 encoded header and payload from the message
signal b64Header[maxB64HeaderLength] <== SelectSubArrayBase64(maxMessageLength, maxB64HeaderLength)(message, 0, b64HeaderLength);
signal b64Payload[maxB64PayloadLength] <== SelectSubArrayBase64(maxMessageLength, maxB64PayloadLength)(message, b64HeaderLength + 1, b64PayloadLength);
// Decode the Base64 encoded header and payload
header <== Base64Decode(maxHeaderLength)(b64Header);
payload <== Base64Decode(maxPayloadLength)(b64Payload);
}

View File

@@ -0,0 +1,394 @@
/**
* Prepares GCP JWT attestations with full certificate chain verification
*
* Extracts all 3 x5c certificates, their public keys, signatures, and generates
* circuit inputs for complete chain-of-trust verification:
* 1. JWT signature (signed by x5c[0])
* 2. x5c[0] certificate signature (signed by x5c[1])
* 3. x5c[1] certificate signature (signed by x5c[2])
*
* Usage: tsx prepare.ts <jwt_file.txt> [output_file.json]
*/
import * as fs from 'fs';
import * as forge from 'node-forge';
import { generateJWTVerifierInputs } from '@zk-email/jwt-tx-builder-helpers/src/input-generators.js';
import type { RSAPublicKey } from '@zk-email/jwt-tx-builder-helpers/src/types.js';
const MAX_CERT_LENGTH = 2048;
const MAX_EAT_NONCE_B64_LENGTH = 99; // Base64url string max length (74 bytes decoded = 99 b64url chars)
const MAX_IMAGE_DIGEST_LENGTH = 71; // "sha256:" + 64 hex chars
interface CertificateInfo {
der: Buffer;
derPadded: Buffer;
paddedLength: number;
publicKey: forge.pki.rsa.PublicKey;
pubkeyOffset: number;
pubkeyLength: number;
signature: Buffer;
cert: forge.pki.Certificate;
}
function parseCertificate(certDer: Buffer): CertificateInfo {
const asn1Cert = forge.asn1.fromDer(forge.util.createBuffer(certDer.toString('binary')));
const cert = forge.pki.certificateFromAsn1(asn1Cert);
const publicKey = cert.publicKey as forge.pki.rsa.PublicKey;
const signature = Buffer.from(cert.signature, 'binary');
const tbsAsn1 = asn1Cert.value[0];
if (typeof tbsAsn1 === 'string') {
throw new Error('Expected ASN.1 object for TBS certificate, got string');
}
const tbsDer = forge.asn1.toDer(tbsAsn1);
const tbsBytes = Buffer.from(tbsDer.getBytes(), 'binary');
const tbsHex = tbsBytes.toString('hex');
const pubkeyDer = Buffer.from(publicKey.n.toByteArray());
const pubkeyHex = pubkeyDer.toString('hex');
const rawOffset = tbsHex.indexOf(pubkeyHex);
if (rawOffset === -1) {
throw new Error('Could not find public key in TBS certificate DER encoding');
}
const pubkeyOffset = (rawOffset / 2) + 1;
const pubkeyLength = pubkeyDer.length > 256 ? pubkeyDer.length - 1 : pubkeyDer.length;
// Validate TBS certificate size before padding
if (tbsBytes.length > MAX_CERT_LENGTH) {
throw new Error(
`TBS certificate size ${tbsBytes.length} exceeds MAX_CERT_LENGTH ${MAX_CERT_LENGTH}`
);
}
const paddedLength = Math.ceil((tbsBytes.length + 9) / 64) * 64;
// Validate padded length doesn't exceed buffer bounds
if (paddedLength > MAX_CERT_LENGTH) {
throw new Error(
`Padded TBS length ${paddedLength} exceeds MAX_CERT_LENGTH ${MAX_CERT_LENGTH}. ` +
`TBS size was ${tbsBytes.length} bytes. Consider increasing MAX_CERT_LENGTH or using a smaller certificate.`
);
}
const tbsPadded = Buffer.alloc(MAX_CERT_LENGTH);
tbsBytes.copy(tbsPadded, 0);
tbsPadded[tbsBytes.length] = 0x80;
const lengthInBits = tbsBytes.length * 8;
tbsPadded.writeBigUInt64BE(BigInt(lengthInBits), paddedLength - 8);
console.log(`[INFO] Certificate: ${cert.subject.attributes[0]?.value || 'Unknown'}`);
console.log(` Full cert DER size: ${certDer.length} bytes`);
console.log(` TBS cert DER size: ${tbsBytes.length} bytes`);
console.log(` TBS padded length: ${paddedLength} bytes`);
console.log(` Public key offset (in TBS): ${pubkeyOffset}`);
console.log(` Public key length: ${pubkeyLength} bytes`);
console.log(` Signature length: ${signature.length} bytes`);
return {
der: tbsBytes,
derPadded: tbsPadded,
paddedLength,
publicKey,
pubkeyOffset,
pubkeyLength,
signature,
cert
};
}
/**
* Convert RSA public key to circuit format
* Circuit uses n=120, k=35 for RSA-4096 support
* RSA-2048 keys are padded with zeros to fill 35 chunks
*/
function pubkeyToChunks(publicKey: forge.pki.rsa.PublicKey): string[] {
const n = publicKey.n;
const chunks: string[] = [];
const chunkSize = 120; // bits (circuit parameter n=120)
const k = 35; // number of chunks (circuit parameter k=35 for RSA-4096)
for (let i = 0; i < k; i++) {
const shift = BigInt(i * chunkSize);
const mask = (BigInt(1) << BigInt(chunkSize)) - BigInt(1);
const chunk = (BigInt(n.toString()) >> shift) & mask;
chunks.push(chunk.toString());
}
return chunks;
}
/**
* Convert signature to circuit format (chunked)
* Circuit uses n=120, k=35 for RSA-4096 support
* RSA-2048 signatures are padded with zeros to fill 35 chunks
*/
function signatureToChunks(signature: Buffer): string[] {
const sigBigInt = BigInt('0x' + signature.toString('hex'));
const chunks: string[] = [];
const chunkSize = 120; // bits (circuit parameter n=120)
const k = 35; // number of chunks (circuit parameter k=35 for RSA-4096)
for (let i = 0; i < k; i++) {
const shift = BigInt(i * chunkSize);
const mask = (BigInt(1) << BigInt(chunkSize)) - BigInt(1);
const chunk = (sigBigInt >> shift) & mask;
chunks.push(chunk.toString());
}
return chunks;
}
/**
* Re-chunk signature from library format (n=121, k=17) to circuit format (n=120, k=35)
* ZK-Email's JWT library uses 121-bit chunks (RSA-2048), but our circuit uses 120-bit chunks (RSA-4096)
*/
function rechunkSignatureToK35(signatureChunks: string[]): string[] {
// Reconstruct BigInt from library's 121-bit chunks (k=17)
let sigBigInt = BigInt(0);
const libraryChunkSize = 121; // JWT library uses 121-bit chunks
for (let i = 0; i < signatureChunks.length; i++) {
const chunk = BigInt(signatureChunks[i]);
sigBigInt += chunk << BigInt(i * libraryChunkSize);
}
// Re-chunk as 120-bit chunks for circuit (k=35)
const chunks: string[] = [];
const circuitChunkSize = 120; // Circuit uses 120-bit chunks
const k = 35; // Circuit expects 35 chunks for RSA-4096
for (let i = 0; i < k; i++) {
const shift = BigInt(i * circuitChunkSize);
const mask = (BigInt(1) << BigInt(circuitChunkSize)) - BigInt(1);
const chunk = (sigBigInt >> shift) & mask;
chunks.push(chunk.toString());
}
return chunks;
}
function bufferToByteArray(buffer: Buffer, maxLength: number): string[] {
const arr = new Array(maxLength).fill('0');
for (let i = 0; i < buffer.length && i < maxLength; i++) {
arr[i] = buffer[i].toString();
}
return arr;
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: tsx prepare.ts <jwt_file.txt> [output_file.json]');
console.error('Example: tsx prepare.ts example_jwt.txt circuit_inputs.json');
process.exit(1);
}
const inputFile = args[0];
const outputFile = args[1] || 'circuit_inputs.json';
try {
// Read raw JWT string (header.payload.signature)
const rawJWT = fs.readFileSync(inputFile, 'utf8').trim();
// Parse header to extract x5c certificate chain
const [headerB64, payloadB64, jwtSigB64] = rawJWT.split('.');
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
console.log('[INFO] Loaded raw JWT from', inputFile);
console.log(`[INFO] Issuer: ${payload.iss}`);
console.log(`[INFO] Subject: ${payload.sub}`);
// Extract x5c certificate chain
if (!header.x5c || !Array.isArray(header.x5c) || header.x5c.length !== 3) {
throw new Error(`[ERROR] Expected 3 certificates in x5c, got ${header.x5c?.length || 0}`);
}
console.log('\n[INFO] Processing certificate chain...\n');
// Parse all 3 certificates
const leafCertDer = Buffer.from(header.x5c[0], 'base64');
const intermediateCertDer = Buffer.from(header.x5c[1], 'base64');
const rootCertDer = Buffer.from(header.x5c[2], 'base64');
const leafCert = parseCertificate(leafCertDer);
console.log();
const intermediateCert = parseCertificate(intermediateCertDer);
console.log();
const rootCert = parseCertificate(rootCertDer);
console.log();
// Generate JWT verifier inputs (for JWT signature verification)
const rsaPublicKey: RSAPublicKey = {
n: Buffer.from(leafCert.publicKey.n.toByteArray()).toString('base64'),
e: leafCert.publicKey.e.intValue()
};
console.log('[INFO] Generating JWT verifier inputs...');
const jwtInputs = await generateJWTVerifierInputs(rawJWT, rsaPublicKey, {
maxMessageLength: 11776
});
console.log('[INFO] JWT signature verified');
// Extract eat_nonce[0] from payload
if (!payload.eat_nonce || !Array.isArray(payload.eat_nonce) || payload.eat_nonce.length === 0) {
throw new Error('[ERROR] No eat_nonce found in JWT payload');
}
const eatNonce0Base64url = payload.eat_nonce[0];
console.log(`\n[INFO] eat_nonce[0] (base64url): ${eatNonce0Base64url}`);
console.log(`[INFO] eat_nonce[0] string length: ${eatNonce0Base64url.length} characters`);
if (eatNonce0Base64url.length > MAX_EAT_NONCE_B64_LENGTH) {
throw new Error(`[ERROR] eat_nonce[0] length ${eatNonce0Base64url.length} exceeds max ${MAX_EAT_NONCE_B64_LENGTH}`);
}
// Decode for verification/logging (not used in circuit)
const eatNonce0Buffer = Buffer.from(eatNonce0Base64url, 'base64url');
console.log(`[INFO] eat_nonce[0] decoded: ${eatNonce0Buffer.length} bytes`);
// Find offset of eat_nonce[0] in the decoded payload JSON
// Decode the payload from base64url to get the exact JSON string
const payloadJSON = Buffer.from(payloadB64, 'base64url').toString('utf8');
// Find key offset: position after the opening quote of "eat_nonce"
const eatNonceKeyPattern = '"eat_nonce"';
const eatNonceKeyStart = payloadJSON.indexOf(eatNonceKeyPattern);
if (eatNonceKeyStart === -1) {
throw new Error('[ERROR] Could not find "eat_nonce" key in payload JSON');
}
const eatNonce0KeyOffset = eatNonceKeyStart + 1; // Position after opening quote
// Find value offset: position of the actual value
const eatNonce0ValueOffset = payloadJSON.indexOf(eatNonce0Base64url);
if (eatNonce0ValueOffset === -1) {
console.error('[ERROR] Could not find eat_nonce[0] value in decoded payload JSON');
console.error('[DEBUG] Payload JSON:', payloadJSON);
console.error('[DEBUG] Looking for:', eatNonce0Base64url);
throw new Error('[ERROR] Could not find eat_nonce[0] value in decoded payload JSON');
}
console.log(`[INFO] eat_nonce key offset in payload: ${eatNonce0KeyOffset}`);
console.log(`[INFO] eat_nonce[0] value offset in payload: ${eatNonce0ValueOffset}`);
// Convert base64url string to character codes (ASCII values) for circuit
const eatNonce0CharCodes = new Array(MAX_EAT_NONCE_B64_LENGTH).fill(0);
for (let i = 0; i < eatNonce0Base64url.length; i++) {
eatNonce0CharCodes[i] = eatNonce0Base64url.charCodeAt(i);
}
// Extract image_digest from payload.submods.container.image_digest
if (!payload.submods?.container?.image_digest) {
throw new Error('[ERROR] No image_digest found in payload.submods.container');
}
const imageDigest = payload.submods.container.image_digest;
console.log(`\n[INFO] image_digest: ${imageDigest}`);
console.log(`[INFO] image_digest string length: ${imageDigest.length} characters`);
if (!imageDigest.startsWith('sha256:')) {
throw new Error(`[ERROR] image_digest must start with "sha256:", got: ${imageDigest}`);
}
if (imageDigest.length !== 71) {
throw new Error(`[ERROR] image_digest must be 71 characters ("sha256:" + 64 hex), got: ${imageDigest.length}`);
}
if (imageDigest.length > MAX_IMAGE_DIGEST_LENGTH) {
throw new Error(`[ERROR] image_digest length ${imageDigest.length} exceeds max ${MAX_IMAGE_DIGEST_LENGTH}`);
}
// Find offset of image_digest in the decoded payload JSON
// Find key offset: position after the opening quote of "image_digest"
const imageDigestKeyPattern = '"image_digest"';
const imageDigestKeyStart = payloadJSON.indexOf(imageDigestKeyPattern);
if (imageDigestKeyStart === -1) {
throw new Error('[ERROR] Could not find "image_digest" key in payload JSON');
}
const imageDigestKeyOffset = imageDigestKeyStart + 1; // Position after opening quote
// Find value offset: position of the actual value
const imageDigestValueOffset = payloadJSON.indexOf(imageDigest);
if (imageDigestValueOffset === -1) {
console.error('[ERROR] Could not find image_digest value in decoded payload JSON');
console.error('[DEBUG] Payload JSON:', payloadJSON);
console.error('[DEBUG] Looking for:', imageDigest);
throw new Error('[ERROR] Could not find image_digest value in decoded payload JSON');
}
console.log(`[INFO] image_digest key offset in payload: ${imageDigestKeyOffset}`);
console.log(`[INFO] image_digest value offset in payload: ${imageDigestValueOffset}`);
// Convert image_digest string to character codes (ASCII values) for circuit
const imageDigestCharCodes = new Array(MAX_IMAGE_DIGEST_LENGTH).fill(0);
for (let i = 0; i < imageDigest.length; i++) {
imageDigestCharCodes[i] = imageDigest.charCodeAt(i);
}
// Build circuit inputs
const circuitInputs = {
// JWT inputs
message: jwtInputs.message,
messageLength: jwtInputs.messageLength,
periodIndex: jwtInputs.periodIndex,
// x5c[0] - Leaf certificate
leaf_cert: bufferToByteArray(leafCert.derPadded, MAX_CERT_LENGTH),
leaf_cert_padded_length: leafCert.paddedLength.toString(),
leaf_pubkey_offset: leafCert.pubkeyOffset.toString(),
leaf_pubkey_actual_size: leafCert.pubkeyLength.toString(),
// x5c[1] - Intermediate certificate
intermediate_cert: bufferToByteArray(intermediateCert.derPadded, MAX_CERT_LENGTH),
intermediate_cert_padded_length: intermediateCert.paddedLength.toString(),
intermediate_pubkey_offset: intermediateCert.pubkeyOffset.toString(),
intermediate_pubkey_actual_size: intermediateCert.pubkeyLength.toString(),
// x5c[2] - Root certificate
root_cert: bufferToByteArray(rootCert.derPadded, MAX_CERT_LENGTH),
root_cert_padded_length: rootCert.paddedLength.toString(),
root_pubkey_offset: rootCert.pubkeyOffset.toString(),
root_pubkey_actual_size: rootCert.pubkeyLength.toString(),
// Public keys (chunked for RSA circuit)
leaf_pubkey: pubkeyToChunks(leafCert.publicKey),
intermediate_pubkey: pubkeyToChunks(intermediateCert.publicKey),
root_pubkey: pubkeyToChunks(rootCert.publicKey),
// Signatures (chunked for RSA circuit)
// JWT signature comes from library as n=121,k=17, re-chunk to n=120,k=35 for circuit
jwt_signature: rechunkSignatureToK35(jwtInputs.signature),
leaf_signature: signatureToChunks(leafCert.signature),
intermediate_signature: signatureToChunks(intermediateCert.signature),
// EAT nonce[0] (circuit will extract value directly from payload)
eat_nonce_0_b64_length: eatNonce0Base64url.length.toString(),
eat_nonce_0_key_offset: eatNonce0KeyOffset.toString(),
eat_nonce_0_value_offset: eatNonce0ValueOffset.toString(),
// Container image digest (circuit will extract value directly from payload)
image_digest_length: imageDigest.length.toString(),
image_digest_key_offset: imageDigestKeyOffset.toString(),
image_digest_value_offset: imageDigestValueOffset.toString(),
};
fs.writeFileSync(outputFile, JSON.stringify(circuitInputs, null, 2));
console.log('\n[INFO] Circuit inputs saved to', outputFile);
} catch (error) {
console.error('[ERROR]', error instanceof Error ? error.message : error);
if (error instanceof Error && error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,97 @@
pragma circom 2.1.9;
include "../passport/checkPubkeyPosition.circom";
include "../passport/checkPubkeysEqual.circom";
include "../passport/signatureAlgorithm.circom";
include "circomlib/circuits/comparators.circom";
include "circomlib/circuits/bitify.circom";
include "@openpassport/zk-email-circuits/utils/array.circom";
/// @title ExtractAndValidatePubkey
/// @notice Extract the public key from certificate and validate position and equality
/// @dev Pubkey extraction and validation logic for X.509 certificate chain verification:
/// - Calculates the region containing prefix + pubkey + suffix
/// - Validates indices are within certificate bounds
/// - Extracts the pubkey region using SelectSubArray
/// - Validates the ASN.1 structure matches expected format
/// - Extracts the raw pubkey bytes (without prefix/suffix)
/// - Verifies extracted pubkey matches the provided input pubkey
template ExtractAndValidatePubkey(
signatureAlgorithm, // Algorithm ID (e.g., 1 for RSA-SHA256)
n, // RSA chunk size (e.g., 120)
k, // Number of chunks (e.g., 35)
MAX_CERT_LENGTH, // Maximum certificate size in bytes
MAX_PUBKEY_PREFIX, // ASN.1 prefix length (typically 33)
MAX_PUBKEY_LENGTH // Maximum pubkey length in bytes
) {
var kLengthFactor = getKLengthFactor(signatureAlgorithm);
var kScaled = k * kLengthFactor;
var suffixLength = kLengthFactor == 1 ? getSuffixLength(signatureAlgorithm) : 0;
signal input cert[MAX_CERT_LENGTH];
signal input pubkey_offset;
signal input pubkey_actual_size;
signal input input_pubkey[kScaled];
// Validate pubkey_actual_size is within bounds (prevent OOB attacks)
component size_max_check = LessEqThan(log2Ceil(MAX_PUBKEY_LENGTH));
size_max_check.in[0] <== pubkey_actual_size;
size_max_check.in[1] <== MAX_PUBKEY_LENGTH;
size_max_check.out === 1;
// Validate pubkey_offset is within bounds (prevent underflow in prefix calculation)
component offset_min_check = GreaterEqThan(log2Ceil(MAX_CERT_LENGTH));
offset_min_check.in[0] <== pubkey_offset;
offset_min_check.in[1] <== MAX_PUBKEY_PREFIX;
offset_min_check.out === 1;
// Calculate prefix start index and net length
signal pubkey_prefix_start_index <== pubkey_offset - MAX_PUBKEY_PREFIX;
signal pubkey_net_length <== MAX_PUBKEY_PREFIX + pubkey_actual_size + suffixLength;
// Validate indices are in range
component prefix_idx_valid = Num2Bits(log2Ceil(MAX_CERT_LENGTH));
prefix_idx_valid.in <== pubkey_prefix_start_index;
component net_len_valid = Num2Bits(log2Ceil(MAX_CERT_LENGTH));
net_len_valid.in <== pubkey_net_length;
component prefix_in_range = LessEqThan(log2Ceil(MAX_CERT_LENGTH));
prefix_in_range.in[0] <== pubkey_prefix_start_index + pubkey_net_length;
prefix_in_range.in[1] <== MAX_CERT_LENGTH;
prefix_in_range.out === 1;
// Extract pubkey region with prefix and suffix
signal pubkey_region[MAX_PUBKEY_PREFIX + MAX_PUBKEY_LENGTH + suffixLength] <== SelectSubArray(
MAX_CERT_LENGTH,
MAX_PUBKEY_PREFIX + MAX_PUBKEY_LENGTH + suffixLength
)(
cert,
pubkey_prefix_start_index,
pubkey_net_length
);
// Validate pubkey position (checks ASN.1 prefix and suffix)
CheckPubkeyPosition(
MAX_PUBKEY_PREFIX,
MAX_PUBKEY_LENGTH,
suffixLength,
signatureAlgorithm
)(
pubkey_region,
pubkey_actual_size
);
// Extract pubkey without prefix
signal extracted_pubkey[MAX_PUBKEY_LENGTH];
for (var i = 0; i < MAX_PUBKEY_LENGTH; i++) {
extracted_pubkey[i] <== pubkey_region[MAX_PUBKEY_PREFIX + i];
}
// Verify extracted pubkey matches input pubkey
CheckPubkeysEqual(n, kScaled, kLengthFactor, MAX_PUBKEY_LENGTH)(
input_pubkey,
extracted_pubkey,
pubkey_actual_size
);
}

View File

@@ -0,0 +1,40 @@
pragma circom 2.1.9;
include "../passport/signatureVerifier.circom";
include "../passport/signatureAlgorithm.circom";
include "../crypto/hasher/shaBytes/shaBytesDynamic.circom";
/// @title VerifyCertificateSignature
/// @notice Hash the certificate and verify its signature
/// @dev Certificate hashing and signature verification for X.509 chain validation:
/// - Hashes the DER-encoded certificate using SHA (dynamic length)
/// - Verifies the signature using the signer's public key
/// - Supports multiple signature algorithms
template VerifyCertificateSignature(
signatureAlgorithm, // Algorithm ID (e.g., 1 for RSA-SHA256)
n, // RSA chunk size (e.g., 120)
k, // Number of chunks (e.g., 35)
MAX_CERT_LENGTH // Maximum certificate size in bytes
) {
var kLengthFactor = getKLengthFactor(signatureAlgorithm);
var kScaled = k * kLengthFactor;
var hashLength = getHashLength(signatureAlgorithm);
signal input cert[MAX_CERT_LENGTH];
signal input cert_padded_length;
signal input signer_pubkey[kScaled];
signal input signature[kScaled];
// Hash certificate using dynamic-length SHA
signal hashed_cert[hashLength] <== ShaBytesDynamic(hashLength, MAX_CERT_LENGTH)(
cert,
cert_padded_length
);
// Verify signature using signer's public key
SignatureVerifier(signatureAlgorithm, n, k)(
hashed_cert,
signer_pubkey,
signature
);
}

View File

@@ -0,0 +1,123 @@
pragma circom 2.1.9;
include "circomlib/circuits/comparators.circom";
include "@openpassport/zk-email-circuits/utils/array.circom";
/// @title ExtractAndVerifyJSONField
/// @notice Verifies JSON key name and extracts the related value
/// @dev Validates the JSON key name and position, then extracts and outputs the value directly.
/// @param maxJSONLength Maximum length of the JSON string
/// @param maxKeyNameLength Maximum length of the JSON key name (without quotes)
/// @param maxValueLength Maximum length of the extracted value
/// @input json The JSON string to extract from
/// @input key_offset Offset where the JSON key name starts (position after opening quote)
/// @input key_length Actual length of the key name
/// @input value_offset Offset where the value starts (raw value, without quotes if string)
/// @input value_length Actual length of the value
/// @input expected_key_name Expected key name as array of ASCII codes (without quotes)
/// @output extracted_value The value extracted from the JSON at the specified offset
template ExtractAndVerifyJSONField(
maxJSONLength,
maxKeyNameLength,
maxValueLength
) {
signal input json[maxJSONLength];
signal input key_offset;
signal input key_length;
signal input value_offset;
signal input value_length;
signal input expected_key_name[maxKeyNameLength];
signal output extracted_value[maxValueLength];
// Ensure key_offset is at least 1 (prevents underflow in key_offset - 1)
component key_offset_min = GreaterEqThan(log2Ceil(maxJSONLength));
key_offset_min.in[0] <== key_offset;
key_offset_min.in[1] <== 1;
key_offset_min.out === 1;
// Verify opening quote before key
signal key_quote_before <== ItemAtIndex(maxJSONLength)(json, key_offset - 1);
key_quote_before === 34; // ASCII code for "
// Extract key name from JSON
signal extracted_key_name[maxKeyNameLength] <== SelectSubArray(
maxJSONLength,
maxKeyNameLength
)(json, key_offset, key_length);
// Verify key name matches expected (with padding validation)
component key_char_match[maxKeyNameLength];
for (var i = 0; i < maxKeyNameLength; i++) {
key_char_match[i] = GreaterThan(log2Ceil(maxKeyNameLength));
key_char_match[i].in[0] <== key_length;
key_char_match[i].in[1] <== i;
// If within length: extracted must equal expected
// If beyond length: expected must be 0 (padding)
key_char_match[i].out * (extracted_key_name[i] - expected_key_name[i]) === 0;
(1 - key_char_match[i].out) * expected_key_name[i] === 0;
}
// Verify closing quote after key
signal key_quote_after <== ItemAtIndex(maxJSONLength)(json, key_offset + key_length);
key_quote_after === 34; // ASCII code for "
// Verify colon after closing quote (ensures valid JSON key:value structure)
signal colon_after_key <== ItemAtIndex(maxJSONLength)(json, key_offset + key_length + 1);
colon_after_key === 58; // ASCII code for ':'
// Validate JSON array structure: "key":["value"] or "key": ["value"]
signal colon_position <== key_offset + key_length + 1;
// Check character at colon+1: must be '[' (91) or space (32)
signal char_after_colon <== ItemAtIndex(maxJSONLength)(json, colon_position + 1);
// is_bracket: 1 if char is '[', 0 otherwise
component is_bracket = IsEqual();
is_bracket.in[0] <== char_after_colon;
is_bracket.in[1] <== 91; // '['
// is_space: 1 if char is space, 0 otherwise
component is_space = IsEqual();
is_space.in[0] <== char_after_colon;
is_space.in[1] <== 32; // ' '
// Exactly one must be true: char is either '[' or space
is_bracket.out + is_space.out === 1;
// If bracket at colon+1: check quote at colon+2, value at colon+3
// If space at colon+1: check bracket at colon+2, quote at colon+3, value at colon+4
// When is_bracket=1 (no space): expect quote at colon+2
signal char_at_plus2 <== ItemAtIndex(maxJSONLength)(json, colon_position + 2);
// When is_space=1: expect bracket at colon+2
// Constraint: if is_bracket=1, char_at_plus2 must be quote(34)
// if is_space=1, char_at_plus2 must be bracket(91)
is_bracket.out * (char_at_plus2 - 34) === 0; // If bracket at +1, quote at +2
is_space.out * (char_at_plus2 - 91) === 0; // If space at +1, bracket at +2
// When is_space=1: check quote at colon+3
signal char_at_plus3 <== ItemAtIndex(maxJSONLength)(json, colon_position + 3);
is_space.out * (char_at_plus3 - 34) === 0; // If space at +1, quote at +3
// Enforce value_offset based on pattern
// Pattern 1 (no space): :[" -> value at colon+3
// Pattern 2 (space): : [" -> value at colon+4
signal expected_value_offset <== colon_position + 3 + is_space.out;
value_offset === expected_value_offset;
// Extract value from JSON and output directly
extracted_value <== SelectSubArray(
maxJSONLength,
maxValueLength
)(json, value_offset, value_length);
// Validate value ends with closing quote and bracket: "value"]
signal closing_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length);
closing_quote === 34; // ASCII code for "
signal closing_bracket <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1);
closing_bracket === 93; // ASCII code for ]
}

View File

@@ -8,6 +8,7 @@
"build-all": "bash scripts/build/build_register_circuits.sh && bash scripts/build/build_register_circuits_id.sh && bash scripts/build/build_register_aadhaar.sh && bash scripts/build/build_dsc_circuits.sh && bash scripts/build/build_disclose_circuits.sh",
"build-disclose": "bash scripts/build/build_disclose_circuits.sh",
"build-dsc": "bash scripts/build/build_dsc_circuits.sh",
"build-gcp-jwt-verifier": "bash scripts/build/build_gcp_jwt_verifier.sh",
"build-register": "bash scripts/build/build_register_circuits.sh",
"build-register-id": "bash scripts/build/build_register_circuits_id.sh",
"build:deps": "yarn workspaces foreach --from @selfxyz/circuits --topological-dev --recursive run build",
@@ -45,6 +46,8 @@
"@selfxyz/common": "workspace:^",
"@zk-email/circuits": "^6.3.2",
"@zk-email/helpers": "^6.1.1",
"@zk-email/jwt-tx-builder-circuits": "0.1.0",
"@zk-email/jwt-tx-builder-helpers": "0.1.0",
"@zk-email/zk-regex-circom": "^1.2.1",
"@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree",
"@zk-kit/circuits": "^1.0.0-beta",

View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -euo pipefail
# Get script directory for stable sourcing
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
source "${SCRIPT_DIR}/common.sh"
# Set environment (change this value as needed)
# ENV="prod"
ENV="staging"
echo -e "${GREEN}Building GCP JWT verifier circuit for $ENV environment${NC}"
# Circuit-specific configurations
CIRCUIT_TYPE="gcp_jwt_verifier"
CIRCUIT_NAME="gcp_jwt_verifier"
OUTPUT_DIR="build/gcp"
POWEROFTAU=24
MAX_MEMORY=204800
# Download power of tau if needed
download_ptau $POWEROFTAU
# Build the circuit
build_circuit "$CIRCUIT_NAME" "$CIRCUIT_TYPE" "$POWEROFTAU" "$OUTPUT_DIR" "$MAX_MEMORY"
echo ""
echo -e "${BLUE}To generate circuit inputs, run:${NC}"
echo -e "${YELLOW} yarn tsx circuits/gcp_jwt_verifier/prepare.ts example_jwt.txt circuit_inputs.json${NC}"

View File

@@ -75,7 +75,6 @@ build_circuit() {
-l node_modules \
-l node_modules/@zk-kit/binary-merkle-root.circom/src \
-l node_modules/circomlib/circuits \
-l node_modules \
--r1cs --O1 --wasm -c \
--output ${OUTPUT_DIR}/${CIRCUIT_NAME}/

636
yarn.lock

File diff suppressed because it is too large Load Diff