diff --git a/rust/Earthfile b/rust/Earthfile index 2afaf65c99..04ec97997f 100644 --- a/rust/Earthfile +++ b/rust/Earthfile @@ -1,6 +1,6 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.2.23 AS rust-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.2.25 AS rust-ci COPY_SRC: FUNCTION diff --git a/rust/clippy.toml b/rust/clippy.toml index 0358cdb508..7bada5473b 100644 --- a/rust/clippy.toml +++ b/rust/clippy.toml @@ -1,2 +1,3 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true +allow-panic-in-tests = true diff --git a/rust/rbac-registration/Cargo.toml b/rust/rbac-registration/Cargo.toml index 9b1936d685..153c0a5392 100644 --- a/rust/rbac-registration/Cargo.toml +++ b/rust/rbac-registration/Cargo.toml @@ -21,13 +21,11 @@ workspace = true hex = "0.4.3" anyhow = "1.0.89" strum_macros = "0.26.4" -regex = "1.11.0" minicbor = { version = "0.25.1", features = ["alloc", "derive", "half"] } brotli = "7.0.0" zstd = "0.13.2" x509-cert = "0.2.5" der-parser = "9.0.0" -bech32 = "0.11.0" dashmap = "6.1.0" blake2b_simd = "1.0.2" tracing = "0.1.40" diff --git a/rust/rbac-registration/src/cardano/cip509/utils/cip134.rs b/rust/rbac-registration/src/cardano/cip509/utils/cip134.rs new file mode 100644 index 0000000000..d4be9cd883 --- /dev/null +++ b/rust/rbac-registration/src/cardano/cip509/utils/cip134.rs @@ -0,0 +1,184 @@ +//! Utility functions for CIP-0134 address. + +// Ignore URIs that are used in tests and doc-examples. +// cSpell:ignoreRegExp web\+cardano:.+ + +use std::fmt::{Display, Formatter}; + +use anyhow::{anyhow, Context, Result}; +use pallas::ledger::addresses::Address; + +/// An URI in the CIP-0134 format. +/// +/// See the [proposal] for more details. +/// +/// [proposal]: https://github.com/cardano-foundation/CIPs/pull/888 +#[derive(Debug)] +pub struct Cip0134Uri { + /// A URI string. + uri: String, + /// An address parsed from the URI. + address: Address, +} + +impl Cip0134Uri { + /// Creates a new `Cip0134Uri` instance by parsing the given URI. + /// + /// # Errors + /// - Invalid URI. + /// + /// # Examples + /// + /// ``` + /// use rbac_registration::cardano::cip509::utils::Cip0134Uri; + /// + /// let uri = "web+cardano://addr/stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"; + /// let cip0134_uri = Cip0134Uri::parse(uri).unwrap(); + /// ``` + pub fn parse(uri: &str) -> Result { + let bech32 = uri + .strip_prefix("web+cardano://addr/") + .ok_or_else(|| anyhow!("Missing schema part of URI"))?; + let address = Address::from_bech32(bech32).context("Unable to parse bech32 part of URI")?; + + Ok(Self { + uri: uri.to_owned(), + address, + }) + } + + /// Returns a URI string. + /// + /// # Examples + /// + /// ``` + /// use rbac_registration::cardano::cip509::utils::Cip0134Uri; + /// + /// let uri = "web+cardano://addr/stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"; + /// let cip0134_uri = Cip0134Uri::parse(uri).unwrap(); + /// assert_eq!(cip0134_uri.uri(), uri); + #[must_use] + pub fn uri(&self) -> &str { + &self.uri + } + + /// Returns a URI string. + /// + /// # Examples + /// + /// ``` + /// use pallas::ledger::addresses::{Address, Network}; + /// use rbac_registration::cardano::cip509::utils::Cip0134Uri; + /// + /// let uri = "web+cardano://addr/stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"; + /// let cip0134_uri = Cip0134Uri::parse(uri).unwrap(); + /// let Address::Stake(address) = cip0134_uri.address() else { + /// panic!("Unexpected address type"); + /// }; + /// assert_eq!(address.network(), Network::Mainnet); + #[must_use] + pub fn address(&self) -> &Address { + &self.address + } +} + +impl Display for Cip0134Uri { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.uri()) + } +} + +#[cfg(test)] +mod tests { + use pallas::ledger::addresses::{Address, Network}; + + use super::*; + + #[test] + fn invalid_prefix() { + // cSpell:disable + let test_uris = [ + "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", + "//addr/addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", + "web+cardano:/addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", + "somthing+unexpected://addr/addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", + ]; + // cSpell:enable + + for uri in test_uris { + let err = format!("{:?}", Cip0134Uri::parse(uri).expect_err(uri)); + assert!(err.starts_with("Missing schema part of URI")); + } + } + + #[test] + fn invalid_bech32() { + let uri = "web+cardano://addr/adr1qx2fxv2umyh"; + let err = format!("{:?}", Cip0134Uri::parse(uri).unwrap_err()); + assert!(err.starts_with("Unable to parse bech32 part of URI")); + } + + #[test] + fn stake_address() { + let test_data = [ + ( + "web+cardano://addr/stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn", + Network::Testnet, + "337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251", + ), + ( + "web+cardano://addr/stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw", + Network::Mainnet, + "337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251", + ), + ( + "web+cardano://addr/drep_vk17axh4sc9zwkpsft3tlgpjemfwc0u5mnld80r85zw7zdqcst6w54sdv4a4e", + Network::Other(7), + "4d7ac30513ac1825715fd0196769761fca6e7f69de33d04ef09a0c41", + ) + ]; + + for (uri, network, payload) in test_data { + let cip0134_uri = Cip0134Uri::parse(uri).expect(uri); + let Address::Stake(address) = cip0134_uri.address() else { + panic!("Unexpected address type ({uri})"); + }; + assert_eq!(network, address.network()); + assert_eq!(payload, address.payload().as_hash().to_string()); + } + } + + #[test] + fn shelley_address() { + let test_data = [ + ( + "web+cardano://addr/addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x", + Network::Mainnet, + ), + ( + "web+cardano://addr/addr_test1gz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer5pnz75xxcrdw5vky", + Network::Testnet, + ), + ( + "web+cardano://addr/cc_hot_vk10y48lq72hypxraew74lwjjn9e2dscuwphckglh2nrrpkgweqk5hschnzv5", + Network::Other(9), + ) + ]; + + for (uri, network) in test_data { + let cip0134_uri = Cip0134Uri::parse(uri).expect(uri); + let Address::Shelley(address) = cip0134_uri.address() else { + panic!("Unexpected address type ({uri})"); + }; + assert_eq!(network, address.network()); + } + } + + // The Display should return the original URI. + #[test] + fn display() { + let uri = "web+cardano://addr/stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"; + let cip0134_uri = Cip0134Uri::parse(uri).expect(uri); + assert_eq!(uri, cip0134_uri.to_string()); + } +} diff --git a/rust/rbac-registration/src/cardano/cip509/utils/cip19.rs b/rust/rbac-registration/src/cardano/cip509/utils/cip19.rs index 20f6fd6fd5..e7fe994c7d 100644 --- a/rust/rbac-registration/src/cardano/cip509/utils/cip19.rs +++ b/rust/rbac-registration/src/cardano/cip509/utils/cip19.rs @@ -1,39 +1,9 @@ //! Utility functions for CIP-19 address. use anyhow::bail; -use regex::Regex; use crate::cardano::transaction::witness::TxWitness; -/// Extracts the CIP-19 bytes from a URI. -/// Example input: `web+cardano://addr/` -/// -/// URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment] -#[must_use] -pub fn extract_cip19_hash(uri: &str, prefix: Option<&str>) -> Option> { - // Regex pattern to match the expected URI format - let r = Regex::new("^.+://addr/(.+)$").ok()?; - - // Apply the regex pattern to capture the CIP-19 address string - let address = r - .captures(uri) - .and_then(|cap| cap.get(1).map(|m| m.as_str().to_string())); - - match address { - Some(addr) => { - if let Some(prefix) = prefix { - if !addr.starts_with(prefix) { - return None; - } - } - let addr = bech32::decode(&addr).ok()?.1; - // As in CIP19, the first byte is the header, so extract only the payload - extract_key_hash(&addr) - }, - None => None, - } -} - /// Extract the first 28 bytes from the given key /// Refer to for more information. pub(crate) fn extract_key_hash(key: &[u8]) -> Option> { @@ -67,71 +37,3 @@ pub(crate) fn compare_key_hash( Ok(()) }) } - -#[cfg(test)] -mod tests { - use super::*; - - // Test data from https://cips.cardano.org/cip/CIP-19 - // cSpell:disable - const STAKE_ADDR: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; - const PAYMENT_ADDR: &str = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"; - // cSpell:enable - - #[test] - fn test_extract_cip19_hash_with_stake() { - // Additional tools to check for bech32 https://slowli.github.io/bech32-buffer/ - let uri = &format!("web+cardano://addr/{STAKE_ADDR}"); - // Given: - // e0337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251 - // The first byte is the header, so extract only the payload - let bytes = hex::decode("337b62cfff6403a06a3acbc34f8c46003c69fe79a3628cefa9c47251") - .expect("Failed to decode bytes"); - assert_eq!( - extract_cip19_hash(uri, Some("stake")).expect("Failed to extract CIP-19 hash"), - bytes - ); - } - - #[test] - fn test_extract_cip19_hash_with_addr_with_prefix_set() { - let uri = &format!("web+cardano://addr/{PAYMENT_ADDR}"); - let result = extract_cip19_hash(uri, Some("stake")); - assert_eq!(result, None); - } - - #[test] - fn test_extract_cip19_hash_with_addr_without_prefix_set() { - let uri = &format!("web+cardano://addr/{PAYMENT_ADDR}"); - let result = extract_cip19_hash(uri, None); - assert!(result.is_some()); - } - - #[test] - fn test_extract_cip19_hash_invalid_uri() { - let uri = "invalid_uri"; - let result = extract_cip19_hash(uri, None); - assert_eq!(result, None); - } - - #[test] - fn test_extract_cip19_hash_non_bech32_address() { - let uri = "example://addr/not_bech32"; - let result = extract_cip19_hash(uri, None); - assert_eq!(result, None); - } - - #[test] - fn test_extract_cip19_hash_empty_uri() { - let uri = ""; - let result = extract_cip19_hash(uri, None); - assert_eq!(result, None); - } - - #[test] - fn test_extract_cip19_hash_no_address() { - let uri = "example://addr/"; - let result = extract_cip19_hash(uri, None); - assert_eq!(result, None); - } -} diff --git a/rust/rbac-registration/src/cardano/cip509/utils/mod.rs b/rust/rbac-registration/src/cardano/cip509/utils/mod.rs index 54e683f166..ab7c3954d6 100644 --- a/rust/rbac-registration/src/cardano/cip509/utils/mod.rs +++ b/rust/rbac-registration/src/cardano/cip509/utils/mod.rs @@ -1,3 +1,6 @@ //! Utility functions for CIP-509 pub mod cip19; +pub use cip134::Cip0134Uri; + +mod cip134; diff --git a/rust/rbac-registration/src/cardano/cip509/validation.rs b/rust/rbac-registration/src/cardano/cip509/validation.rs index d549c4d452..205957f546 100644 --- a/rust/rbac-registration/src/cardano/cip509/validation.rs +++ b/rust/rbac-registration/src/cardano/cip509/validation.rs @@ -28,7 +28,7 @@ use pallas::{ minicbor::{Encode, Encoder}, utils::Bytes, }, - ledger::traverse::MultiEraTx, + ledger::{addresses::Address, traverse::MultiEraTx}, }; use x509_cert::der::{oid::db::rfc5912::ID_CE_SUBJECT_ALT_NAME, Decode}; @@ -38,7 +38,10 @@ use super::{ certs::{C509Cert, X509DerCert}, role_data::{LocalRefInt, RoleData}, }, - utils::cip19::{compare_key_hash, extract_cip19_hash, extract_key_hash}, + utils::{ + cip19::{compare_key_hash, extract_key_hash}, + Cip0134Uri, + }, Cip509, TxInputHash, TxWitness, }; use crate::utils::general::zero_out_last_n_bytes; @@ -166,10 +169,12 @@ pub(crate) fn validate_stake_public_key( // Extract the CIP19 hash and push into // array - if let Some(h) = - extract_cip19_hash(&addr, Some("stake")) - { - pk_addrs.push(h); + if let Ok(uri) = Cip0134Uri::parse(&addr) { + if let Address::Stake(a) = uri.address() { + pk_addrs.push( + a.payload().as_hash().to_vec(), + ); + } } }, Err(e) => { @@ -218,9 +223,11 @@ pub(crate) fn validate_stake_public_key( if name.gn_type() == &c509_certificate::general_names::general_name::GeneralNameTypeRegistry::UniformResourceIdentifier { match name.gn_value() { GeneralNameValue::Text(s) => { - if let Some(h) = extract_cip19_hash(s, Some("stake")) { - pk_addrs.push(h); + if let Ok(uri) = Cip0134Uri::parse(s) { + if let Address::Stake(a) = uri.address() { + pk_addrs.push(a.payload().as_hash().to_vec()); } + } }, _ => { validation_report.push( diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index b0f20832c9..905bde2d0b 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -36,7 +36,7 @@ max_width = 100 # Comments: normalize_comments = true -normalize_doc_attributes = true +normalize_doc_attributes = false wrap_comments = true comment_width = 90 # small excess is okay but prefer 80 format_code_in_doc_comments = true