diff --git a/contract/Cargo.toml b/contract/Cargo.toml index f1d87ae..165287a 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -43,3 +43,4 @@ dusk-poseidon = "=0.42.0-rc.0" dusk-bytes = "=0.1.7" dusk-vm = "=1.6.0" dusk-core = { version = "=1.6.0", features = ["zk"] } +sha2 = { version = "0.10.8", default-features = false } diff --git a/contract/README.md b/contract/README.md index 78a1f45..5f6d3c1 100644 --- a/contract/README.md +++ b/contract/README.md @@ -29,6 +29,17 @@ cargo test --release --test license_contract The build script first tries to download the Dusk trusted setup and verify its SHA-256 hash. If the download is unavailable it generates local setup material so tests can run, but those generated keys are not deployment-ready. +The build requires Cargo's `OUT_DIR` and writable output paths for generated +artifacts. Missing build environment or artifact-write failures are fatal build +errors. + +## Reverts + +Contract entrypoints use `panic!` to reject invalid calls, which Dusk VM treats +as contract reverts. Capacity guards such as a full license tree follow the +same revert model as malformed public inputs, stale roots, duplicate +nullifiers, and failed proof verification. + ## License This project is licensed under the [Mozilla Public License 2.0](../LICENSE). diff --git a/contract/build.rs b/contract/build.rs index 153adf6..ebc4cc9 100644 --- a/contract/build.rs +++ b/contract/build.rs @@ -11,7 +11,7 @@ use sha2::{Digest, Sha256}; use std::env; use std::fs::{self, File}; use std::io::prelude::*; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use zk_citadel::circuit; static LABEL: &[u8; 12] = b"dusk-network"; @@ -22,90 +22,115 @@ const CRS_17_HASH: &str = "6161605616b62356cf09fa28252c672ef53b2c8489ad5f81d87af const PROVER_PATH: &str = "../target/prover"; const VERIFIER_PATH: &str = "../target/verifier"; const CIRCUIT_MARKER_PATH: &str = "../target/license-circuit-build-id"; -const CIRCUIT_BUILD_ID: &str = "zk-citadel-license-circuit"; +const OUT_DIR_VERIFIER_FILENAME: &str = "license_verifier"; +const CIRCUIT_SOURCE_FINGERPRINT_DOMAIN: &[u8] = b"zk-citadel-license-circuit-source-fingerprint"; +const CIRCUIT_SOURCE_PATHS: &[(&str, &str)] = &[ + ("core/Cargo.toml", "../core/Cargo.toml"), + ("core/src/helpers.rs", "../core/src/helpers.rs"), + ("core/src/signatures.rs", "../core/src/signatures.rs"), + ("core/src/zk/circuit.rs", "../core/src/zk/circuit.rs"), + ("core/src/zk/gadgets.rs", "../core/src/zk/gadgets.rs"), +]; #[tokio::main] async fn main() { - if setup_material_stale() { - let response = reqwest::get(CRS_URL).await; - - match response { - Ok(pp_bytes) => { - // If setup didn't exist locally, we download the setup again from server - let pp_bytes = pp_bytes.bytes().await.unwrap(); - let mut hasher = Sha256::new(); - hasher.update(pp_bytes.clone()); - let hash = format!("{:x}", hasher.finalize()); - - // We check the file integrity - assert_eq!(hash, CRS_17_HASH); - - let pp = PublicParameters::from_slice(pp_bytes.to_vec().as_slice()) - .expect("Creating PublicParameters from slice failed."); - - // Compile the license circuit - let (prover, verifier) = Compiler::compile::(&pp, LABEL) - .expect("failed to compile circuit"); - - // Write prover key to disk - let mut file = File::create(PROVER_PATH).unwrap(); - file.write_all(&prover.to_bytes()).unwrap(); - - // Write verifier key to disk - let mut file = File::create(VERIFIER_PATH).unwrap(); - file.write_all(&verifier.to_bytes()).unwrap(); - write_circuit_marker(); - - info!("Local trusted setup not found, a new one was downloaded."); - } - Err(_e) => { - // If download fails, we create a setup from scratch - let pp = PublicParameters::setup(1 << circuit::CAPACITY, &mut OsRng).unwrap(); - - // Compile the license circuit - let (prover, verifier) = Compiler::compile::(&pp, LABEL) - .expect("failed to compile circuit"); - - // Write prover key to disk - let mut file = File::create(PROVER_PATH).unwrap(); - file.write_all(&prover.to_bytes()).unwrap(); - - // Write verifier key to disk - let mut file = File::create(VERIFIER_PATH).unwrap(); - file.write_all(&verifier.to_bytes()).unwrap(); - write_circuit_marker(); - - warn!( - "Download of trusted setup from server failed. A new one was generated from scratch. USE AT YOUR OWN RISK." - ); - } - } + emit_rerun_directives(); + + let circuit_build_id = circuit_build_id(); + let response = reqwest::get(CRS_URL).await; + let verifier_bytes = match response { + Ok(pp_bytes) => compile_from_trusted_crs(pp_bytes, &circuit_build_id).await, + Err(_e) => compile_from_fresh_setup(&circuit_build_id), + }; + + write_metadata_hashes(&circuit_build_id, &verifier_bytes); +} + +fn emit_rerun_directives() { + std::println!("cargo:rerun-if-changed=build.rs"); + for (_, disk_path) in CIRCUIT_SOURCE_PATHS { + std::println!("cargo:rerun-if-changed={disk_path}"); } +} + +async fn compile_from_trusted_crs(pp_bytes: reqwest::Response, circuit_build_id: &str) -> Vec { + let pp_bytes = pp_bytes.bytes().await.unwrap(); + let mut hasher = Sha256::new(); + hasher.update(pp_bytes.clone()); + let hash = format!("{:x}", hasher.finalize()); + + // We check the file integrity + assert_eq!(hash, CRS_17_HASH); - write_metadata_hashes(); + let pp = PublicParameters::from_slice(pp_bytes.to_vec().as_slice()) + .expect("Creating PublicParameters from slice failed."); + let verifier_bytes = compile_and_write_setup_material(&pp, circuit_build_id); + + info!("License circuit setup material was regenerated from the trusted CRS."); + verifier_bytes } -fn setup_material_stale() -> bool { - if !(Path::new(PROVER_PATH).exists()) || !(Path::new(VERIFIER_PATH).exists()) { - return true; +fn compile_from_fresh_setup(circuit_build_id: &str) -> Vec { + // If download fails, we create a setup from scratch + let pp = PublicParameters::setup(1 << circuit::CAPACITY, &mut OsRng).unwrap(); + let verifier_bytes = compile_and_write_setup_material(&pp, circuit_build_id); + + warn!( + "Download of trusted setup from server failed. A new one was generated from scratch. USE AT YOUR OWN RISK." + ); + verifier_bytes +} + +fn compile_and_write_setup_material(pp: &PublicParameters, circuit_build_id: &str) -> Vec { + let (prover, verifier) = + Compiler::compile::(pp, LABEL).expect("failed to compile circuit"); + let prover_bytes = prover.to_bytes(); + let verifier_bytes = verifier.to_bytes(); + + // Keep the legacy target paths for wallets/tests, but embed the verifier + // from OUT_DIR so deployed bytecode cannot observe a stale target file. + let mut file = File::create(PROVER_PATH).unwrap(); + file.write_all(&prover_bytes).unwrap(); + + let mut file = File::create(VERIFIER_PATH).unwrap(); + file.write_all(&verifier_bytes).unwrap(); + + let out_dir_verifier = out_dir().join(OUT_DIR_VERIFIER_FILENAME); + fs::write(out_dir_verifier, &verifier_bytes).expect("OUT_DIR verifier should be written"); + + write_circuit_marker(circuit_build_id); + verifier_bytes +} + +fn circuit_build_id() -> String { + let mut hasher = Sha256::new(); + hasher.update(CIRCUIT_SOURCE_FINGERPRINT_DOMAIN); + + for (canonical_path, disk_path) in CIRCUIT_SOURCE_PATHS { + let source = fs::read(disk_path) + .unwrap_or_else(|error| panic!("failed to read {disk_path}: {error}")); + hasher.update((*canonical_path).as_bytes()); + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source); } - fs::read_to_string(CIRCUIT_MARKER_PATH) - .map(|marker| marker.trim() != CIRCUIT_BUILD_ID) - .unwrap_or(true) + format!("{:x}", hasher.finalize()) } -fn write_circuit_marker() { - fs::write(CIRCUIT_MARKER_PATH, CIRCUIT_BUILD_ID).expect("circuit marker should be written"); +fn write_circuit_marker(circuit_build_id: &str) { + let prover_hash = file_sha256_hex(PROVER_PATH).expect("prover hash should be computed"); + let verifier_hash = file_sha256_hex(VERIFIER_PATH).expect("verifier hash should be computed"); + let marker = format!( + "circuit_build_id={circuit_build_id}\nprover_hash={prover_hash}\nverifier_hash={verifier_hash}\n" + ); + fs::write(CIRCUIT_MARKER_PATH, marker).expect("circuit marker should be written"); } -fn write_metadata_hashes() { - let verifier = fs::read(VERIFIER_PATH).expect("verifier key should exist after build"); - let verifier_key_hash = scalar_from_sha256(b"CITADEL_VERIFIER_KEY_HASH_V1", &verifier); - let circuit_hash = scalar_from_sha256(b"CITADEL_CIRCUIT_HASH_V1", CIRCUIT_BUILD_ID.as_bytes()); +fn write_metadata_hashes(circuit_build_id: &str, verifier: &[u8]) { + let verifier_key_hash = scalar_from_sha256(b"CITADEL_VERIFIER_KEY_HASH_V1", verifier); + let circuit_hash = scalar_from_sha256(b"CITADEL_CIRCUIT_HASH_V1", circuit_build_id.as_bytes()); - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR should be set")); - let generated = out_dir.join("metadata_hashes.rs"); + let generated = out_dir().join("metadata_hashes.rs"); let contents = format!( "const VERIFIER_KEY_HASH: BlsScalar = BlsScalar::from_raw({verifier_key_hash:?});\n\ const CIRCUIT_HASH: BlsScalar = BlsScalar::from_raw({circuit_hash:?});\n", @@ -114,6 +139,10 @@ fn write_metadata_hashes() { fs::write(generated, contents).expect("metadata hash constants should be written"); } +fn out_dir() -> PathBuf { + PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR should be set")) +} + fn scalar_from_sha256(domain: &[u8], bytes: &[u8]) -> [u64; 4] { let mut hasher = Sha256::new(); hasher.update(domain); @@ -131,3 +160,13 @@ fn scalar_from_sha256(domain: &[u8], bytes: &[u8]) -> [u64; 4] { limbs } + +fn file_sha256_hex(path: &str) -> std::io::Result { + fs::read(path).map(|bytes| sha256_hex(&bytes)) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} diff --git a/contract/src/lib.rs b/contract/src/lib.rs index c433d53..e6cfa2b 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -23,7 +23,7 @@ pub use license_types::{ UseLicenseArg, }; -const VD_LICENSE_CIRCUIT: &[u8] = include_bytes!("../../target/verifier"); +const VD_LICENSE_CIRCUIT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/license_verifier")); /// Verifier data for the `License` circuit. #[allow(dead_code)] diff --git a/contract/tests/license_contract.rs b/contract/tests/license_contract.rs index 2eb2733..c8e3ba8 100644 --- a/contract/tests/license_contract.rs +++ b/contract/tests/license_contract.rs @@ -13,6 +13,7 @@ use dusk_bytes::Serializable; use rand::rngs::StdRng; use rand::{CryptoRng, RngCore, SeedableRng}; use rkyv::{Deserialize, Infallible, check_archived_root}; +use sha2::{Digest, Sha256}; use zk_citadel::{License, LicenseOptions, LicenseOrigin, SessionCookie, circuit, gadgets}; const PROVER_BYTES: &[u8] = include_bytes!("../../target/prover"); @@ -90,9 +91,9 @@ fn initialize() -> Session { } /// Deserializes license, panics if deserialization fails. -fn deserialise_license(v: &Vec) -> License { +fn deserialise_license(v: &[u8]) -> License { let response_data = - check_archived_root::(v.as_slice()).expect("License should deserialize correctly"); + check_archived_root::(v).expect("License should deserialize correctly"); let license: License = response_data .deserialize(&mut Infallible) .expect("Infallible"); @@ -101,14 +102,11 @@ fn deserialise_license(v: &Vec) -> License { /// Finds owned license in a collection of licenses. /// It searches in a reverse order to return a newest license. -fn find_owned_license( - sk_user: &SecretKey, - licenses: &Vec<(u64, Vec)>, -) -> Option<(u64, License)> { +fn find_owned_license(sk_user: &SecretKey, licenses: &[(u64, Vec)]) -> Option<(u64, License)> { for (pos, license) in licenses.iter().rev() { - let license = deserialise_license(&license); + let license = deserialise_license(license); if ViewKey::from(sk_user).owns(&license.lsa) { - return Some((pos.clone(), license)); + return Some((*pos, license)); } } None @@ -169,8 +167,7 @@ fn license_issue_get_merkle() { u64::MAX, feeder, ) - .expect("Querying of the licenses should succeed") - .data; + .expect("Querying of the licenses should succeed"); let pos_license_pairs: Vec<(u64, Vec)> = receiver .iter() @@ -237,8 +234,7 @@ fn multiple_licenses_issue_get_merkle() { u64::MAX, feeder, ) - .expect("Querying of the licenses should succeed") - .data; + .expect("Querying of the licenses should succeed"); let pos_license_pairs: Vec<(u64, Vec)> = receiver .iter() @@ -278,7 +274,10 @@ fn metadata_and_info_track_state() { assert_eq!(metadata.protocol_version, BlsScalar::one()); assert_eq!(metadata.chain_id, BlsScalar::zero()); assert_eq!(metadata.contract_id, BlsScalar::zero()); - assert_ne!(metadata.verifier_key_hash, BlsScalar::zero()); + assert_eq!( + metadata.verifier_key_hash, + metadata_scalar_from_sha256(b"CITADEL_VERIFIER_KEY_HASH_V1", VERIFIER_BYTES) + ); assert_ne!(metadata.circuit_hash, BlsScalar::zero()); assert_eq!(metadata.merkle_arity, 4); assert_eq!(metadata.merkle_depth, circuit::DEPTH as u32); @@ -380,6 +379,24 @@ fn metadata_and_info_track_state() { assert!(opening.is_some()); } +fn metadata_scalar_from_sha256(domain: &[u8], bytes: &[u8]) -> BlsScalar { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + let digest = hasher.finalize(); + + let mut scalar_bytes = [0u8; 32]; + scalar_bytes[..31].copy_from_slice(&digest[..31]); + + let mut limbs = [0u64; 4]; + for (i, byte) in scalar_bytes.iter().enumerate() { + limbs[i / 8] |= (*byte as u64) << ((i % 8) * 8); + } + + BlsScalar::from_raw(limbs) +} + #[test] fn issue_license_rejects_invalid_and_duplicate_public_keys() { let rng = &mut StdRng::seed_from_u64(0xcafe); @@ -585,8 +602,7 @@ fn use_license_get_session() { u64::MAX, feeder, ) - .expect("Querying the license should succeed") - .data; + .expect("Querying the license should succeed"); let pos_license_pairs: Vec<(u64, Vec)> = receiver .iter() diff --git a/core/src/assets/license.rs b/core/src/assets/license.rs index a634c54..9f69f85 100644 --- a/core/src/assets/license.rs +++ b/core/src/assets/license.rs @@ -22,7 +22,7 @@ use dusk_plonk::prelude::*; use crate::assets::{REQ_PLAINTEXT_SIZE, Request}; use crate::helpers::{ DEFAULT_DEPLOYMENT, Deployment, OBJECT_VERSION_V1, license_encryption_salt, license_key, - license_sig_message, public_key_is_valid, request_encryption_salt, + license_sig_message, public_key_is_valid, request_encryption_salt, stealth_address_is_valid, }; use crate::signatures::LicenseSignature; @@ -190,6 +190,9 @@ impl License { if req.deployment_id != deployment.id { return Err(Error::InvalidData); } + if !stealth_address_is_valid(&req.rsa) { + return Err(Error::InvalidData); + } let actual_pk_lp = PublicKey::from(sk_lp); let k_dh = dhke(sk_lp.a(), req.rsa.R()); @@ -201,6 +204,9 @@ impl License { let mut lsa_bytes = [0u8; StealthAddress::SIZE]; lsa_bytes.copy_from_slice(&dec[..StealthAddress::SIZE]); let lsa = StealthAddress::from_bytes(&lsa_bytes)?; + if !stealth_address_is_valid(&lsa) { + return Err(Error::InvalidData); + } let mut k_lic_bytes = [0u8; JubJubAffine::SIZE]; let mut offset = StealthAddress::SIZE; @@ -237,6 +243,10 @@ impl License { (lsa, k_lic) } LicenseOrigin::FromPublicKey(pk_user) => { + if !public_key_is_valid(pk_user) { + return Err(Error::InvalidData); + } + let r_dh = JubJubScalar::random(&mut *rng); let lsa = pk_user.gen_stealth_address(&r_dh); let k_lic = dhke(&r_dh, pk_user.A()); @@ -285,6 +295,10 @@ impl License { BlsScalar::zero(), BlsScalar::zero(), ); + if !stealth_address_is_valid(&self.lsa) { + return Err(Error::InvalidData); + } + let lpk = JubJubAffine::from(self.lsa.note_pk().as_ref()); let lsa_r = JubJubAffine::from(self.lsa.R()); let lsk = sk.gen_note_sk(&self.lsa); diff --git a/core/src/assets/request.rs b/core/src/assets/request.rs index f7fcc71..3ae0e43 100644 --- a/core/src/assets/request.rs +++ b/core/src/assets/request.rs @@ -20,8 +20,8 @@ use rkyv::{Archive, Deserialize, Serialize}; use dusk_plonk::prelude::*; use crate::helpers::{ - DEFAULT_DEPLOYMENT, Deployment, OBJECT_VERSION_V1, license_key, request_encryption_salt, - request_id, + DEFAULT_DEPLOYMENT, Deployment, OBJECT_VERSION_V1, license_key, public_key_is_valid, + request_encryption_salt, request_id, }; const DEPLOYMENT_CONTEXT_SIZE: usize = BlsScalar::SIZE; @@ -71,6 +71,10 @@ impl Request { deployment: Deployment, rng: &mut R, ) -> Result { + if !public_key_is_valid(pk_user) || !public_key_is_valid(pk_lp) { + return Err(Error::InvalidData); + } + let lsa = pk_user.gen_stealth_address(&JubJubScalar::random(&mut *rng)); let lsk = sk_user.gen_note_sk(&lsa); let k_lic = license_key( diff --git a/core/src/helpers.rs b/core/src/helpers.rs index 8c024e3..c8e0e80 100644 --- a/core/src/helpers.rs +++ b/core/src/helpers.rs @@ -480,6 +480,12 @@ pub fn public_key_is_valid(pk: &PublicKey) -> bool { && public_key_point_is_valid(JubJubAffine::from(pk.B())) } +/// Returns whether a Phoenix stealth address is made of valid non-identity subgroup points. +pub fn stealth_address_is_valid(sa: &StealthAddress) -> bool { + public_key_point_is_valid(JubJubAffine::from(sa.R())) + && public_key_point_is_valid(JubJubAffine::from(sa.note_pk().as_ref())) +} + /// Returns whether a Phoenix public-key point is valid and non-identity. pub fn public_key_point_is_valid(point: JubJubAffine) -> bool { bool::from(point.is_on_curve()) diff --git a/core/src/signatures.rs b/core/src/signatures.rs index 5153ad7..6232c2b 100644 --- a/core/src/signatures.rs +++ b/core/src/signatures.rs @@ -15,7 +15,9 @@ use dusk_plonk::prelude::BlsScalar; use ff::Field; use rand_core::{CryptoRng, RngCore}; -use crate::helpers::{Deployment, license_sig_challenge, session_sig_challenge}; +use crate::helpers::{ + Deployment, license_sig_challenge, public_key_point_is_valid, session_sig_challenge, +}; /// LP Schnorr signature over a Citadel license message. #[cfg_attr( @@ -67,6 +69,10 @@ impl LicenseSignature { msg_lic: BlsScalar, ) -> bool { let r = JubJubAffine::from(self.r); + if !public_key_point_is_valid(signing_point) || !public_key_point_is_valid(r) { + return false; + } + let challenge = license_sig_challenge(deployment, signing_point, r, msg_lic); let lhs = GENERATOR_EXTENDED * self.z; let rhs = self.r + (dusk_jubjub::JubJubExtended::from(signing_point) * challenge); @@ -168,14 +174,17 @@ impl SessionAuthSignature { lpk_p: JubJubAffine, session_auth: BlsScalar, ) -> bool { - let challenge = session_sig_challenge( - deployment, - lpk, - lpk_p, - JubJubAffine::from(self.r), - JubJubAffine::from(self.r_prime), - session_auth, - ); + let r = JubJubAffine::from(self.r); + let r_prime = JubJubAffine::from(self.r_prime); + if !public_key_point_is_valid(lpk) + || !public_key_point_is_valid(lpk_p) + || !public_key_point_is_valid(r) + || !public_key_point_is_valid(r_prime) + { + return false; + } + + let challenge = session_sig_challenge(deployment, lpk, lpk_p, r, r_prime, session_auth); let lhs = GENERATOR_EXTENDED * self.z; let rhs = self.r + (dusk_jubjub::JubJubExtended::from(lpk) * challenge); diff --git a/core/src/zk/gadgets.rs b/core/src/zk/gadgets.rs index dccbc74..7d44bbe 100644 --- a/core/src/zk/gadgets.rs +++ b/core/src/zk/gadgets.rs @@ -5,7 +5,8 @@ // Copyright (c) DUSK NETWORK. All rights reserved. use dusk_jubjub::{ - EDWARDS_D, GENERATOR, GENERATOR_EXTENDED, GENERATOR_NUMS, GENERATOR_NUMS_EXTENDED, dhke, + EDWARDS_D, GENERATOR, GENERATOR_EXTENDED, GENERATOR_NUMS, GENERATOR_NUMS_EXTENDED, + JubJubExtended, dhke, }; use dusk_plonk::prelude::*; use dusk_poseidon::{Domain, HashGadget}; @@ -23,7 +24,7 @@ use crate::{ helpers::{ COOKIE_MODE_BASE, CitadelDomain, DEFAULT_DEPLOYMENT, OBJECT_VERSION_V1, license_encryption_salt, license_key, lp_commitment, session_auth, session_hash, - session_id, + session_id, stealth_address_is_valid, }, signatures::{LicenseSignature, SessionAuthSignature}, }; @@ -246,12 +247,22 @@ fn assert_valid_witness_point(composer: &mut Composer, point: WitnessPoint) { assert_on_curve(composer, point); assert_not_identity(composer, point); - // Jubjub has cofactor 8; multiplying by 8 must not collapse a valid - // prime-order witness point to the identity. - let two_p = composer.component_add_point(point, point); - let four_p = composer.component_add_point(two_p, two_p); - let eight_p = composer.component_add_point(four_p, four_p); - assert_not_identity(composer, eight_p); + // The cofactor map [8] has the prime-order subgroup as its image. Proving + // point = [8]Q for an on-curve Q excludes hidden torsion components. + // Eight is non-zero. Falling back to zero makes the equality below + // unsatisfiable for non-identity points instead of aborting synthesis. + let inv_eight = JubJubScalar::from(8u64) + .invert() + .unwrap_or(JubJubScalar::zero()); + let point_value = JubJubAffine::from_raw_unchecked(composer[*point.x()], composer[*point.y()]); + let subgroup_preimage = JubJubAffine::from(JubJubExtended::from(point_value) * inv_eight); + let subgroup_preimage = composer.append_point(subgroup_preimage); + assert_on_curve(composer, subgroup_preimage); + + let two_q = composer.component_add_point(subgroup_preimage, subgroup_preimage); + let four_q = composer.component_add_point(two_q, two_q); + let eight_q = composer.component_add_point(four_q, four_q); + composer.assert_equal_point(eight_q, point); } fn assert_on_curve(composer: &mut Composer, point: WitnessPoint) { @@ -348,6 +359,9 @@ impl GadgetParameters { if lic.version != OBJECT_VERSION_V1 || lic.deployment_id != DEFAULT_DEPLOYMENT.id { return Err(phoenix_core::Error::InvalidData); } + if !stealth_address_is_valid(&lic.lsa) { + return Err(phoenix_core::Error::InvalidData); + } let lsk = sk.gen_note_sk(&lic.lsa); let k_lic = dhke(sk.a(), lic.lsa.R()); @@ -444,3 +458,62 @@ impl GadgetParameters { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + use dusk_jubjub::JubJubScalar; + use rand_core::OsRng; + + static LABEL: &[u8; 22] = b"citadel-point-validity"; + + #[derive(Clone, Copy)] + struct PointValidityCircuit { + point: JubJubAffine, + } + + impl Default for PointValidityCircuit { + fn default() -> Self { + Self { + point: JubJubAffine::from(GENERATOR_EXTENDED * JubJubScalar::from(7u64)), + } + } + } + + impl Circuit for PointValidityCircuit { + fn circuit(&self, composer: &mut Composer) -> Result<(), Error> { + let point = composer.append_point(self.point); + assert_valid_witness_point(composer, point); + Ok(()) + } + } + + #[test] + fn witness_point_validity_rejects_torsion_shifted_points() { + let pp = PublicParameters::setup(1 << 11, &mut OsRng) + .expect("public parameters should be created"); + let (prover, verifier) = Compiler::compile::(&pp, LABEL) + .expect("point-validity circuit should compile"); + + let valid = PointValidityCircuit::default(); + let (proof, public_inputs) = prover + .prove(&mut OsRng, &valid) + .expect("valid prime-order point should prove"); + verifier + .verify(&proof, &public_inputs) + .expect("valid prime-order point should verify"); + + let torsion = JubJubAffine::from_raw_unchecked(BlsScalar::zero(), -BlsScalar::one()); + let shifted = JubJubAffine::from( + (GENERATOR_EXTENDED * JubJubScalar::from(9u64)) + JubJubExtended::from(torsion), + ); + assert!(bool::from(shifted.is_on_curve())); + assert!(!bool::from(shifted.is_prime_order())); + + let invalid = PointValidityCircuit { point: shifted }; + prover + .prove(&mut OsRng, &invalid) + .expect_err("torsion-shifted point should not satisfy the circuit"); + } +} diff --git a/core/tests/assets.rs b/core/tests/assets.rs index 8aae023..f865b33 100644 --- a/core/tests/assets.rs +++ b/core/tests/assets.rs @@ -4,14 +4,14 @@ // // Copyright (c) DUSK NETWORK. All rights reserved. -use dusk_jubjub::{GENERATOR_EXTENDED, GENERATOR_NUMS_EXTENDED, JubJubAffine}; +use dusk_jubjub::{GENERATOR_EXTENDED, GENERATOR_NUMS_EXTENDED, JubJubAffine, JubJubExtended}; use dusk_plonk::prelude::*; -use phoenix_core::{PublicKey, SecretKey}; +use phoenix_core::{PublicKey, SecretKey, StealthAddress}; use rand_core::OsRng; use zk_citadel::{ - AttributeOpening, Error as CitadelError, License, LicenseOptions, LicenseOrigin, Request, - Session, SessionCookie, SessionPolicy, + AttributeOpening, Error as CitadelError, License, LicenseOptions, LicenseOrigin, + LicenseSignature, Request, Session, SessionAuthSignature, SessionCookie, SessionPolicy, helpers::{ COOKIE_MODE_BASE, DEFAULT_DEPLOYMENT, Deployment, OBJECT_VERSION_V1, PI_COM_1_X, PI_COM_1_Y, attr_data as compute_attr_data, attr_data_from_canonical_attributes, @@ -168,6 +168,65 @@ fn direct_license_carries_selected_deployment() { assert_eq!(license.deployment_id, deployment.id); } +#[test] +fn request_and_direct_license_reject_invalid_public_keys_before_dh() { + let sk_user = SecretKey::random(&mut OsRng); + let pk_user = PublicKey::from(&sk_user); + let sk_lp = SecretKey::random(&mut OsRng); + let pk_lp = PublicKey::from(&sk_lp); + let invalid_pk = PublicKey::new(JubJubExtended::identity(), JubJubExtended::identity()); + let attr_data = JubJubScalar::from(457u64); + + assert!(Request::new(&sk_user, &invalid_pk, &pk_lp, &mut OsRng).is_err()); + assert!(Request::new(&sk_user, &pk_user, &invalid_pk, &mut OsRng).is_err()); + assert!( + License::new( + &attr_data, + &sk_lp, + &LicenseOrigin::FromPublicKey(Box::new(invalid_pk)), + LicenseOptions::default(), + &mut OsRng, + ) + .is_err() + ); +} + +#[test] +fn request_and_license_reject_invalid_stealth_addresses_before_dh() { + let sk_user = SecretKey::random(&mut OsRng); + let pk_user = PublicKey::from(&sk_user); + let sk_lp = SecretKey::random(&mut OsRng); + let pk_lp = PublicKey::from(&sk_lp); + let attr_data = JubJubScalar::from(458u64); + + let mut request = + Request::new(&sk_user, &pk_user, &pk_lp, &mut OsRng).expect("request should build"); + request.rsa = + StealthAddress::from_raw_unchecked(JubJubExtended::identity(), *request.rsa.note_pk()); + assert!( + License::new( + &attr_data, + &sk_lp, + &LicenseOrigin::FromRequest(Box::new(request)), + LicenseOptions::default(), + &mut OsRng, + ) + .is_err() + ); + + let mut license = License::new( + &attr_data, + &sk_lp, + &LicenseOrigin::FromPublicKey(Box::new(pk_user)), + LicenseOptions::default(), + &mut OsRng, + ) + .expect("direct issuance should succeed"); + license.lsa = + StealthAddress::from_raw_unchecked(JubJubExtended::identity(), *license.lsa.note_pk()); + assert!(license.open(&sk_user).is_err()); +} + #[test] fn license_payload_round_trip_carries_full_lp_key() { let sk_user = SecretKey::random(&mut OsRng); @@ -209,6 +268,40 @@ fn license_payload_round_trip_carries_full_lp_key() { assert_eq!(payload.context.revocation_id, BlsScalar::from(34u64)); } +#[test] +fn schnorr_verify_rejects_invalid_public_key_forgeries() { + let msg = BlsScalar::from(71u64); + let signing_secret = JubJubScalar::from(72u64); + let signing_point = JubJubAffine::from(GENERATOR_EXTENDED * signing_secret); + let license_sig = LicenseSignature::sign( + &mut OsRng, + DEFAULT_DEPLOYMENT, + &signing_secret, + signing_point, + msg, + ); + assert!(license_sig.verify(DEFAULT_DEPLOYMENT, signing_point, msg)); + assert!(!LicenseSignature::default().verify(DEFAULT_DEPLOYMENT, JubJubAffine::identity(), msg)); + + let lpk = JubJubAffine::from(GENERATOR_EXTENDED * signing_secret); + let lpk_p = JubJubAffine::from(GENERATOR_NUMS_EXTENDED * signing_secret); + let session_sig = SessionAuthSignature::sign( + &mut OsRng, + DEFAULT_DEPLOYMENT, + &signing_secret, + lpk, + lpk_p, + msg, + ); + assert!(session_sig.verify(DEFAULT_DEPLOYMENT, lpk, lpk_p, msg)); + assert!(!SessionAuthSignature::default().verify( + DEFAULT_DEPLOYMENT, + JubJubAffine::identity(), + JubJubAffine::identity(), + msg + )); +} + #[test] fn attr_data_from_canonical_attributes_is_stable_and_blinded() { let schema_id = BlsScalar::from(51u64); @@ -271,10 +364,10 @@ fn session_rejects_malformed_public_inputs() { fn session_verify_reports_each_cookie_opening_failure() { let sc = cookie(); let session = Session::from(&public_inputs(&sc)).expect("valid public inputs should parse"); - let policy = policy(&sc); + let selected_policy = policy(&sc); session - .verify(sc, &policy) + .verify(sc, &selected_policy) .expect("matching cookie should open the session"); let signing_point_policy = SessionPolicy::new(sc.policy_id, sc.pk_sp, sc.pk_lp, sc.c) @@ -287,35 +380,35 @@ fn session_verify_reports_each_cookie_opening_failure() { let mut wrong_deployment = sc; wrong_deployment.deployment_id = BlsScalar::from(1u64); assert!(matches!( - session.verify(wrong_deployment, &policy), + session.verify(wrong_deployment, &selected_policy), Err(CitadelError::WrongDeployment) )); let mut wrong_policy_id = sc; wrong_policy_id.policy_id = BlsScalar::from(42u64); assert!(matches!( - session.verify(wrong_policy_id, &policy), + session.verify(wrong_policy_id, &selected_policy), Err(CitadelError::WrongPolicyId) )); let mut wrong_cookie_mode = sc; wrong_cookie_mode.cookie_mode = BlsScalar::from(2u64); assert!(matches!( - session.verify(wrong_cookie_mode, &policy), + session.verify(wrong_cookie_mode, &selected_policy), Err(CitadelError::WrongCookieMode) )); let mut wrong_session_id = sc; wrong_session_id.session_id = BlsScalar::from(2u64); assert!(matches!( - session.verify(wrong_session_id, &policy), + session.verify(wrong_session_id, &selected_policy), Err(CitadelError::WrongSessionId) )); let mut wrong_session_hash = sc; wrong_session_hash.r_session = BlsScalar::from(3u64); assert!(matches!( - session.verify(wrong_session_hash, &policy), + session.verify(wrong_session_hash, &selected_policy), Err(CitadelError::WrongSessionHash) )); @@ -323,7 +416,7 @@ fn session_verify_reports_each_cookie_opening_failure() { let mut wrong_sp = sc; wrong_sp.pk_sp = other_sp; assert!(matches!( - session.verify(wrong_sp, &policy), + session.verify(wrong_sp, &selected_policy), Err(CitadelError::WrongServiceProvider) )); @@ -331,35 +424,35 @@ fn session_verify_reports_each_cookie_opening_failure() { let mut wrong_lp = sc; wrong_lp.pk_lp = other_lp; assert!(matches!( - session.verify(wrong_lp, &policy), + session.verify(wrong_lp, &selected_policy), Err(CitadelError::WrongLicenseProvider) )); let mut wrong_lp_commitment = sc; wrong_lp_commitment.s_0 = BlsScalar::from(4u64); assert!(matches!( - session.verify(wrong_lp_commitment, &policy), + session.verify(wrong_lp_commitment, &selected_policy), Err(CitadelError::WrongLicenseProviderComm) )); let mut wrong_attr_commitment = sc; wrong_attr_commitment.attr_data = JubJubScalar::from(5u64); assert!(matches!( - session.verify(wrong_attr_commitment, &policy), + session.verify(wrong_attr_commitment, &selected_policy), Err(CitadelError::WrongAttributeDataComm) )); let mut wrong_challenge_commitment = sc; wrong_challenge_commitment.c = JubJubScalar::from(6u64); assert!(matches!( - session.verify(wrong_challenge_commitment, &policy), + session.verify(wrong_challenge_commitment, &selected_policy), Err(CitadelError::WrongChallenge) )); let mut wrong_challenge_opening = sc; wrong_challenge_opening.s_2 = JubJubScalar::from(19u64); assert!(matches!( - session.verify(wrong_challenge_opening, &policy), + session.verify(wrong_challenge_opening, &selected_policy), Err(CitadelError::WrongChallengeComm) )); } diff --git a/docs/specs.md b/docs/specs.md index 13276a5..add99f6 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -474,6 +474,8 @@ The base cookie reveals the openings needed by the SP. In the base mode, `attr_d The base session cookie is a bearer credential unless the selected SP profile adds binding. Anyone who obtains it can attempt to replay it to the SP. SPs MUST treat cookies as sensitive credentials and MUST define a replay policy before using Citadel for real service access. +Profile-defined binding data carried only in the cookie does not itself change this bearer-credential property. Account, channel, client-key, nonce, or request binding MUST be enforced through the committed challenge, another proof-bound session value, or previously established authenticated server state keyed by a proof-bound value. Comparing an expected value only with an uncommitted cookie field is insufficient. + The cookie or the surrounding authenticated request MUST identify the SP policy profile being used. The SP MUST NOT infer a policy profile from fields that could be valid under multiple profiles. ### 6.5 Selective-Disclosure Cookie diff --git a/wallet/README.md b/wallet/README.md index 12c4a3b..c3f7f25 100644 --- a/wallet/README.md +++ b/wallet/README.md @@ -47,8 +47,9 @@ Artifact defaults are relative to the current working directory: override with `--code` or `CITADEL_CONTRACT_WASM`. - `use-license` reads `target/prover`; override with `CITADEL_PROVER_PATH`. -Passwords can be provided with `--password` or `CITADEL_WALLET_PASSWORD`. -Without either, the CLI prompts when it needs to open the wallet. +Passwords can be provided with `CITADEL_WALLET_PASSWORD`. Without it, the CLI +prompts when it needs to open the wallet. The wallet intentionally does not +accept passwords through argv. Local Citadel state is stored next to the Rusk wallet in `citadel_wallet.dat` and `citadel_session_cookies.dat`. Both files are encrypted with AES-GCM using diff --git a/wallet/src/citadel.rs b/wallet/src/citadel.rs index 76b207b..6127087 100644 --- a/wallet/src/citadel.rs +++ b/wallet/src/citadel.rs @@ -10,7 +10,7 @@ //! them synchronized with `contract/src/license_types.rs` and the deployment //! constants checked in `validate_metadata`. -use std::{path::Path, str::FromStr}; +use std::{fs, path::Path, path::PathBuf, str::FromStr}; use anyhow::{Context, Result, anyhow}; use bytecheck::CheckBytes; @@ -20,21 +20,45 @@ use dusk_core::{JubJubAffine, JubJubScalar}; use dusk_plonk::prelude::Prover; use phoenix_core::{PublicKey, SecretKey}; use poseidon_merkle::Opening; +use rand::RngCore; use rand::rngs::OsRng; use rkyv::{Archive, Deserialize, Serialize}; use rusk_wallet::Address; -use sha2::{Digest, Sha512}; +use sha2::{Digest, Sha256, Sha512}; use zk_citadel::{ License, LicenseOptions, LicenseOrigin, Request, Session, SessionCookie, SessionPolicy, circuit, gadgets, helpers::{ DEFAULT_DEPLOYMENT, MERKLE_ARITY, OBJECT_VERSION_V1, PUBLIC_INPUTS_LEN, - attr_data_from_canonical_attributes, + attr_data_from_canonical_attributes, public_key_is_valid, }, }; const ROOT_HISTORY_SIZE: u32 = 8; const MAX_LICENSE_BLOB_SIZE: u32 = 4096; +const WALLET_TEXT_ATTRIBUTE_SCHEMA_ID: u64 = 1; +const DEFAULT_VERIFIER_PATH: &str = "target/verifier"; +const VERIFIER_PATH_ENV: &str = "CITADEL_VERIFIER_PATH"; +const CIRCUIT_SOURCE_FINGERPRINT_DOMAIN: &[u8] = b"zk-citadel-license-circuit-source-fingerprint"; +const CIRCUIT_SOURCE_FILES: &[(&str, &str)] = &[ + ("core/Cargo.toml", include_str!("../../core/Cargo.toml")), + ( + "core/src/helpers.rs", + include_str!("../../core/src/helpers.rs"), + ), + ( + "core/src/signatures.rs", + include_str!("../../core/src/signatures.rs"), + ), + ( + "core/src/zk/circuit.rs", + include_str!("../../core/src/zk/circuit.rs"), + ), + ( + "core/src/zk/gadgets.rs", + include_str!("../../core/src/zk/gadgets.rs"), + ), +]; /// Serialized argument passed to the contract's `issue_license` method. #[derive(Debug, Clone, PartialEq, Archive, Serialize, Deserialize)] @@ -183,10 +207,14 @@ pub fn decode_session(bytes: &[u8]) -> Result> { pub fn parse_shielded_address(address: &str) -> Result { let address = Address::from_str(address.trim()).map_err(|error| anyhow!("{error:?}"))?; - address + let public_key = address .shielded_key() .copied() - .map_err(|_| anyhow!("expected a shielded address, got a public account address")) + .map_err(|_| anyhow!("expected a shielded address, got a public account address"))?; + if !public_key_is_valid(&public_key) { + anyhow::bail!("shielded address contains an invalid public key"); + } + Ok(public_key) } pub fn license_request( @@ -215,38 +243,44 @@ pub fn issue_license_arg( attributes: &str, recipient: PublicKey, issuer: &SecretKey, -) -> Result { - let attr_data = attribute_scalar(attributes); +) -> Result<(IssueLicenseArg, JubJubScalar)> { + let (schema_id, attr_data) = attribute_scalar(attributes); let license = License::new( &attr_data, issuer, &LicenseOrigin::FromPublicKey(Box::new(recipient)), - LicenseOptions::default(), + LicenseOptions { + schema_id, + ..LicenseOptions::default() + }, &mut OsRng, ) .map_err(|error| anyhow!("{error}"))?; - issue_arg_from_license(&license) + Ok((issue_arg_from_license(&license)?, attr_data)) } pub fn issue_license_from_request_arg( attributes: &str, request_blob: &[u8], issuer: &SecretKey, -) -> Result<(IssueLicenseArg, BlsScalar)> { +) -> Result<(IssueLicenseArg, BlsScalar, JubJubScalar)> { let request: Request = rkyv::from_bytes(request_blob).map_err(|_| anyhow!("failed to decode request"))?; let request_id = request.id(); - let attr_data = attribute_scalar(attributes); + let (schema_id, attr_data) = attribute_scalar(attributes); let license = License::new( &attr_data, issuer, &LicenseOrigin::FromRequest(Box::new(request)), - LicenseOptions::default(), + LicenseOptions { + schema_id, + ..LicenseOptions::default() + }, &mut OsRng, ) .map_err(|error| anyhow!("{error}"))?; - Ok((issue_arg_from_license(&license)?, request_id)) + Ok((issue_arg_from_license(&license)?, request_id, attr_data)) } fn issue_arg_from_license(license: &License) -> Result { @@ -290,10 +324,6 @@ pub fn public_key_hex(public_key: &PublicKey) -> String { hex::encode(public_key.to_bytes()) } -pub fn attribute_scalar_hex(attributes: &str) -> String { - hex::encode(attribute_scalar(attributes).to_bytes()) -} - pub fn owned_license( position: u64, license_blob: &[u8], @@ -406,6 +436,22 @@ pub fn validate_metadata(metadata: &DeploymentMetadata) -> Result<()> { "contract metadata does not match local deployment parameters" )); } + + let expected_circuit_hash = expected_circuit_hash(); + if metadata.circuit_hash != expected_circuit_hash { + return Err(anyhow!( + "contract circuit hash does not match this wallet build" + )); + } + + let expected_verifier_key_hash = expected_verifier_key_hash()?; + if metadata.verifier_key_hash != expected_verifier_key_hash { + return Err(anyhow!( + "contract verifier key hash does not match {}", + verifier_path().display() + )); + } + Ok(()) } @@ -426,13 +472,15 @@ pub fn verify_session_cookie( chain_session: &LicenseSession, expected_challenge: JubJubScalar, expected_service_provider: PublicKey, + expected_policy_id: BlsScalar, + expected_license_provider: PublicKey, ) -> Result { let session = Session::from(&chain_session.public_inputs) .map_err(|error| anyhow!("failed to parse session public inputs: {error}"))?; let policy = SessionPolicy::new( - cookie.policy_id, + expected_policy_id, expected_service_provider, - cookie.pk_lp, + expected_license_provider, expected_challenge, ); @@ -491,6 +539,15 @@ pub fn parse_bls_scalar_hex(value: &str, label: &str) -> Result { .ok_or_else(|| anyhow!("{label} is not a canonical scalar")) } +pub fn parse_public_key_hex(value: &str, label: &str) -> Result { + let bytes = decode_fixed_hex::<{ PublicKey::SIZE }>(value, label)?; + let public_key = PublicKey::from_bytes(&bytes).map_err(|_| anyhow!("{label} is invalid"))?; + if !public_key_is_valid(&public_key) { + anyhow::bail!("{label} is not a valid non-identity subgroup public key"); + } + Ok(public_key) +} + pub fn encode_challenge(challenge: &str) -> Result { let challenge = challenge.trim(); if challenge.is_empty() { @@ -511,15 +568,74 @@ fn decode_fixed_hex(value: &str, label: &str) -> Result<[u8; N]> .map_err(|_| anyhow!("{label} must be {N} bytes of hex")) } -fn attribute_scalar(attributes: &str) -> JubJubScalar { - attr_data_from_canonical_attributes( - DEFAULT_DEPLOYMENT, - LicenseOptions::default().schema_id, - attributes.as_bytes(), - JubJubScalar::from(0u64), +fn attribute_scalar(attributes: &str) -> (BlsScalar, JubJubScalar) { + let schema_id = BlsScalar::from(WALLET_TEXT_ATTRIBUTE_SCHEMA_ID); + let mut wide = [0u8; 64]; + OsRng.fill_bytes(&mut wide); + let r_attr = JubJubScalar::from_bytes_wide(&wide); + ( + schema_id, + attr_data_from_canonical_attributes( + DEFAULT_DEPLOYMENT, + schema_id, + attributes.as_bytes(), + r_attr, + ), ) } +fn verifier_path() -> PathBuf { + std::env::var_os(VERIFIER_PATH_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_VERIFIER_PATH)) +} + +fn expected_verifier_key_hash() -> Result { + let path = verifier_path(); + let verifier = fs::read(&path).map_err(|error| { + anyhow!( + "failed to read {} for verifier-key hash pinning; build the contract with `make contract` or set {VERIFIER_PATH_ENV}: {error}", + path.display() + ) + })?; + Ok(scalar_from_sha256( + b"CITADEL_VERIFIER_KEY_HASH_V1", + &verifier, + )) +} + +fn expected_circuit_hash() -> BlsScalar { + let mut hasher = Sha256::new(); + hasher.update(CIRCUIT_SOURCE_FINGERPRINT_DOMAIN); + + for (canonical_path, source) in CIRCUIT_SOURCE_FILES { + hasher.update((*canonical_path).as_bytes()); + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source.as_bytes()); + } + + let circuit_build_id = format!("{:x}", hasher.finalize()); + scalar_from_sha256(b"CITADEL_CIRCUIT_HASH_V1", circuit_build_id.as_bytes()) +} + +fn scalar_from_sha256(domain: &[u8], bytes: &[u8]) -> BlsScalar { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + let digest = hasher.finalize(); + + let mut scalar_bytes = [0u8; 32]; + scalar_bytes[..31].copy_from_slice(&digest[..31]); + + let mut limbs = [0u64; 4]; + for (i, byte) in scalar_bytes.iter().enumerate() { + limbs[i / 8] |= (*byte as u64) << ((i % 8) * 8); + } + + BlsScalar::from_raw(limbs) +} + #[cfg(test)] mod tests { use super::*; @@ -596,8 +712,15 @@ mod tests { let cookie = cookie(challenge); let chain_session = chain_session(&cookie); - let verification = verify_session_cookie(cookie, &chain_session, challenge, cookie.pk_sp) - .expect("matching chain session and challenge should verify"); + let verification = verify_session_cookie( + cookie, + &chain_session, + challenge, + cookie.pk_sp, + cookie.policy_id, + cookie.pk_lp, + ) + .expect("matching chain session and challenge should verify"); assert_eq!(verification.session_id, cookie.session_id); assert_eq!(verification.session_root, BlsScalar::from(18u64)); @@ -614,7 +737,9 @@ mod tests { cookie, &chain_session, JubJubScalar::from(99u64), - cookie.pk_sp + cookie.pk_sp, + cookie.policy_id, + cookie.pk_lp, ) .is_err() ); @@ -627,6 +752,57 @@ mod tests { let chain_session = chain_session(&cookie); let other_sp = PublicKey::from(&SecretKey::random(&mut OsRng)); - assert!(verify_session_cookie(cookie, &chain_session, challenge, other_sp).is_err()); + assert!( + verify_session_cookie( + cookie, + &chain_session, + challenge, + other_sp, + cookie.policy_id, + cookie.pk_lp, + ) + .is_err() + ); + } + + #[test] + fn rejects_cookie_selected_policy_or_issuer() { + let challenge = JubJubScalar::from(14u64); + let cookie = cookie(challenge); + let chain_session = chain_session(&cookie); + let other_lp = PublicKey::from(&SecretKey::random(&mut OsRng)); + + assert!( + verify_session_cookie( + cookie, + &chain_session, + challenge, + cookie.pk_sp, + BlsScalar::from(99u64), + cookie.pk_lp, + ) + .is_err() + ); + assert!( + verify_session_cookie( + cookie, + &chain_session, + challenge, + cookie.pk_sp, + cookie.policy_id, + other_lp, + ) + .is_err() + ); + } + + #[test] + fn attribute_scalar_uses_schema_and_fresh_blinding() { + let (schema_id, first) = attribute_scalar("tier=academic"); + let (second_schema_id, second) = attribute_scalar("tier=academic"); + + assert_eq!(schema_id, BlsScalar::from(WALLET_TEXT_ATTRIBUTE_SCHEMA_ID)); + assert_eq!(second_schema_id, schema_id); + assert_ne!(first, second); } } diff --git a/wallet/src/cli.rs b/wallet/src/cli.rs index c2c047b..f3c5678 100644 --- a/wallet/src/cli.rs +++ b/wallet/src/cli.rs @@ -26,10 +26,6 @@ pub struct Cli { #[arg(long, value_name = "PATH", default_value_os_t = default_wallet_dir())] pub wallet_dir: PathBuf, - /// Wallet password. Prefer CITADEL_WALLET_PASSWORD in CI. - #[arg(long, env = "CITADEL_WALLET_PASSWORD")] - pub password: Option, - /// Rusk state node URL. #[arg(long, default_value = DEFAULT_STATE_URL)] pub state: String, @@ -211,6 +207,14 @@ pub struct VerifySessionCookieArgs { #[arg(long, value_name = "HEX")] pub session_cookie: String, + /// Expected policy ID as a canonical 32-byte scalar hex value. + #[arg(long, value_name = "HEX")] + pub policy_id: String, + + /// Trusted License Provider public key as 64 bytes of hex. + #[arg(long, value_name = "HEX")] + pub license_provider: String, + /// Expected SP challenge text. Encoded the same way as use-license. #[arg(long, value_name = "TEXT")] pub challenge: String, diff --git a/wallet/src/dusk/mod.rs b/wallet/src/dusk/mod.rs index ec41df0..7f716a8 100644 --- a/wallet/src/dusk/mod.rs +++ b/wallet/src/dusk/mod.rs @@ -11,5 +11,5 @@ mod util; pub use query::{CitadelQuery, Dusk}; pub use rusk_lib::{ ContractDeploy, IssueLicense, ReceiveLicense, RuskWallet, RuskWalletConfig, UseLicense, - prompt_wallet_password, + configured_wallet_password, prompt_wallet_password, }; diff --git a/wallet/src/dusk/rusk_lib.rs b/wallet/src/dusk/rusk_lib.rs index c5d4570..3c52e4d 100644 --- a/wallet/src/dusk/rusk_lib.rs +++ b/wallet/src/dusk/rusk_lib.rs @@ -38,6 +38,8 @@ use wallet_core::{Seed, keys::derive_phoenix_sk}; use zeroize::Zeroize; use zeroize::Zeroizing; +const WALLET_PASSWORD_ENV: &str = "CITADEL_WALLET_PASSWORD"; + use crate::citadel::{self, IssueLicenseArg, OwnedLicense, UseLicenseArg}; use super::util::{decode_hex, normalize_contract_id}; @@ -439,8 +441,12 @@ impl RuskWallet { return Ok(password.clone()); } + if let Some(password) = configured_wallet_password() { + return Ok(Zeroizing::new(password)); + } + if !io::stdin().is_terminal() { - bail!("wallet password is required; pass --password or set CITADEL_WALLET_PASSWORD"); + bail!("wallet password is required; set CITADEL_WALLET_PASSWORD"); } prompt_wallet_password(None) @@ -516,6 +522,10 @@ pub fn prompt_wallet_password(config_password: Option<&String>) -> Result Option { + std::env::var(WALLET_PASSWORD_ENV).ok() +} + fn derive_wallet_key( file_version: FileVersion, password: &str, diff --git a/wallet/src/main.rs b/wallet/src/main.rs index cd234c7..dc74e89 100644 --- a/wallet/src/main.rs +++ b/wallet/src/main.rs @@ -33,7 +33,8 @@ async fn main() -> Result<()> { return tui::run(&cli).await; } - let wallet_password = dusk::prompt_wallet_password(cli.password.as_ref())?; + let configured_password = dusk::configured_wallet_password(); + let wallet_password = dusk::prompt_wallet_password(configured_password.as_ref())?; let storage_key = wallet(&cli, Some(&wallet_password)).citadel_storage_key()?; match command { @@ -100,7 +101,7 @@ async fn main() -> Result<()> { let contract_id = state.active_contract()?.to_string(); let request = citadel::parse_request_blob_hex(&args.request_blob)?; let issuer = wallet(&cli, Some(&wallet_password)).citadel_secret_key(PROFILE_IDX)?; - let (issue_arg, request_id) = + let (issue_arg, request_id, attr_data) = citadel::issue_license_from_request_arg(&args.attributes, &request, &issuer)?; let receipt = wallet(&cli, Some(&wallet_password)) .issue_license( @@ -121,17 +122,15 @@ async fn main() -> Result<()> { "issuer_public_key: {}", citadel::issuer_public_key_hex(&issuer) ); - println!( - "attribute_scalar: {}", - citadel::attribute_scalar_hex(&args.attributes) - ); + println!("attribute_scalar: {}", hex::encode(attr_data.to_bytes())); Ok(()) } Command::IssueLicense(args) => { let state = CitadelWalletState::load(&cli.wallet_dir, &storage_key)?; let issuer = wallet(&cli, Some(&wallet_password)).citadel_secret_key(PROFILE_IDX)?; let recipient = citadel::parse_shielded_address(&args.shielded_address)?; - let issue_arg = citadel::issue_license_arg(&args.attributes, recipient, &issuer)?; + let (issue_arg, attr_data) = + citadel::issue_license_arg(&args.attributes, recipient, &issuer)?; let receipt = wallet(&cli, Some(&wallet_password)) .issue_license( IssueLicense { @@ -150,10 +149,7 @@ async fn main() -> Result<()> { "issuer_public_key: {}", citadel::issuer_public_key_hex(&issuer) ); - println!( - "attribute_scalar: {}", - citadel::attribute_scalar_hex(&args.attributes) - ); + println!("attribute_scalar: {}", hex::encode(attr_data.to_bytes())); Ok(()) } Command::ListLicenses => { @@ -253,6 +249,9 @@ async fn main() -> Result<()> { let state = CitadelWalletState::load(&cli.wallet_dir, &storage_key)?; let contract_id = state.active_contract()?; let cookie = citadel::parse_session_cookie_hex(&args.session_cookie)?; + let expected_policy_id = citadel::parse_bls_scalar_hex(&args.policy_id, "policy ID")?; + let expected_license_provider = + citadel::parse_public_key_hex(&args.license_provider, "license provider")?; let expected_challenge = citadel::encode_challenge(&args.challenge)?; let service_provider = phoenix_core::PublicKey::from( &wallet(&cli, Some(&wallet_password)).citadel_secret_key(PROFILE_IDX)?, @@ -270,6 +269,8 @@ async fn main() -> Result<()> { &session, expected_challenge, service_provider, + expected_policy_id, + expected_license_provider, )?; for line in citadel::session_cookie_verification_lines(&verification) { println!("{line}"); @@ -369,11 +370,9 @@ fn session_cookie_lines(index: usize, record: &SessionCookieRecord) -> Vec>) -> RuskWallet { RuskWallet::new(RuskWalletConfig { wallet_dir: cli.wallet_dir.clone(), - password: password.cloned().or_else(|| { - cli.password - .as_ref() - .map(|password| Zeroizing::new(password.clone())) - }), + password: password + .cloned() + .or_else(|| dusk::configured_wallet_password().map(Zeroizing::new)), state: cli.state.clone(), prover: cli.prover.clone().unwrap_or_else(|| cli.state.clone()), archiver: cli.archiver.clone().unwrap_or_else(|| cli.state.clone()), diff --git a/wallet/src/tui.rs b/wallet/src/tui.rs index 80eea5f..3b5f1a3 100644 --- a/wallet/src/tui.rs +++ b/wallet/src/tui.rs @@ -41,7 +41,7 @@ use crate::{ }, dusk::{ CitadelQuery, ContractDeploy, Dusk, IssueLicense, ReceiveLicense, RuskWallet, - RuskWalletConfig, UseLicense, + RuskWalletConfig, UseLicense, configured_wallet_password, }, state::{CitadelWalletState, SessionCookieRecord, SessionCookieStore}, }; @@ -273,8 +273,8 @@ impl App { } pub async fn run(cli: &Cli) -> Result<()> { - let wallet_password = match &cli.password { - Some(password) => Some(Zeroizing::new(password.clone())), + let wallet_password = match configured_wallet_password() { + Some(password) => Some(Zeroizing::new(password)), None => Some(prompt_wallet_password()?), }; @@ -1144,6 +1144,16 @@ fn prompts_for(action: Action, wallet_state: &CitadelWalletState) -> Vec default: String::new(), required: true, }, + Prompt { + label: "expected policy ID hex", + default: String::new(), + required: true, + }, + Prompt { + label: "trusted license provider hex", + default: String::new(), + required: true, + }, Prompt { label: "expected challenge", default: String::new(), @@ -1226,7 +1236,7 @@ async fn execute_action( let attributes = answer(&answers, 1, "attributes")?; let contract_id = wallet_state.active_contract()?.to_string(); let issuer = wallet(cli, wallet_password).citadel_secret_key(PROFILE_IDX)?; - let (issue_arg, request_id) = + let (issue_arg, request_id, attr_data) = citadel::issue_license_from_request_arg(&attributes, &request, &issuer)?; let receipt = wallet(cli, wallet_password) .issue_license( @@ -1244,10 +1254,7 @@ async fn execute_action( Ok(vec![ format!("tx_hash: {}", receipt.tx_hash), format!("request_id: {}", hex::encode(request_id.to_bytes())), - format!( - "attribute_scalar: {}", - citadel::attribute_scalar_hex(&attributes) - ), + format!("attribute_scalar: {}", hex::encode(attr_data.to_bytes())), format!( "issuer_public_key: {}", citadel::issuer_public_key_hex(&issuer) @@ -1259,7 +1266,8 @@ async fn execute_action( let recipient = answer(&answers, 1, "recipient address")?; let issuer = wallet(cli, wallet_password).citadel_secret_key(PROFILE_IDX)?; let recipient_key = citadel::parse_shielded_address(&recipient)?; - let issue_arg = citadel::issue_license_arg(&attributes, recipient_key, &issuer)?; + let (issue_arg, attr_data) = + citadel::issue_license_arg(&attributes, recipient_key, &issuer)?; let receipt = wallet(cli, wallet_password) .issue_license( IssueLicense { @@ -1276,10 +1284,7 @@ async fn execute_action( Ok(vec![ format!("tx_hash: {}", receipt.tx_hash), format!("recipient_address: {recipient}"), - format!( - "attribute_scalar: {}", - citadel::attribute_scalar_hex(&attributes) - ), + format!("attribute_scalar: {}", hex::encode(attr_data.to_bytes())), format!( "issuer_public_key: {}", citadel::issuer_public_key_hex(&issuer) @@ -1410,7 +1415,13 @@ async fn execute_action( let cookie = citadel::parse_session_cookie_hex(&answer(&answers, 0, "session cookie")?)?; let expected_challenge = - citadel::encode_challenge(&answer(&answers, 1, "expected challenge")?)?; + citadel::encode_challenge(&answer(&answers, 3, "expected challenge")?)?; + let expected_policy_id = + citadel::parse_bls_scalar_hex(&answer(&answers, 1, "policy ID")?, "policy ID")?; + let expected_license_provider = citadel::parse_public_key_hex( + &answer(&answers, 2, "license provider")?, + "license provider", + )?; let service_provider = phoenix_core::PublicKey::from( &wallet(cli, wallet_password).citadel_secret_key(PROFILE_IDX)?, ); @@ -1428,6 +1439,8 @@ async fn execute_action( &session, expected_challenge, service_provider, + expected_policy_id, + expected_license_provider, )?; Ok(citadel::session_cookie_verification_lines(&verification)) } @@ -1527,11 +1540,9 @@ fn panel_block(title: &'static str) -> Block<'static> { fn wallet(cli: &Cli, password: Option<&Zeroizing>) -> RuskWallet { RuskWallet::new(RuskWalletConfig { wallet_dir: cli.wallet_dir.clone(), - password: password.cloned().or_else(|| { - cli.password - .as_ref() - .map(|password| Zeroizing::new(password.clone())) - }), + password: password + .cloned() + .or_else(|| configured_wallet_password().map(Zeroizing::new)), state: cli.state.clone(), prover: cli.prover.clone().unwrap_or_else(|| cli.state.clone()), archiver: cli.archiver.clone().unwrap_or_else(|| cli.state.clone()),