Skip to content

Commit

Permalink
fix(rust/rbac-registration): Fix extraction of Cardano Addresses from…
Browse files Browse the repository at this point in the history
… a cardano address URI string (#102)

* Fix extraction of Cardano Addresses from a cardano address URI string

* Introduce Cip0134Uri type

* Update Clippy and rustfmt configs
  • Loading branch information
stanislav-tkach authored Dec 13, 2024
1 parent 424cc0a commit 0aeadb9
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 110 deletions.
2 changes: 1 addition & 1 deletion rust/Earthfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/clippy.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
2 changes: 0 additions & 2 deletions rust/rbac-registration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
184 changes: 184 additions & 0 deletions rust/rbac-registration/src/cardano/cip509/utils/cip134.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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());
}
}
98 changes: 0 additions & 98 deletions rust/rbac-registration/src/cardano/cip509/utils/cip19.rs
Original file line number Diff line number Diff line change
@@ -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/<cip-19 address string>`
/// <https://github.com/cardano-foundation/CIPs/tree/6bae5165dde5d803778efa5e93bd408f3317ca03/CPS-0016>
/// URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
#[must_use]
pub fn extract_cip19_hash(uri: &str, prefix: Option<&str>) -> Option<Vec<u8>> {
// 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 <https://cips.cardano.org/cip/CIP-19> for more information.
pub(crate) fn extract_key_hash(key: &[u8]) -> Option<Vec<u8>> {
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions rust/rbac-registration/src/cardano/cip509/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
//! Utility functions for CIP-509
pub mod cip19;
pub use cip134::Cip0134Uri;

mod cip134;
23 changes: 15 additions & 8 deletions rust/rbac-registration/src/cardano/cip509/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion rust/rustfmt.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 0aeadb9

Please sign in to comment.