Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bfba2ef
fix(rs-dapi): decode base64 CBOR message in Tenderdash error data field
lklimek Mar 16, 2026
7ae8846
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek Mar 18, 2026
25de8d2
fix(rs-dapi): address PR review comments on decode_data_message
lklimek Mar 18, 2026
1b38d74
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek Mar 20, 2026
91daf4b
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek Mar 20, 2026
b18c173
feat(sdk): detect duplicate identity key errors from drive-error-data…
lklimek Mar 20, 2026
5e99219
Revert "feat(sdk): detect duplicate identity key errors from drive-er…
lklimek Mar 20, 2026
c27a959
feat(sdk): add DriveInternalError variant and decode drive-error-data…
lklimek Mar 20, 2026
a9a9fb9
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek Mar 23, 2026
e32635f
fix(sdk): log drive-error-data-bin to_bytes() failures instead of sil…
lklimek Mar 23, 2026
8055ce2
Merge remote-tracking branch 'origin/v3.1-dev' into fix/sdk-cbor-secu…
lklimek Apr 13, 2026
5581447
test(sdk): add unit tests for drive-error-data-bin DriveInternalError…
lklimek Apr 13, 2026
f7833a2
fix(wasm-sdk): handle DriveInternalError variant in error mapping
lklimek Apr 13, 2026
d855555
feat(sdk): auto-detect protocol version from network response metadata
lklimek Apr 13, 2026
6928fdd
test(sdk): add TC-6 concurrent updates and TC-7 global DPP version sy…
lklimek Apr 13, 2026
a9fbe7c
fix(sdk-ffi): map DriveInternalError to dedicated FFI error code
lklimek Apr 13, 2026
3445940
fix(sdk): fix TC-7 test race and formatting
lklimek Apr 13, 2026
8c85455
Revert "fix(sdk): fix TC-7 test race and formatting"
lklimek Apr 13, 2026
32b661c
Revert "test(sdk): add TC-6 concurrent updates and TC-7 global DPP ve…
lklimek Apr 13, 2026
60c4d32
Revert "feat(sdk): auto-detect protocol version from network response…
lklimek Apr 13, 2026
86fbeea
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek Apr 15, 2026
474cfa5
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek May 5, 2026
b64ec6b
fix(rs-sdk-ffi): match DriveInternalError variant before substring he…
lklimek May 5, 2026
177d23c
fix(rs-sdk,rs-dapi): cap ciborium input size to 64 KiB before decode
lklimek May 5, 2026
3a4fc87
fix(rs-dapi): cap CBOR size, share base64_decode helper, tighten uniq…
lklimek May 5, 2026
8baeb63
Merge branch 'v3.1-dev' into fix/sdk-cbor-security-hardening
lklimek May 6, 2026
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
246 changes: 235 additions & 11 deletions packages/rs-dapi/src/services/platform_service/error_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,23 @@ impl Debug for TenderdashStatus {
}
}

/// Hard cap on the length of attacker-influenceable CBOR payloads we accept
/// before decoding Tenderdash error data.
///
/// Tenderdash responses are bounded by the upstream HTTP client, but no
/// explicit size cap exists at this layer; 64 KiB is comfortably above any
/// legitimate payload while preventing pathological CBOR from forcing
/// unbounded recursion on the server side.
///
/// TODO(CMT-004): `ciborium` does not yet expose a depth-limited reader; once
/// upstream offers one, swap the size cap for a structural depth cap.
const MAX_CBOR_INPUT_SIZE: usize = 65_536;

/// Decode a potentially unpadded base64 string used by Tenderdash error payloads.
///
/// This is a pure decoder: callers decide whether and at what level to log a
/// failure (some treat decode failure as the expected fall-through path, others
/// want to surface the decoder error).
pub(crate) fn base64_decode(input: &str) -> Option<Vec<u8>> {
static BASE64: engine::GeneralPurpose = {
let b64_config = engine::GeneralPurposeConfig::new()
Expand All @@ -185,15 +201,17 @@ pub(crate) fn base64_decode(input: &str) -> Option<Vec<u8>> {

engine::GeneralPurpose::new(&base64::alphabet::STANDARD, b64_config)
};
BASE64
.decode(input)
.inspect_err(|e| {
tracing::debug!("Failed to decode base64: {}", e);
})
.ok()
BASE64.decode(input).ok()
}

/// Walk a nested CBOR map by following the provided key path.
///
/// INTENTIONAL(CMT-007): a single-key form of this walk is duplicated as
/// `extract_drive_error_message` at packages/rs-sdk/src/error.rs. The two
/// crates have different dependency surfaces; extracting a shared helper would
/// require a new shared crate solely for this lookup. If you change the
/// wire-format expectations here, MIRROR the change at the rs-sdk site so both
/// sides of the boundary stay in sync.
fn walk_cbor_for_key<'a>(data: &'a ciborium::Value, keys: &[&str]) -> Option<&'a ciborium::Value> {
if keys.is_empty() {
tracing::trace!(?data, "found value, returning");
Expand Down Expand Up @@ -226,7 +244,18 @@ pub(super) fn decode_consensus_error(info_base64: String) -> Option<Vec<u8>> {
use ciborium::value::Value;

tracing::trace!(?info_base64, "decode_consensus_error: received info");
let decoded_bytes = base64_decode(&info_base64)?;
let decoded_bytes = base64_decode(&info_base64).or_else(|| {
tracing::debug!("Failed to base64-decode consensus error info");
None
})?;
if decoded_bytes.len() > MAX_CBOR_INPUT_SIZE {
tracing::debug!(
len = decoded_bytes.len(),
max = MAX_CBOR_INPUT_SIZE,
"consensus error info exceeds CBOR size cap; refusing to decode"
);
return None;
}
tracing::trace!(hex = %hex::encode(&decoded_bytes), len = decoded_bytes.len(), "decode_consensus_error: base64 decoded bytes");
// CBOR-decode decoded_bytes
let raw_value: Value = ciborium::de::from_reader(decoded_bytes.as_slice())
Expand Down Expand Up @@ -273,6 +302,30 @@ pub(super) fn decode_consensus_error(info_base64: String) -> Option<Vec<u8>> {
Some(serialized_error)
}

/// Try to decode a Tenderdash `data` field as base64 → CBOR and extract the
/// human-readable `message` text. Returns `None` if the string is not
/// base64-encoded CBOR or does not contain a `message` key, allowing the
/// caller to fall back to the raw string.
fn decode_data_message(data: &str) -> Option<String> {
// Failure of either step is the expected fall-through for plain-text data
// fields, so we deliberately do not log here — `base64_decode` is a pure
// decoder and CBOR failure on plain text is normal.
let decoded_bytes = base64_decode(data)?;

Comment thread
lklimek marked this conversation as resolved.
if decoded_bytes.len() > MAX_CBOR_INPUT_SIZE {
tracing::debug!(
len = decoded_bytes.len(),
max = MAX_CBOR_INPUT_SIZE,
"data field exceeds CBOR size cap; refusing to decode"
);
return None;
}

let raw_value: ciborium::Value = ciborium::de::from_reader(decoded_bytes.as_slice()).ok()?;

walk_cbor_for_key(&raw_value, &["message"]).and_then(|v| v.as_text().map(|s| s.to_string()))
}
Comment thread
lklimek marked this conversation as resolved.

impl From<serde_json::Value> for TenderdashStatus {
// Convert from a JSON error object returned by Tenderdash RPC, typically in the `error` field of a JSON-RPC response.
fn from(value: serde_json::Value) -> Self {
Expand All @@ -295,11 +348,10 @@ impl From<serde_json::Value> for TenderdashStatus {
object
.get("data")
.and_then(|d| d.as_str())
.filter(|s| s.is_ascii())
.map(|s| decode_data_message(s).unwrap_or_else(|| s.to_string()))
} else {
raw_message
}
.map(|s| s.to_string());
raw_message.map(|s| s.to_string())
};

// info contains additional error details, possibly including consensus error
let consensus_error = object
Expand Down Expand Up @@ -678,4 +730,176 @@ mod tests {
// "tx already exists in cache" maps to AlreadyExists, which maps to already_exists
assert_eq!(tonic_status.code(), tonic::Code::AlreadyExists);
}

// -- decode_data_message tests --

#[test]
fn decode_data_message_plain_text_returns_none() {
// Plain text that is not base64 CBOR → returns None so the caller
// can fall back to using the raw string.
assert!(super::decode_data_message("just plain text").is_none());
}

#[test]
fn decode_data_message_base64_cbor_with_message() {
// CBOR: {"message": "hello world"}
let cbor_bytes = {
let mut buf = Vec::new();
ciborium::ser::into_writer(
&ciborium::Value::Map(vec![(
ciborium::Value::Text("message".to_string()),
ciborium::Value::Text("hello world".to_string()),
)]),
&mut buf,
)
.unwrap();
buf
};
let b64 = base64::prelude::BASE64_STANDARD.encode(&cbor_bytes);
assert_eq!(
super::decode_data_message(&b64),
Some("hello world".to_string())
);
}

#[test]
fn decode_data_message_base64_cbor_without_message_key() {
// CBOR: {"data": {"serializedError": [1, 2, 3]}} — no "message" key
let cbor_bytes = {
let mut buf = Vec::new();
ciborium::ser::into_writer(
&ciborium::Value::Map(vec![(
ciborium::Value::Text("data".to_string()),
ciborium::Value::Map(vec![(
ciborium::Value::Text("serializedError".to_string()),
ciborium::Value::Array(vec![
ciborium::Value::Integer(1.into()),
ciborium::Value::Integer(2.into()),
ciborium::Value::Integer(3.into()),
]),
)]),
)]),
&mut buf,
)
.unwrap();
buf
};
let b64 = base64::prelude::BASE64_STANDARD.encode(&cbor_bytes);
assert!(super::decode_data_message(&b64).is_none());
}

// -- Real-world DET log fixture tests --

#[test]
fn from_json_value_decodes_cbor_data_field_non_unique_key() {
setup_tracing();
// Real fixture from DET logs: code 13 Internal, data is base64 CBOR
// containing {"message": "storage: identity: a unique key ... non unique set [...]"}
let data_b64 = concat!(
"oWdtZXNzYWdleMVzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IHdpdGggdGhh",
"dCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVhZHkgZXhpc3RzIGluIHRo",
"ZSBub24gdW5pcXVlIHNldCBbMTM1LCAyMDIsIDE3MiwgNTMsIDE3NiwgNDUsIDE5MSwg",
"MjcsIDUwLCAxMiwgNTAsIDIxNSwgNjUsIDEyNCwgMTQ3LCAzLCAyMDgsIDYsIDIyNiwg",
"MTUxXQ==",
);

let value = serde_json::json!({
"code": 13,
"message": "Internal error",
"data": data_b64,
"info": ""
});

let status = TenderdashStatus::from(value);
assert_eq!(status.code, 13);
assert!(
status
.message
.as_deref()
.expect("message should be decoded")
.starts_with("storage: identity: a unique key with that hash already exists"),
"expected decoded message, got: {:?}",
status.message
);
assert!(
status
.message
.as_deref()
.unwrap()
.contains("non unique set"),
);
assert!(status.consensus_error.is_none());
}

#[test]
fn from_json_value_decodes_cbor_data_field_unique_key() {
setup_tracing();
// Real fixture from DET logs: code 13 Internal, "unique set" variant
let data_b64 = concat!(
"oWdtZXNzYWdleMdzdG9yYWdlOiBpZGVudGl0eTogYSB1bmlxdWUga2V5IHdpdGggdGhh",
"dCBoYXNoIGFscmVhZHkgZXhpc3RzOiB0aGUga2V5IGFscmVhZHkgZXhpc3RzIGluIHRo",
"ZSB1bmlxdWUgc2V0IFsyMzIsIDQ4LCAxMTksIDEzNywgMTYxLCAxNDMsIDE1LCAxNzks",
"IDIzNSwgOTgsIDEwMSwgMjUxLCAyNTEsIDExMCwgMTMyLCAzNSwgMTE5LCA4NCwgMTQ3",
"LCAxMjRd",
);

let value = serde_json::json!({
"code": 13,
"message": "Internal error",
"data": data_b64,
"info": ""
});

let status = TenderdashStatus::from(value);
assert_eq!(status.code, 13);
assert!(
status
.message
.as_deref()
.expect("message should be decoded")
.starts_with("storage: identity: a unique key with that hash already exists"),
"expected decoded message, got: {:?}",
status.message
);
// Use the more specific phrasing so a swapped fixture wouldn't pass —
// "unique set" alone also matches "non unique set" from the paired test.
assert!(
status
.message
.as_deref()
.unwrap()
.contains("in the unique set"),
);
assert!(status.consensus_error.is_none());
}

#[test]
fn from_json_value_preserves_plain_text_data() {
// When data is plain text (not base64 CBOR), preserve it as-is
let value = serde_json::json!({
"code": 13,
"message": "Internal error",
"data": "plain text error detail"
});

let status = TenderdashStatus::from(value);
assert_eq!(status.message.as_deref(), Some("plain text error detail"));
}
Comment thread
lklimek marked this conversation as resolved.

#[test]
fn from_json_value_preserves_base64_non_cbor_data() {
// data field that is valid base64 but decodes to non-CBOR bytes.
// decode_data_message should return None → fall back to raw string.
let raw_bytes = b"this is not CBOR at all";
let b64 = base64::prelude::BASE64_STANDARD.encode(raw_bytes);

let value = serde_json::json!({
"code": 13,
"message": "Internal error",
"data": b64
});

let status = TenderdashStatus::from(value);
assert_eq!(status.message.as_deref(), Some(b64.as_str()));
}
}
54 changes: 50 additions & 4 deletions packages/rs-sdk-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum DashSDKErrorCode {
Timeout = 8,
/// Feature not implemented
NotImplemented = 9,
/// Drive returned an internal error (e.g., storage-level constraint violation)
DriveInternalError = 10,
/// Internal error
InternalError = 99,
}
Expand Down Expand Up @@ -105,10 +107,16 @@ impl From<FFIError> for DashSDKError {
// Extract more detailed error information
let error_str = sdk_err.to_string();

// Try to determine error type from the message
let (code, detailed_msg) = if error_str.contains("timeout")
|| error_str.contains("Timeout")
{
// Match typed enum variants first — string matching can collide with
// substrings inside Drive messages (e.g., "data contract not found"
// emitted as a DriveInternalError would otherwise be misclassified
// as NotFound).
let (code, detailed_msg) = if matches!(
sdk_err,
dash_sdk::Error::DriveInternalError(_)
) {
(DashSDKErrorCode::DriveInternalError, error_str)
} else if error_str.contains("timeout") || error_str.contains("Timeout") {
(DashSDKErrorCode::Timeout, error_str)
} else if error_str.contains("I/O error") || error_str.contains("connection") {
(
Expand Down Expand Up @@ -191,3 +199,41 @@ macro_rules! ffi_result {
}
};
}

#[cfg(test)]
mod tests {
use super::*;

fn classify(err: dash_sdk::Error) -> DashSDKErrorCode {
let dash_sdk_error: DashSDKError = FFIError::SDKError(err).into();
let code = dash_sdk_error.code;
// Free the message we allocated via DashSDKError::new.
unsafe {
if !dash_sdk_error.message.is_null() {
let _ = CString::from_raw(dash_sdk_error.message);
}
}
code
}

#[test]
fn drive_internal_error_with_not_found_substring_maps_to_drive_internal_error() {
// Drive emits messages like "data contract not found"; the Display form is
// "Drive internal error: data contract not found …". Typed-variant matching
// must take precedence over substring heuristics.
let err = dash_sdk::Error::DriveInternalError("data contract not found 0x123".to_string());
assert_eq!(classify(err), DashSDKErrorCode::DriveInternalError);
}

#[test]
fn drive_internal_error_plain_maps_to_drive_internal_error() {
let err = dash_sdk::Error::DriveInternalError("storage layer constraint".to_string());
assert_eq!(classify(err), DashSDKErrorCode::DriveInternalError);
}

#[test]
fn generic_not_found_still_maps_to_not_found() {
let err = dash_sdk::Error::Generic("identity not found".to_string());
assert_eq!(classify(err), DashSDKErrorCode::NotFound);
}
}
Loading
Loading