mirror of
https://github.com/selfxyz/self.git
synced 2026-01-07 22:04:03 -05:00
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:
96
circuits/circuits/gcp_jwt_verifier/README.md
Normal file
96
circuits/circuits/gcp_jwt_verifier/README.md
Normal 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)
|
||||
1
circuits/circuits/gcp_jwt_verifier/example_jwt.txt
Normal file
1
circuits/circuits/gcp_jwt_verifier/example_jwt.txt
Normal file
File diff suppressed because one or more lines are too long
252
circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom
Normal file
252
circuits/circuits/gcp_jwt_verifier/gcp_jwt_verifier.circom
Normal 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);
|
||||
203
circuits/circuits/gcp_jwt_verifier/jwt_verifier.circom
Normal file
203
circuits/circuits/gcp_jwt_verifier/jwt_verifier.circom
Normal 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);
|
||||
}
|
||||
394
circuits/circuits/gcp_jwt_verifier/prepare.ts
Normal file
394
circuits/circuits/gcp_jwt_verifier/prepare.ts
Normal 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();
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
123
circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom
Normal file
123
circuits/circuits/utils/gcp_jwt/verifyJSONFieldExtraction.circom
Normal 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 ]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
29
circuits/scripts/build/build_gcp_jwt_verifier.sh
Executable file
29
circuits/scripts/build/build_gcp_jwt_verifier.sh
Executable 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}"
|
||||
@@ -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}/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user