Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return};
/// the caller retains ownership. Compressed secp256k1 pubkeys are
/// always 33 bytes (`pubkey_len == 33`); BLS would be 48; etc.
///
/// **Contract bounds** — Encryption / Decryption keys carry a
/// reference to the contract (and optionally a document type)
/// they're allowed to operate within. Encoded inline as:
/// **Contract bounds** — keys may optionally carry a reference to
/// the contract (and optionally a document type) they're allowed to
/// operate within. Consensus accepts unbounded keys for every
/// purpose, including Encryption / Decryption; bounds only become
/// meaningful when the target contract or document type explicitly
/// requires a bounded key. Encoded inline as:
/// - `contract_bounds_kind == 0` → no bounds.
/// - `contract_bounds_kind == 1` → `SingleContract`. The first
/// 32 bytes at `contract_bounds_id` are the contract id; the
Expand Down Expand Up @@ -129,49 +132,37 @@ pub struct IdentityPubkeyFFI {
/// Decode the optional `contract_bounds_*` payload off an
/// [`IdentityPubkeyFFI`] row.
///
/// Shared by the registration and update FFI paths so
/// Encryption / Decryption keys can carry their bounds through
/// either entry point. `kind == 0` is "no bounds"; `1` is
/// Shared by the registration and update FFI paths so optional
/// key bounds can flow through either entry point. `kind == 0` is
/// "no bounds"; `1` is
/// `SingleContract { id }`; `2` is
/// `SingleContractDocumentType { id, document_type_name }`.
///
/// **Encryption / Decryption purposes require contract bounds.**
/// Drive scopes those purposes to a single contract (and optionally
/// a document type), so registering or updating an identity with an
/// unbounded encryption / decryption key produces a key that cannot
/// be used. We reject `kind == 0` for those purposes here so the
/// failure surfaces as a clean FFI error rather than a key Drive
/// silently can't use.
/// Consensus does not require bounds for Encryption / Decryption
/// purposes: an unbounded key is valid, and bounds are only checked
/// when the caller actually supplies them. In that bounded case,
/// consensus then constrains which purposes may carry bounds and
/// whether the referenced contract or document type opted in.
///
/// `purpose` is the parsed `Purpose` discriminant for the row, used
/// only for the encryption / decryption guard above. `row_index` and
/// `field_label` only flavour error messages (different callers want
/// different prefixes — `add_public_keys[i]` for update,
/// `purpose` is the parsed `Purpose` discriminant for the row. It is
/// not needed for `kind == 0`, but remains part of the shared helper
/// signature so callers can keep their error-message context stable if
/// future validation needs it. `row_index` and `field_label` only
/// flavour error messages (different callers want different prefixes —
/// `add_public_keys[i]` for update,
/// `identity_pubkeys[i]` for registration).
///
/// Returns `Err(PlatformWalletFFIResult)` carrying the FFI error the
/// caller should bubble up (the result already holds the message);
/// caller does `unwrap_result_or_return!(decode_contract_bounds(...))`.
pub(crate) unsafe fn decode_contract_bounds(
row: &IdentityPubkeyFFI,
purpose: Purpose,
_purpose: Purpose,
row_index: usize,
field_label: &str,
) -> Result<Option<ContractBounds>, PlatformWalletFFIResult> {
match row.contract_bounds_kind {
0 => {
if matches!(purpose, Purpose::ENCRYPTION | Purpose::DECRYPTION) {
return Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
format!(
"{field_label}[{row_index}].contract_bounds_kind = 0 (no bounds) but \
purpose = {purpose:?} requires bounds — Drive scopes Encryption / \
Decryption keys to a specific contract (use kind 1 or 2)"
),
));
}
Ok(None)
}
0 => Ok(None),
1 => {
if row.contract_bounds_id.is_null() {
return Err(PlatformWalletFFIResult::err(
Expand Down Expand Up @@ -257,8 +248,9 @@ pub(crate) unsafe fn decode_contract_bounds(
/// `ErrorInvalidParameter` instead of silently coercing.
/// - `pubkey_bytes` must be non-null and non-empty.
/// - `contract_bounds` decoded via [`decode_contract_bounds`], which
/// enforces that Encryption / Decryption keys carry bounds
/// (Drive rejects unbounded ones).
/// mirrors consensus: unbounded keys are accepted, while supplied
/// bounds still have to decode cleanly here and satisfy chain rules
/// later.
///
/// Returns `Err(PlatformWalletFFIResult)` carrying the FFI error the
/// caller should bubble up directly via
Expand Down Expand Up @@ -880,21 +872,98 @@ mod tests {
assert_eq!(map.len(), 2);
}

/// An ENCRYPTION key with no contract bounds is still rejected after the
/// refactor (Drive scopes those keys to a contract, so an unbounded one is
/// unusable). Confirms `decode_contract_bounds`' guard survives.
/// Consensus accepts an unbounded ENCRYPTION key, so the shared FFI decoder
/// must do the same instead of being stricter than the chain.
#[test]
fn decode_identity_pubkeys_rejects_unbounded_encryption_key() {
fn decode_identity_pubkeys_accepts_unbounded_encryption_key() {
let pk = [0x02u8; 33];
let mut enc = ffi_row(4, &pk);
enc.purpose = 1; // Purpose::ENCRYPTION
enc.security_level = 3; // SecurityLevel::MEDIUM
// contract_bounds_kind stays 0 → unbounded → rejected.
// contract_bounds_kind stays 0 → unbounded → accepted.
let rows = [ffi_row(0, &pk), enc];
// SAFETY: `rows` (and the pubkey array it borrows) outlive the call.
let mut err = unsafe { decode_identity_pubkeys(rows.as_ptr(), rows.len()) }
.expect_err("unbounded encryption key must be rejected");
assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter);
let map = unsafe { decode_identity_pubkeys(rows.as_ptr(), rows.len()) }
.expect("unbounded encryption key must decode");
assert_eq!(map.len(), 2);
}

#[test]
fn decode_contract_bounds_accepts_none_for_authentication_encryption_and_decryption() {
let pk = [0x02u8; 33];

for purpose in [
Purpose::AUTHENTICATION,
Purpose::ENCRYPTION,
Purpose::DECRYPTION,
] {
let row = ffi_row(0, &pk);
let bounds = unsafe { decode_contract_bounds(&row, purpose, 0, "identity_pubkeys") }
.expect("kind == 0 must decode to no bounds for every supported purpose");
assert_eq!(bounds, None);
}
}

#[test]
fn decode_contract_bounds_kind_1_still_decodes_and_rejects_null_id() {
let pk = [0x02u8; 33];
let contract_id = [0x11u8; 32];
let mut row = ffi_row(0, &pk);
row.contract_bounds_kind = 1;
row.contract_bounds_id = contract_id.as_ptr();

let bounds =
unsafe { decode_contract_bounds(&row, Purpose::ENCRYPTION, 0, "identity_pubkeys") }
.expect("kind == 1 must still decode");
assert_eq!(
bounds,
Some(ContractBounds::SingleContract {
id: Identifier::from(contract_id),
})
);

row.contract_bounds_id = ptr::null();
let mut err =
unsafe { decode_contract_bounds(&row, Purpose::ENCRYPTION, 0, "identity_pubkeys") }
.expect_err("kind == 1 must still reject a null contract id");
assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorNullPointer);
unsafe { platform_wallet_ffi_result_free(&mut err) };
}

#[test]
fn decode_contract_bounds_kind_2_still_decodes_and_rejects_null_payloads() {
let pk = [0x02u8; 33];
let contract_id = [0x22u8; 32];
let doc_type = CString::new("profile").expect("static string is valid CString");
let mut row = ffi_row(0, &pk);
row.contract_bounds_kind = 2;
row.contract_bounds_id = contract_id.as_ptr();
row.contract_bounds_document_type = doc_type.as_ptr();

let bounds =
unsafe { decode_contract_bounds(&row, Purpose::DECRYPTION, 0, "identity_pubkeys") }
.expect("kind == 2 must still decode");
assert_eq!(
bounds,
Some(ContractBounds::SingleContractDocumentType {
id: Identifier::from(contract_id),
document_type_name: "profile".to_string(),
})
);

row.contract_bounds_id = ptr::null();
let mut err =
unsafe { decode_contract_bounds(&row, Purpose::DECRYPTION, 0, "identity_pubkeys") }
.expect_err("kind == 2 must still reject a null contract id");
assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorNullPointer);
unsafe { platform_wallet_ffi_result_free(&mut err) };

row.contract_bounds_id = contract_id.as_ptr();
row.contract_bounds_document_type = ptr::null();
let mut err =
unsafe { decode_contract_bounds(&row, Purpose::DECRYPTION, 0, "identity_pubkeys") }
.expect_err("kind == 2 must still reject a null document type");
assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorNullPointer);
unsafe { platform_wallet_ffi_result_free(&mut err) };
}

Expand Down
Loading
Loading