Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6de6065
feat: allow binding uniqueness proofs to an existing session
kilianglas Jul 10, 2026
82bb6ae
chore: minor cosmetic changes
kilianglas Jul 10, 2026
cee2156
docs: bound proofs verifiable via verifyProofAndSignals, verify() rej…
kilianglas Jul 10, 2026
5440e41
feat: add verifyWithSession verifier entry point for session-bound pr…
kilianglas Jul 10, 2026
e07919a
feat: generate and verify bound-uniqueness solidity fixture
kilianglas Jul 10, 2026
91ca3cd
test: add bound uniqueness fixtures and verifyWithSession tests
kilianglas Jul 13, 2026
de729fd
docs: document verifyWithSession entry point
kilianglas Jul 13, 2026
2382247
refactor: move verifyWithSession to new IWorldIDVerifierV2 interface
kilianglas Jul 13, 2026
24f9cb0
Merge remote-tracking branch 'origin/main' into kilianglas/uniqueness…
kilianglas Jul 13, 2026
6c9a289
Merge branch 'kilianglas/uniqueness-session-binding' into kilianglas/…
kilianglas Jul 13, 2026
50777bc
chore: cleanup
kilianglas Jul 13, 2026
7895737
Merge branch 'kilianglas/uniqueness-session-binding-contracts' of git…
kilianglas Jul 13, 2026
8d475b1
chore: drop unrelated README reformatting
kilianglas Jul 13, 2026
74d15fe
docs: reword session binding verifier requirement
kilianglas Jul 13, 2026
b594803
refactor!: model session creation through SessionRef
kilianglas Jul 14, 2026
a54cb50
feat(node): authorize signed session-seed queries
kilianglas Jul 14, 2026
f4b147b
feat: create sessions from uniqueness proofs
kilianglas Jul 14, 2026
28dafed
test: cover atomic uniqueness session creation
kilianglas Jul 14, 2026
f651855
docs: describe uniqueness session creation
kilianglas Jul 14, 2026
b7c1977
chore: remove unnecessary tests
kilianglas Jul 14, 2026
816e9cf
feat: rp_signature_verification field
kilianglas Jul 15, 2026
8986872
refactor: single RpSignatureVerifcaton erro
kilianglas Jul 15, 2026
6db473a
chore: update comment
kilianglas Jul 15, 2026
3eb823c
chore: remove unnecessary tests
kilianglas Jul 15, 2026
c26e6ad
chore: remove new metrics
kilianglas Jul 15, 2026
de5d29b
chore: add todo
kilianglas Jul 15, 2026
1b4e673
chore: remove unnecessary tests
kilianglas Jul 15, 2026
0ea91d5
feat: improved request validation
kilianglas Jul 15, 2026
06bf943
feat: allow bound uniqueness proofs to re-derive session r seed
kilianglas Jul 15, 2026
25f454c
docs: polish readme
kilianglas Jul 16, 2026
828b5ec
refactor: remove unnecessary unreachable!
kilianglas Jul 21, 2026
2d7d228
fix: remove unused imports in generate-solidity-fixtures
kilianglas Jul 21, 2026
c379ea9
feat: remove uniqueness bindign to existing session id
kilianglas Aug 3, 2026
c2eb105
fix(contracts): reject zero sessionId in verifySession
kilianglas Aug 3, 2026
70af89a
Merge branch 'kilianglas/uniqueness-session-binding-contracts' into k…
kilianglas Aug 3, 2026
ae0f2dd
refactor!: move session binding to WorldIDVerifierV3
kilianglas Aug 4, 2026
db9023a
Merge branch 'kilianglas/uniqueness-session-binding-contracts' into k…
kilianglas Aug 4, 2026
788a003
refactor: restore WorldIDVerifierV2Test to its original state
kilianglas Aug 4, 2026
5c3a870
Merge branch 'kilianglas/uniqueness-session-binding-contracts' into k…
kilianglas Aug 4, 2026
fca6f36
refactor: scope this PR to verifyWithSession only
kilianglas Aug 4, 2026
43a9c85
Merge branch 'kilianglas/uniqueness-session-binding-contracts' into k…
kilianglas Aug 4, 2026
f36bcea
Merge remote-tracking branch 'origin/main' into kilianglas/collapse-c…
kilianglas Aug 6, 2026
477b758
fix: drop the stale bind-to-existing fixture section
kilianglas Aug 6, 2026
1f93b70
feat: restore the one-byte ProofType encoding
kilianglas Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/authenticator/src/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ pub struct CredentialInput {
/// those are SDK concerns.
#[derive(Debug)]
pub struct ProofResult {
/// The session_id_r_seed (`r`), if a session proof was generated.
/// The session_id_r_seed (`r`), when a session was created or proven.
///
/// The SDK should cache this keyed by [`SessionId::oprf_seed`].
/// Returned for session proofs and for uniqueness proofs that create a bound
/// session. The SDK should cache this keyed by [`SessionId::oprf_seed`].
pub session_id_r_seed: Option<FieldElement>,

/// The response to deliver to an RP.
Expand Down
55 changes: 22 additions & 33 deletions crates/authenticator/src/prove.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use secrecy::ExposeSecret;
use world_id_primitives::{
Credential, FieldElement, ProofRequest, ProofResponse, ProofType, RequestItem, ResponseItem,
SessionId, SessionNullifier, ZeroKnowledgeProof,
SessionId, SessionNullifier, SessionRef, ZeroKnowledgeProof,
};
use world_id_proof::{
AuthenticatorProofInput, FullOprfOutput, OprfEntrypoint, ProofCompression,
Expand Down Expand Up @@ -208,20 +208,20 @@ impl Authenticator {
account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
) -> Result<(SessionId, FieldElement), AuthenticatorError> {
proof_request.validate_proof_type()?;
if !proof_request.is_session_proof() {
return Err(AuthenticatorError::PrimitiveError(
world_id_primitives::PrimitiveError::InvalidInput {
attribute: "proof_type".to_string(),
reason: "must be create_session or session".to_string(),
},
));
}

let mut rng = rand::rngs::OsRng;

let oprf_seed = match proof_request.session_id {
Some(session_id) => session_id.oprf_seed,
None => SessionId::generate_oprf_seed(&mut rng),
SessionRef::Existing(session_id) => session_id.oprf_seed,
SessionRef::Create => SessionId::generate_oprf_seed(&mut rng),
SessionRef::None => {
return Err(AuthenticatorError::PrimitiveError(
world_id_primitives::PrimitiveError::InvalidInput {
attribute: "session_id".to_string(),
reason: "session_id must be \"create\" or an existing session id"
.to_string(),
},
));
}
};

let resolved_session_id_r_seed = match session_id_r_seed {
Expand All @@ -244,7 +244,7 @@ impl Authenticator {
let session_id =
SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?;

if let Some(request_session_id) = proof_request.session_id {
if let SessionRef::Existing(request_session_id) = proof_request.session_id {
self.validate_cached_session_r_seed(resolved_session_id_r_seed, request_session_id)?;
}

Expand Down Expand Up @@ -274,9 +274,9 @@ impl Authenticator {
/// - `credentials` — one [`CredentialInput`] per credential to prove,
/// matched to request items by `issuer_schema_id`.
/// - `account_inclusion_proof` — a cached inclusion proof if available (a fresh one will be fetched otherwise)
/// - `session_id_r_seed` — a cached session `r` seed. For Session Proofs it is re-computed
/// if unavailable; for session-bound Uniqueness Proofs ([`ProofRequest::binds_session`])
/// it is required and the call fails with [`AuthenticatorError::SessionSeedRequired`] otherwise.
/// - `session_id_r_seed` — a cached session `r` seed. For requests using an existing
/// session it is re-derived if unavailable. Create flows mint a fresh session and return
/// the new `session_id_r_seed` for caching.
///
/// # Caller Responsibilities
/// 1. The caller must ensure the request can be fulfilled with the credentials which the user has available,
Expand Down Expand Up @@ -307,26 +307,15 @@ impl Authenticator {
.ok_or(AuthenticatorError::UnfullfilableRequest)?;

// 2. Resolve session seed
let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type {
ProofType::Uniqueness => match proof_request.session_id {
// Bind the proof to the existing session. Requires the cached `r`.
Some(session_id) => {
let seed = session_id_r_seed.ok_or(AuthenticatorError::SessionSeedRequired)?;
self.validate_cached_session_r_seed(seed, session_id)?;
(Some(session_id), Some(seed))
}
None => (None, None),
},
ProofType::CreateSession => {
let (resolved_session_id, resolved_session_r_seed) = match proof_request.session_id {
SessionRef::None => (None, None),
SessionRef::Create => {
let (session_id, seed) = self
.build_session_id(proof_request, None, account_inclusion_proof)
.await?;
(Some(session_id), Some(seed))
}
ProofType::Session => {
let session_id = proof_request
.session_id
.expect("session proof must have session_id");
SessionRef::Existing(session_id) => {
if let Some(seed) = session_id_r_seed {
self.validate_cached_session_r_seed(seed, session_id)?;
(Some(session_id), Some(seed))
Expand Down Expand Up @@ -362,7 +351,7 @@ impl Authenticator {
request_item,
&cred_input.credential,
cred_input.blinding_factor,
resolved_session_seed,
resolved_session_r_seed,
resolved_session_id,
proof_request.proof_type,
proof_request.created_at,
Expand All @@ -382,7 +371,7 @@ impl Authenticator {
// 5. Validate and return response
proof_request.validate_response(&proof_response)?;
Ok(ProofResult {
session_id_r_seed: resolved_session_seed,
session_id_r_seed: resolved_session_r_seed,
proof_response,
})
}
Expand Down
130 changes: 57 additions & 73 deletions crates/core/tests/generate_proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::{

use alloy::{
primitives::{U160, U256},
signers::local::LocalSigner,
signers::{SignerSync as _, local::LocalSigner},
};
use eyre::{Context as _, Result, eyre};
use taceo_oprf::{
Expand All @@ -31,7 +31,8 @@ use world_id_gateway::{
spawn_gateway_for_tests,
};
use world_id_primitives::{
Config, FieldElement, ServiceEndpoint, SessionId, TREE_DEPTH, merkle::AccountInclusionProof,
Config, FieldElement, ServiceEndpoint, SessionId, SessionRef, TREE_DEPTH,
merkle::AccountInclusionProof,
};
use world_id_test_utils::{
anvil::WorldIDVerifierV3,
Expand Down Expand Up @@ -305,7 +306,7 @@ async fn e2e_authenticator_generate_proof() -> Result<()> {
expires_at: rp_fixture.expiration_timestamp,
rp_id: rp_fixture.world_rp_id,
oprf_key_id: rp_fixture.oprf_key_id,
session_id: None,
session_id: SessionRef::None,
action: Some(rp_fixture.action.into()),
signature: rp_fixture.signature,
nonce: rp_fixture.nonce.into(),
Expand All @@ -322,8 +323,6 @@ async fn e2e_authenticator_generate_proof() -> Result<()> {
.generate_nullifier(&proof_request, None)
.await?;
assert_ne!(nullifier.oprf_output(), FieldElement::ZERO);
// reused below for the session-bound proof; `generate_proof` does not contact the nodes
let nullifier_for_binding = nullifier.clone();

let credentials = [CredentialInput {
credential: credential.clone(),
Expand Down Expand Up @@ -366,111 +365,96 @@ async fn e2e_authenticator_generate_proof() -> Result<()> {
.await?;
info!("on-chain proof verification succeeded");

// ── SESSION-BOUND UNIQUENESS PROOF ──
// Note: We mock a cached r here. This would be initially obtained from an OPRF query.
let session_id_r_seed = FieldElement::random(&mut rng);
let session_id = SessionId::from_r_seed(
leaf_index,
session_id_r_seed,
SessionId::generate_oprf_seed(&mut rng),
)?;
let bound_request = ProofRequest {
session_id: Some(session_id),
// ── UNIQUENESS + CREATE (atomic session mint and bound uniqueness proof) ──
let mut rng = rand::thread_rng();
let create_nonce = FieldElement::random(&mut rng);
let create_msg = world_id_primitives::rp::compute_rp_signature_msg(
*create_nonce,
rp_fixture.current_timestamp,
rp_fixture.expiration_timestamp,
Some(rp_fixture.action),
);
let create_signature = LocalSigner::from_signing_key(rp_fixture.signing_key.clone())
.sign_message_sync(&create_msg)?;
let create_request = ProofRequest {
id: "test_uniqueness_create".to_string(),
session_id: SessionRef::Create,
action: Some(rp_fixture.action.into()),
nonce: create_nonce,
signature: create_signature,
..proof_request.clone()
};

// binding requires the cached seed
let err = authenticator
.generate_proof(
&bound_request,
nullifier_for_binding.clone(),
&credentials,
None,
None,
)
.await
.unwrap_err();
assert!(matches!(err, AuthenticatorError::SessionSeedRequired));

// a seed that does not match the session's commitment is rejected
let err = authenticator
.generate_proof(
&bound_request,
nullifier_for_binding.clone(),
&credentials,
None,
Some(FieldElement::random(&mut rng)),
)
.await
.unwrap_err();
assert!(matches!(err, AuthenticatorError::SessionIdMismatch));

let bound_result = authenticator
.generate_proof(
&bound_request,
nullifier_for_binding,
&credentials,
None,
Some(session_id_r_seed),
)
let create_nullifier = authenticator
.generate_nullifier(&create_request, None)
.await?;
info!("generated session-bound uniqueness proof");
let create_result = authenticator
.generate_proof(&create_request, create_nullifier, &credentials, None, None)
.await?;
let created_session_id = create_result
.proof_response
.session_id
.expect("uniqueness create must mint a session id");
let created_session_seed = create_result
.session_id_r_seed
.expect("uniqueness create must return session seed");
let create_item = &create_result.proof_response.responses[0];
assert!(create_item.nullifier.is_some());
assert!(create_item.session_nullifier.is_none());
assert_eq!(
SessionId::from_r_seed(
leaf_index,
created_session_seed,
created_session_id.oprf_seed
)?,
created_session_id
);

assert_eq!(bound_result.proof_response.session_id, Some(session_id));
let bound_item = &bound_result.proof_response.responses[0];
assert!(bound_item.session_nullifier.is_none());
let bound_nullifier = bound_item
let create_nullifier = create_item
.nullifier
.expect("bound proof is a uniqueness proof");
// same RP/action => same deterministic nullifier as the unbound proof
assert_eq!(bound_nullifier, response_item.nullifier.unwrap());

// `verify()` pins the sessionId signal to 0, so it must reject the bound proof
.expect("create uniqueness proof should have nullifier");
let unbound_verify = world_id_verifier
.verify(
bound_nullifier.into(),
create_nullifier.into(),
rp_fixture.action.into(),
rp_fixture.world_rp_id.into_inner(),
rp_fixture.nonce.into(),
create_nonce.into(),
request_item.signal_hash().into(),
bound_item.expires_at_min,
create_item.expires_at_min,
issuer_schema_id,
request_item
.genesis_issued_at_min
.unwrap_or_default()
.try_into()
.expect("u64 fits into U256"),
bound_item.proof.as_ethereum_representation(),
create_item.proof.as_ethereum_representation(),
)
.call()
.await;
assert!(
unbound_verify.is_err(),
"bound proof must not verify with sessionId = 0"
"create-bound proof must not verify with sessionId = 0"
);
info!("session-bound proof correctly rejected by the sessionId=0 entry point");

// `verifyWithSession` checks the sessionId signal against the session's commitment
world_id_verifier
.verifyWithSession(
bound_nullifier.into(),
create_nullifier.into(),
rp_fixture.action.into(),
rp_fixture.world_rp_id.into_inner(),
rp_fixture.nonce.into(),
create_nonce.into(),
request_item.signal_hash().into(),
bound_item.expires_at_min,
create_item.expires_at_min,
issuer_schema_id,
request_item
.genesis_issued_at_min
.unwrap_or_default()
.try_into()
.expect("u64 fits into U256"),
session_id.commitment.into(),
bound_item.proof.as_ethereum_representation(),
created_session_id.commitment.into(),
create_item.proof.as_ethereum_representation(),
)
.call()
.await?;
info!("session-bound proof verified via verifyWithSession");
info!("uniqueness create proof verified via verifyWithSession");

indexer_handle.abort();
info!("e2e_authenticator_generate_proof finished successfully");
Expand Down
2 changes: 1 addition & 1 deletion crates/primitives/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub use nullifier::Nullifier;

/// Contains types relevant for Session Proofs.
mod session;
pub use session::{SessionFeType, SessionFieldElement, SessionId, SessionNullifier};
pub use session::{SessionFeType, SessionFieldElement, SessionId, SessionNullifier, SessionRef};

/// Contains the quintessential zero-knowledge proof type.
pub mod proof;
Expand Down
Loading
Loading