Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a6b2b26
add signed doc time based validation
Mr-Leshiy Apr 3, 2025
e02cbbd
fix spelling
Mr-Leshiy Apr 3, 2025
2c04810
wip
Mr-Leshiy Apr 3, 2025
9e0861b
Merge branch 'feat/doc-time-validation' into feat/signed-doc-signatur…
apskhem Apr 3, 2025
f36d423
feat(cat-gateway): Store `RegistrationChain` as a part of the RBAC to…
Mr-Leshiy Apr 4, 2025
db8d8e0
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 8, 2025
f295c10
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 8, 2025
4759931
feat(cat-gateway): Add signature validation layer to `PUT /v1/documen…
apskhem Apr 9, 2025
d6ecf5d
feat(cat-gateway): Disable Catalyst Signed Documents integration test…
Mr-Leshiy Apr 9, 2025
311346d
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 10, 2025
ae2d84a
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 10, 2025
b1a87f8
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 10, 2025
049c1f7
wip (#2246)
Mr-Leshiy Apr 10, 2025
75ffbeb
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 10, 2025
a5465c3
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 14, 2025
16f2174
feat(cat-gateway): Signed Documents signature validation test (#2269)
Mr-Leshiy Apr 16, 2025
22d7bd3
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 16, 2025
9834c42
Merge branch 'main' into feat/signed-doc-signature-validation
Mr-Leshiy Apr 16, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ impl SignedDocBody {
&self.doc_type
}

/// Returns the document authors.
pub(crate) fn authors(&self) -> &Vec<String> {
&self.authors
}

/// Returns the document metadata.
pub(crate) fn metadata(&self) -> Option<&serde_json::Value> {
self.metadata.as_ref()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@
use std::sync::Arc;

use anyhow::Context;
use cardano_blockchain_types::{Network, Point, Slot, TxnIndex};
use cardano_chain_follower::ChainFollower;
use catalyst_signed_doc::IdUri;
use futures::{TryFutureExt, TryStreamExt};
use rbac_registration::{cardano::cip509::Cip509, registration::cardano::RegistrationChain};
use scylla::{
prepared_statement::PreparedStatement, statement::Consistency,
transport::iterator::TypedRowStream, DeserializeRow, SerializeRow, Session,
};
use tracing::error;
use tracing::{error, warn};

use crate::db::{
index::{
Expand Down Expand Up @@ -63,3 +69,78 @@ impl Query {
.map_err(Into::into)
}
}

/// Returns a sorted list of all registrations for the given Catalyst ID from the
/// database.
async fn indexed_registrations(
session: &CassandraSession, catalyst_id: &IdUri,
) -> anyhow::Result<Vec<Query>> {
let mut result: Vec<_> = Query::execute(session, QueryParams {
catalyst_id: catalyst_id.clone().into(),
})
.and_then(|r| r.try_collect().map_err(Into::into))
.await?;

result.sort_by_key(|r| r.slot_no);
Ok(result)
}

/// Build a registration chain from the given indexed data.
pub(crate) async fn build_reg_chain(
session: &CassandraSession, catalyst_id: &IdUri, network: Network,
) -> anyhow::Result<Option<RegistrationChain>> {
let regs = indexed_registrations(session, catalyst_id).await?;
let mut regs_iter = regs.iter();
let Some(root) = regs_iter.next() else {
return Ok(None);
};

let root = registration(network, root.slot_no.into(), root.txn_index.into())
.await
.context("Failed to get root registration")?;
let mut chain = RegistrationChain::new(root).context("Invalid root registration")?;

for reg in regs_iter {
// We only store valid registrations in this table, so an error here indicates a bug in
// our indexing logic.
let cip509 = registration(network, reg.slot_no.into(), reg.txn_index.into())
.await
.with_context(|| {
format!(
"Invalid or missing registration at {:?} block {:?} transaction",
reg.slot_no, reg.txn_index,
)
})?;
match chain.update(cip509) {
Ok(c) => chain = c,
Err(e) => {
// This isn't a hard error because while the individual registration can be valid it
// can be invalid in the context of the whole registration chain.
warn!(
"Unable to apply registration from {:?} block {:?} txn index: {e:?}",
reg.slot_no, reg.txn_index
);
},
}
}

Ok(Some(chain))
}

/// A helper function to return a RBAC registration from the given block and slot.
async fn registration(network: Network, slot: Slot, txn_index: TxnIndex) -> anyhow::Result<Cip509> {
let point = Point::fuzzy(slot);
let block = ChainFollower::get_block(network, point)
.await
.context("Unable to get block")?
.data;
if block.point().slot_or_default() != slot {
// The `ChainFollower::get_block` function can return the next consecutive block if
// it cannot find the exact one. This shouldn't happen, but we need
// to check anyway.
return Err(anyhow::anyhow!("Unable to find exact block"));
}
Cip509::new(&block, txn_index, &[])
.context("Invalid RBAC registration")?
.context("No RBAC registration at this block and txn index")
}
4 changes: 4 additions & 0 deletions catalyst-gateway/bin/src/db/index/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ pub(crate) enum CassandraSessionError {
/// Error indicating that the session has already been set.
#[error("Session already set")]
SessionAlreadySet,
/// Should be used by the caller when it fails to acquire the initialized database
/// session.
#[error("Failed acquiring database session")]
FailedAcquiringSession,
}

/// All interaction with cassandra goes through this struct.
Expand Down
4 changes: 2 additions & 2 deletions catalyst-gateway/bin/src/service/api/cardano/rbac/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ impl Api {
/// No Authorization required, but Token permitted.
auth: NoneOrRBAC,
) -> registrations_get::AllResponses {
let auth_catalyst_id = auth.into();
registrations_get::endpoint(lookup, auth_catalyst_id).await
let token = auth.into();
registrations_get::endpoint(lookup, token).await
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ use crate::{
chain_info::ChainInfo, registration_chain::RbacRegistrationChain, response::Responses,
unprocessable_content::RbacUnprocessableContent,
},
common::types::{
cardano::query::cat_id_or_stake::CatIdOrStake, headers::retry_after::RetryAfterOption,
common::{
auth::rbac::token::CatalystRBACTokenV1,
types::{
cardano::query::cat_id_or_stake::CatIdOrStake,
headers::retry_after::RetryAfterOption,
},
},
},
};

/// Get RBAC registration endpoint.
pub(crate) async fn endpoint(
lookup: Option<CatIdOrStake>, auth_catalyst_id: Option<IdUri>,
lookup: Option<CatIdOrStake>, token: Option<CatalystRBACTokenV1>,
) -> AllResponses {
let Some(persistent_session) = CassandraSession::get(true) else {
let err = anyhow!("Failed to acquire persistent db session");
Expand Down Expand Up @@ -78,8 +82,8 @@ pub(crate) async fn endpoint(
}
},
None => {
match auth_catalyst_id {
Some(id) => id,
match token {
Some(token) => token.catalyst_id().clone(),
None => {
return Responses::UnprocessableContent(Json(RbacUnprocessableContent::new(
"Either lookup parameter or token must be provided",
Expand Down
143 changes: 143 additions & 0 deletions catalyst-gateway/bin/src/service/api/documents/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! A module for placing common structs, functions, and variables across the `document`
//! endpoint module not specified to a specific endpoint.
use std::collections::HashMap;

use catalyst_signed_doc::CatalystSignedDocument;
use rbac_registration::cardano::cip509::RoleNumber;

use super::templates::get_doc_static_template;
use crate::{
db::event::{error::NotFoundError, signed_docs::FullSignedDoc},
service::common::auth::rbac::token::CatalystRBACTokenV1,
settings::Settings,
};

/// Get document from the database
pub(crate) async fn get_document(
document_id: &uuid::Uuid, version: Option<&uuid::Uuid>,
) -> anyhow::Result<CatalystSignedDocument> {
// Find the doc in the static templates first
if let Some(doc) = get_doc_static_template(document_id) {
return Ok(doc);
}

// If doesn't exist in the static templates, try to find it in the database
let db_doc = FullSignedDoc::retrieve(document_id, version).await?;
db_doc.raw().try_into()
}

/// A struct which implements a
/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait
pub(crate) struct DocProvider;

impl catalyst_signed_doc::providers::CatalystSignedDocumentProvider for DocProvider {
async fn try_get_doc(
&self, doc_ref: &catalyst_signed_doc::DocumentRef,
) -> anyhow::Result<Option<CatalystSignedDocument>> {
let id = doc_ref.id.uuid();
let ver = doc_ref.ver.uuid();
match get_document(&id, Some(&ver)).await {
Ok(doc) => Ok(Some(doc)),
Err(err) if err.is::<NotFoundError>() => Ok(None),
Err(err) => Err(err),
}
}

fn future_threshold(&self) -> Option<std::time::Duration> {
let signed_doc_cfg = Settings::signed_doc_cfg();
Some(signed_doc_cfg.future_threshold())
}

fn past_threshold(&self) -> Option<std::time::Duration> {
let signed_doc_cfg = Settings::signed_doc_cfg();
Some(signed_doc_cfg.past_threshold())
}
}

// TODO: make the struct to support multi sigs validation
/// A struct which implements a
/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait
pub(crate) struct VerifyingKeyProvider(
HashMap<catalyst_signed_doc::IdUri, ed25519_dalek::VerifyingKey>,
);

impl catalyst_signed_doc::providers::VerifyingKeyProvider for VerifyingKeyProvider {
async fn try_get_key(
&self, kid: &catalyst_signed_doc::IdUri,
) -> anyhow::Result<Option<ed25519_dalek::VerifyingKey>> {
Ok(self.0.get(kid).copied())
}
}

impl VerifyingKeyProvider {
/// Attempts to construct an instance of `Self` by validating and resolving a list of
/// Catalyst Document KIDs against a provided RBAC token.
///
/// This method performs the following steps:
/// 1. Verifies that only a single KID is provided with a document (as multi-signature
/// is currently unsupported).
/// 2. Verifies that **all** provided KIDs match the Catalyst ID from the RBAC token.
/// 3. Verifies that each provided KID is actually a signing key.
/// 4. Extracts the role index and rotation from each KID.
/// 5. Retrieves the latest signing public key and rotation state associated with the
/// role for each KID from the registration chain.
/// 6. Verifies that each provided KID uses its latest rotation.
/// 7. Collects and returns a vector of tuples containing the KID, and its latest
/// signing key.
///
/// # Errors
///
/// Returns an `anyhow::Error` if:
/// - Any KID's short Catalyst ID does not match the one in the token.
/// - Indexed registration queries or chain building fail.
/// - The KID's role index and rotation parsing fails.
/// - The KID is not a singing key.
/// - The latest signing key for a required role cannot be found.
/// - The KID is not using the latest rotation.
pub(crate) async fn try_from_kids(
token: &mut CatalystRBACTokenV1, kids: &[catalyst_signed_doc::IdUri],
) -> anyhow::Result<Self> {
if kids.len() > 1 {
anyhow::bail!("Multi-signature document is currently unsupported");
}

if kids
.iter()
.any(|kid| kid.as_short_id() != token.catalyst_id().as_short_id())
{
anyhow::bail!("RBAC Token CatID does not match with the document KIDs");
}

let Some(reg_chain) = token.reg_chain().await? else {
anyhow::bail!("Failed to retrieve a registration from corresponding Catalyst ID");
};

let result = kids.iter().map(|kid| {
if !kid.is_signature_key() {
anyhow::bail!("Invalid KID {kid}: KID must be a signing key not an encryption key");
}

let (kid_role_index, kid_rotation) = kid.role_and_rotation();
let kid_role_index = RoleNumber::from(kid_role_index.to_string().parse::<u8>()?);
let kid_rotation = kid_rotation.to_string().parse::<usize>()?;

let (latest_pk, rotation) = reg_chain
.get_latest_signing_pk_for_role(&kid_role_index)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to get last signing key for the proposer role for {kid} Catalyst ID"
)
})?;

if rotation != kid_rotation {
anyhow::bail!("Invalid KID {kid}: KID's rotation ({kid_rotation}) is not the latest rotation ({rotation})");
}

Ok((kid.clone(), latest_pk))
})
.collect::<Result<_, _>>()?;

Ok(Self(result))
}
}
50 changes: 3 additions & 47 deletions catalyst-gateway/bin/src/service/api/documents/get_document.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
//! Implementation of the GET `/document` endpoint
use catalyst_signed_doc::CatalystSignedDocument;
use poem_openapi::ApiResponse;

use super::templates::get_doc_static_template;
use super::common;
use crate::{
db::event::{error::NotFoundError, signed_docs::FullSignedDoc},
db::event::error::NotFoundError,
service::common::{responses::WithErrorResponses, types::payload::cbor::Cbor},
settings::Settings,
};

/// Endpoint responses.
Expand All @@ -30,7 +28,7 @@ pub(crate) type AllResponses = WithErrorResponses<Responses>;

/// # GET `/document`
pub(crate) async fn endpoint(document_id: uuid::Uuid, version: Option<uuid::Uuid>) -> AllResponses {
match get_document(&document_id, version.as_ref()).await {
match common::get_document(&document_id, version.as_ref()).await {
Ok(doc) => {
match doc.try_into() {
Ok(doc_cbor_bytes) => Responses::Ok(Cbor(doc_cbor_bytes)).into(),
Expand All @@ -41,45 +39,3 @@ pub(crate) async fn endpoint(document_id: uuid::Uuid, version: Option<uuid::Uuid
Err(err) => AllResponses::handle_error(&err),
}
}

/// Get document from the database
pub(crate) async fn get_document(
document_id: &uuid::Uuid, version: Option<&uuid::Uuid>,
) -> anyhow::Result<CatalystSignedDocument> {
// Find the doc in the static templates first
if let Some(doc) = get_doc_static_template(document_id) {
return Ok(doc);
}

// If doesn't exist in the static templates, try to find it in the database
let db_doc = FullSignedDoc::retrieve(document_id, version).await?;
db_doc.raw().try_into()
}

/// A struct which implements a
/// `catalyst_signed_doc::providers::CatalystSignedDocumentProvider` trait
pub(crate) struct DocProvider;

impl catalyst_signed_doc::providers::CatalystSignedDocumentProvider for DocProvider {
async fn try_get_doc(
&self, doc_ref: &catalyst_signed_doc::DocumentRef,
) -> anyhow::Result<Option<CatalystSignedDocument>> {
let id = doc_ref.id.uuid();
let ver = doc_ref.ver.uuid();
match get_document(&id, Some(&ver)).await {
Ok(doc) => Ok(Some(doc)),
Err(err) if err.is::<NotFoundError>() => Ok(None),
Err(err) => Err(err),
}
}

fn future_threshold(&self) -> Option<std::time::Duration> {
let signed_doc_cfg = Settings::signed_doc_cfg();
Some(signed_doc_cfg.future_threshold())
}

fn past_threshold(&self) -> Option<std::time::Duration> {
let signed_doc_cfg = Settings::signed_doc_cfg();
Some(signed_doc_cfg.past_threshold())
}
}
Loading
Loading