Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
117 changes: 116 additions & 1 deletion crates/primitives/src/oprf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use circom_types::groth16::Proof;
use serde::{Deserialize, Serialize};
use taceo_oprf::types::api::{CloseFrameMessage, OprfRequestAuthenticatorError};

use crate::rp::RpId;
use crate::{FieldElement, rp::RpId};

#[expect(unused_imports, reason = "used in doc comments")]
use crate::SessionFeType;
Expand Down Expand Up @@ -69,6 +69,14 @@ pub struct NullifierOprfRequestAuthV1 {
with = "serde_utils::hex_bytes_opt"
)]
pub wip101_data: Option<Vec<u8>>,
/// The RP-signed uniqueness action (MSB `0x00`) for create-and-bind session-seed queries.
///
/// Only valid on session-seed queries (see [`SessionFeType::OprfSeed`]) from EOA-backed RPs.
/// When present, the OPRF node verifies the RP signature over the action-inclusive message
/// (see `compute_rp_signature_msg`) instead of the action-less one, so a single RP signature
/// can authorize creating a session and binding a Uniqueness Proof to it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_action: Option<FieldElement>,
}

/// A request sent by a client for OPRF credential blinding factor authentication.
Expand Down Expand Up @@ -171,6 +179,14 @@ pub enum WorldIdRequestAuthError {
/// prefixes.
#[error("invalid_action_for_session")]
InvalidActionSession,
/// The provided signed action is not a valid nullifier action. Signed actions must
/// start with `0x00` (MSB).
#[error("invalid_signed_action")]
InvalidSignedAction,
/// A signed action was provided on a request that does not support one. Signed
/// actions are only allowed on session-seed queries from EOA-backed RPs.
#[error("signed_action_not_allowed")]
SignedActionNotAllowed,
/// The RP signer is a contract but does not implement the WIP101 interface.
#[error("wip101_incompatible_rp_signer")]
Wip101IncompatibleRpSigner,
Expand Down Expand Up @@ -235,6 +251,7 @@ impl WorldIdRequestAuthError {
| Self::InvalidRpSignature
| Self::DuplicateNonce
| Self::InvalidActionNullifier
| Self::InvalidSignedAction
| Self::Wip101IncompatibleRpSigner
| Self::Wip101VerificationFailed(_)
| Self::Wip101CustomRevert
Expand All @@ -247,6 +264,7 @@ impl WorldIdRequestAuthError {
| Self::InvalidQueryProof
| Self::InvalidActionSchemaIssuer
| Self::InvalidActionSession
| Self::SignedActionNotAllowed
| Self::RpSignatureMissing => ErrorActor::Authenticator,
Self::Internal | Self::Unknown(_) => ErrorActor::OprfNode,
}
Expand All @@ -268,6 +286,8 @@ impl From<u16> for WorldIdRequestAuthError {
error_codes::UNKNOWN_SCHEMA_ISSUER => Self::UnknownSchemaIssuerId,
error_codes::INVALID_ACTION_NULLIFIER => Self::InvalidActionNullifier,
error_codes::INVALID_ACTION_SESSION => Self::InvalidActionSession,
error_codes::INVALID_SIGNED_ACTION => Self::InvalidSignedAction,
error_codes::SIGNED_ACTION_NOT_ALLOWED => Self::SignedActionNotAllowed,
error_codes::RP_SIGNATURE_EXPIRED => Self::RpSignatureExpired,
error_codes::RP_SIGNATURE_MISSING => Self::RpSignatureMissing,
error_codes::INVALID_TIMESTAMP => Self::InvalidTimestamp,
Expand Down Expand Up @@ -309,6 +329,10 @@ impl From<WorldIdRequestAuthError> for u16 {
error_codes::INVALID_ACTION_NULLIFIER
}
WorldIdRequestAuthError::InvalidActionSession => error_codes::INVALID_ACTION_SESSION,
WorldIdRequestAuthError::InvalidSignedAction => error_codes::INVALID_SIGNED_ACTION,
WorldIdRequestAuthError::SignedActionNotAllowed => {
error_codes::SIGNED_ACTION_NOT_ALLOWED
}
WorldIdRequestAuthError::RpSignatureExpired => error_codes::RP_SIGNATURE_EXPIRED,
WorldIdRequestAuthError::CreatedAtTooFarInFuture => {
error_codes::CREATED_AT_TOO_FAR_IN_FUTURE
Expand Down Expand Up @@ -386,6 +410,10 @@ pub mod error_codes {
pub const BLOCKED_RP: u16 = 4522;
/// Error code for [`super::WorldIdRequestAuthError::ExpiresAtTooFarInFuture`].
pub const EXPIRES_AT_TOO_FAR_IN_FUTURE: u16 = 4523;
/// Error code for [`super::WorldIdRequestAuthError::InvalidSignedAction`].
pub const INVALID_SIGNED_ACTION: u16 = 4524;
/// Error code for [`super::WorldIdRequestAuthError::SignedActionNotAllowed`].
pub const SIGNED_ACTION_NOT_ALLOWED: u16 = 4525;
/// Error code for [`super::WorldIdRequestAuthError::Internal`].
pub const INTERNAL: u16 = 1011;
}
Expand Down Expand Up @@ -468,6 +496,16 @@ impl From<WorldIdRequestAuthError> for OprfRequestAuthenticatorError {
// this should never truncate as code is a U256 encoded as hex
CloseFrameMessage::new_truncate(format!("{:#x}", code))
}
WorldIdRequestAuthError::InvalidSignedAction => {
taceo_oprf::types::close_frame_message!(
"Invalid signed action - must be a valid nullifier action (MSB 0x00)"
)
}
WorldIdRequestAuthError::SignedActionNotAllowed => {
taceo_oprf::types::close_frame_message!(
"Signed actions are only allowed on session-seed queries from EOA-backed RPs"
)
}
WorldIdRequestAuthError::Wip101AuxDataOnEoa => taceo_oprf::types::close_frame_message!(
"Auxiliary data must be empty with EOA backed signer"
),
Expand Down Expand Up @@ -496,6 +534,81 @@ impl From<WorldIdRequestAuthError> for OprfRequestAuthenticatorError {
mod tests {
use super::*;

/// A structurally valid Groth16 proof (BN254 generator points) for serde tests.
fn test_proof() -> Proof<Bn254> {
serde_json::from_value(serde_json::json!({
"pi_a": ["1", "2", "1"],
"pi_b": [
[
"10857046999023057135944570762232829481370756359578518086990519993285655852781",
"11559732032986387107991004021392285783925812861821192530917403151452391805634"
],
[
"8495653923123431417604973247489272438418190587263600148770280649306958101930",
"4082367875863433681332203403145435568316851327593401208105741076214120093531"
],
["1", "0"]
],
"pi_c": ["1", "2", "1"],
"protocol": "groth16",
"curve": "bn128"
}))
.expect("valid test proof")
}

fn test_auth(signed_action: Option<FieldElement>) -> NullifierOprfRequestAuthV1 {
NullifierOprfRequestAuthV1 {
proof: test_proof(),
action: ark_babyjubjub::Fq::from(1u64),
nonce: ark_babyjubjub::Fq::from(2u64),
merkle_root: ark_babyjubjub::Fq::from(3u64),
created_at: 4,
expires_at: 5,
signature: None,
rp_id: RpId::new(6),
wip101_data: None,
signed_action,
}
}

#[test]
fn nullifier_auth_signed_action_none_is_omitted() {
let value = serde_json::to_value(test_auth(None)).unwrap();
// Forward compat: unused, the field never appears on the wire.
assert!(value.get("signed_action").is_none());
// Backward compat: payloads without the field deserialize to `None`.
let parsed: NullifierOprfRequestAuthV1 = serde_json::from_value(value).unwrap();
assert!(parsed.signed_action.is_none());
}

#[test]
fn nullifier_auth_signed_action_json_roundtrip() {
let signed_action = FieldElement::from(42u64);
let auth = test_auth(Some(signed_action));
let json = serde_json::to_string(&auth).unwrap();
let parsed: NullifierOprfRequestAuthV1 = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.signed_action, Some(signed_action));
}

#[test]
fn nullifier_auth_signed_action_cbor_roundtrip() {
let signed_action = FieldElement::from(42u64);
let auth = test_auth(Some(signed_action));
let mut bytes = Vec::new();
ciborium::into_writer(&auth, &mut bytes).unwrap();
let parsed: NullifierOprfRequestAuthV1 = ciborium::from_reader(bytes.as_slice()).unwrap();
assert_eq!(parsed.signed_action, Some(signed_action));
}

#[test]
fn nullifier_auth_ignores_unknown_fields() {
// Old nodes must ignore fields added later (no `deny_unknown_fields`).
let mut value = serde_json::to_value(test_auth(None)).unwrap();
value["some_future_field"] = serde_json::json!("ignored");
let parsed = serde_json::from_value::<NullifierOprfRequestAuthV1>(value);
assert!(parsed.is_ok());
}

#[test]
fn error_code_roundtrip() {
let codes: &[u16] = &[
Expand All @@ -511,6 +624,8 @@ mod tests {
error_codes::UNKNOWN_SCHEMA_ISSUER,
error_codes::INVALID_ACTION_NULLIFIER,
error_codes::INVALID_ACTION_SESSION,
error_codes::INVALID_SIGNED_ACTION,
error_codes::SIGNED_ACTION_NOT_ALLOWED,
error_codes::INACTIVE_RP,
error_codes::RP_SIGNATURE_EXPIRED,
error_codes::INVALID_TIMESTAMP,
Expand Down
2 changes: 2 additions & 0 deletions crates/proof/src/oprf_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ impl<'a> OprfEntrypoint<'a> {
signature: Some(proof_request.signature),
rp_id: proof_request.rp_id,
wip101_data: None,
signed_action: None,
};

let verifiable_oprf_output = Self::execute_distributed_oprf(
Expand Down Expand Up @@ -311,6 +312,7 @@ impl<'a> OprfEntrypoint<'a> {
signature: Some(proof_request.signature),
rp_id: proof_request.rp_id,
wip101_data: None,
signed_action: None,
};

let verifiable_oprf_output = Self::execute_distributed_oprf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ fn generate_oprf_auth_request(
signature: Some(proof_request.signature),
rp_id: proof_request.rp_id,
wip101_data: None,
signed_action: None,
};

Ok(auth)
Expand Down
50 changes: 47 additions & 3 deletions services/oprf-node/src/auth/rp_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@
//! Both the session and uniqueness modules share identical struct fields, init
//! logic, and query-proof verification. They differ only in:
//! - how the action field is validated (`MSB == 0x00` for uniqueness vs `0x01/0x02` for sessions depending on the [`SessionFeType`])
//! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for session)
//! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for
//! session — unless the request carries a `signed_action`, see below)
//! - which [`WorldIdRequestAuthError`] variant is returned for an invalid action
//!
//! [`RpModuleKind`] captures these differences; [`RpModuleAuth`] holds the shared
//! state and branches on the kind at runtime.
//!
//! # Signed actions on session-seed queries (create-and-bind)
//!
//! A session-seed query may carry an optional `signed_action` (a nullifier action, MSB
//! `0x00`). When present, the session module verifies the RP signature over the
//! action-inclusive message instead of the action-less one. This lets a single RP
//! signature authorize both creating a session and binding a Uniqueness Proof to it.
//! `signed_action` is rejected everywhere else: on session-action queries, on the
//! uniqueness module, and for WIP101 contract-backed RPs (which do not support session
//! queries yet).

use crate::{
accountant_batcher::AccountantBatcherHandle,
Expand Down Expand Up @@ -42,7 +53,9 @@ pub(crate) mod wip101;
/// Distinguishes the two RP-authenticated OPRF modules.
#[derive(Clone)]
pub(crate) enum RpModuleKind {
/// Session module: action MSB must be `0x01` (seed) or `0x02` (action); action is NOT signed.
/// Session module: action MSB must be `0x01` (seed) or `0x02` (action); action is NOT
/// signed. Seed queries may carry a `signed_action` (MSB `0x00`), in which case the RP
/// signature is verified over the action-inclusive message (create-and-bind).
Session,
/// Uniqueness module: action MSB must be `0x00`; action IS signed.
Uniqueness(AccountantBatcherHandle),
Expand Down Expand Up @@ -73,6 +86,10 @@ pub(crate) enum RpModuleError {

#[error("Invalid action for uniqueness (action MSB must be 0x00): {action}")]
InvalidActionUniqueness { action: FieldElement },
#[error("Invalid signed action (MSB must be 0x00): {signed_action}")]
InvalidSignedAction { signed_action: FieldElement },
#[error("Signed action not allowed: {context}")]
SignedActionNotAllowed { context: &'static str },
#[error("Could not verify query proof")]
InvalidQueryProof,
#[error(transparent)]
Expand Down Expand Up @@ -131,6 +148,8 @@ impl From<&RpModuleError> for WorldIdRequestAuthError {
match value {
RpModuleError::InvalidActionSession { .. } => Self::InvalidActionSession,
RpModuleError::InvalidActionUniqueness { .. } => Self::InvalidActionNullifier,
RpModuleError::InvalidSignedAction { .. } => Self::InvalidSignedAction,
RpModuleError::SignedActionNotAllowed { .. } => Self::SignedActionNotAllowed,
RpModuleError::InvalidQueryProof => Self::InvalidQueryProof,
RpModuleError::MerkleWatcher(e) => Self::from(e.as_ref()),
RpModuleError::RpRegistry(e) => Self::from(e.as_ref()),
Expand Down Expand Up @@ -321,12 +340,19 @@ impl RpModuleAuth {
tracing::trace!("RP signer is EOA");
let action = match self.kind {
RpModuleKind::Uniqueness(_) => Some(action),
RpModuleKind::Session => None,
// Session RP signatures do not include the action, unless the request
// carries a `signed_action` (create-and-bind seed queries).
RpModuleKind::Session => request.auth.signed_action.map(|a| *a),
};
rp.verify_eoa(action, request)
}
RpAccountType::Contract => {
// TODO(session-proofs): WIP-101 does not currently support session proofs.
if request.auth.signed_action.is_some() {
return Err(RpModuleError::SignedActionNotAllowed {
context: "not supported for WIP101 contract-backed RPs",
});
}
Ok(rp
.verify_wip101(
action,
Expand Down Expand Up @@ -378,19 +404,37 @@ impl RpModuleAuth {
let action = FieldElement::from(request.auth.action);

// Validate the action per kind and derive the nonce scope it consumes.
// A `signed_action` (a nullifier action the RP signature covers) is only valid on
// session-seed queries; see the module docs for the create-and-bind flow.
let nonce_scope = match self.kind {
RpModuleKind::Session => {
metrics::auth_module::inc_session();
if action.is_valid_for_session(SessionFeType::OprfSeed) {
if let Some(signed_action) = request.auth.signed_action {
if signed_action.to_be_bytes()[0] != 0 {
return Err(RpModuleError::InvalidSignedAction { signed_action });
}
metrics::auth_module::inc_session_signed_action();
}
NonceScope::SessionOprfSeed
} else if action.is_valid_for_session(SessionFeType::Action) {
if request.auth.signed_action.is_some() {
return Err(RpModuleError::SignedActionNotAllowed {
context: "only allowed on session-seed queries",
});
}
NonceScope::SessionAction
} else {
return Err(RpModuleError::InvalidActionSession { action });
}
}
RpModuleKind::Uniqueness(_) => {
metrics::auth_module::inc_nullifier();
if request.auth.signed_action.is_some() {
return Err(RpModuleError::SignedActionNotAllowed {
context: "only allowed on the session module",
});
}
if action.to_be_bytes()[0] != 0 {
return Err(RpModuleError::InvalidActionUniqueness { action });
}
Expand Down
Loading
Loading