Skip to content
Merged
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
56 changes: 56 additions & 0 deletions docs/protocol/contract-bound-authentication-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Contract-bound authentication keys

Protocol version 14 lets an identity register an AUTHENTICATION key whose
`contractBounds` name one contract, or one contract and one document type. Before
protocol 14 only ENCRYPTION and DECRYPTION keys could carry bounds. The bounds
variants and their encoding are unchanged; only which keys may carry them and what
they mean for signing changes.

## Registering a bound key

- The key has AUTHENTICATION purpose and a non-MASTER security level (HIGH is the
usual choice for document operations).
- `contractBounds` is `singleContract { id }` or `documentType { id, documentTypeName }`.
- The contract, and the document type when named, must exist when the key is
registered. Any contract may be bound; no contract opt-in is required, unlike
encryption bounds.
- Several bound authentication keys may cover the same contract. Drive keeps one
current-key pointer per contract (or contract and document type) that names the
newest registered key.

Registration goes through the normal identity create or identity update flow, signed
by the identity's own keys. Below protocol 14 such a key is rejected as it is today.

## What a bound key may sign

A bound authentication key may sign only Batch transitions, and every member of the
batch must be inside the bounds:

- `singleContract`: document operations of any type on that contract, and token
operations on tokens defined by that contract.
- `documentType`: document operations of that type on that contract. Token operations
are contract-wide and are never covered by a document-type bound.

Anything else fails signature validation with `ContractBoundedKeyNonBatchError`
(identity updates, contract writes, credit transfers and withdrawals, votes). A batch
with a member outside the bounds fails as a paid validation failure with
`ContractBoundedKeyOutOfBoundsError`: fees are charged and the first member's
identity contract nonce advances, the same handling as other paid batch failures.

Document security-level requirements, ownership rules and token rules still apply. The
bounds add a restriction; they never grant anything the key's purpose and security
level do not already allow.

## What bounds do not do

There are no per-operation permissions, no spending limits and no expiry. A bound key
can perform every document and token operation on its contract, including document
purchases (credits move to the seller) and token transfers, until the identity disables
it through a master-key identity update. Treat a bound key as full authority over the
bound contract, limited in scope but not in amount.

## Compatibility

No wire format changes. Clients and wallets that already handle the two bounds
variants decode bound authentication keys today. The SDK signing helpers refuse to sign
a transition the bounds do not cover before it reaches the network.
2 changes: 2 additions & 0 deletions packages/rs-dpp/src/errors/consensus/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ impl ErrorWithCode for SignatureError {
Self::BasicBLSError(_) => 20010,
Self::InvalidSignaturePublicKeyPurposeError(_) => 20011,
Self::UncompressedPublicKeyNotAllowedError(_) => 20012,
Self::ContractBoundedKeyOutOfBoundsError(_) => 20014,
Self::ContractBoundedKeyNonBatchError(_) => 20013,
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::consensus::signature::SignatureError;
use crate::consensus::ConsensusError;
use crate::ProtocolError;
use bincode::{Decode, DecodeUntrusted, Encode};
use platform_serialization_derive::{
PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize,
};
use thiserror::Error;

#[derive(
Error,
Debug,
Clone,
PartialEq,
Eq,
Encode,
Decode,
PlatformSerialize,
PlatformDeserializeTrusted,
PlatformDeserializeUntrusted,
DecodeUntrusted,
)]
#[error("Contract-bound authentication key {public_key_id} cannot sign a non-batch transition")]
#[platform_serialize(unversioned)]
pub struct ContractBoundedKeyNonBatchError {
public_key_id: u32,
}
impl ContractBoundedKeyNonBatchError {
pub fn new(public_key_id: u32) -> Self {
Self { public_key_id }
}
pub fn public_key_id(&self) -> &u32 {
&self.public_key_id
}
}
impl From<ContractBoundedKeyNonBatchError> for ConsensusError {
fn from(error: ContractBoundedKeyNonBatchError) -> Self {
Self::SignatureError(SignatureError::ContractBoundedKeyNonBatchError(error))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::consensus::signature::SignatureError;
use crate::consensus::ConsensusError;
use crate::ProtocolError;
use bincode::{Decode, DecodeUntrusted, Encode};
use platform_serialization_derive::{
PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize,
};
use thiserror::Error;

#[derive(
Error,
Debug,
Clone,
PartialEq,
Eq,
Encode,
Decode,
PlatformSerialize,
PlatformDeserializeTrusted,
PlatformDeserializeUntrusted,
DecodeUntrusted,
)]
#[error("Batch member is outside the contract bounds of key {public_key_id}")]
#[platform_serialize(unversioned)]
pub struct ContractBoundedKeyOutOfBoundsError {
public_key_id: u32,
}
impl ContractBoundedKeyOutOfBoundsError {
pub fn new(public_key_id: u32) -> Self {
Self { public_key_id }
}
pub fn public_key_id(&self) -> &u32 {
&self.public_key_id
}
}
impl From<ContractBoundedKeyOutOfBoundsError> for ConsensusError {
fn from(error: ContractBoundedKeyOutOfBoundsError) -> Self {
Self::SignatureError(SignatureError::ContractBoundedKeyOutOfBoundsError(error))
}
}
6 changes: 6 additions & 0 deletions packages/rs-dpp/src/errors/consensus/signature/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ pub use crate::consensus::signature::signature_error::SignatureError;
pub use crate::consensus::signature::signature_should_not_be_present_error::SignatureShouldNotBePresentError;
pub use crate::consensus::signature::uncompressed_public_key_not_allowed_error::UncompressedPublicKeyNotAllowedError;
pub use crate::consensus::signature::wrong_public_key_purpose_error::WrongPublicKeyPurposeError;

mod contract_bounded_key_non_batch_error;
pub use contract_bounded_key_non_batch_error::ContractBoundedKeyNonBatchError;

mod contract_bounded_key_out_of_bounds_error;
pub use contract_bounded_key_out_of_bounds_error::ContractBoundedKeyOutOfBoundsError;
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use crate::consensus::signature::ContractBoundedKeyNonBatchError;
use crate::consensus::signature::ContractBoundedKeyOutOfBoundsError;
use crate::consensus::signature::{
BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError,
InvalidSignaturePublicKeySecurityLevelError, InvalidStateTransitionSignatureError,
Expand Down Expand Up @@ -71,10 +73,56 @@ pub enum SignatureError {

#[error(transparent)]
UncompressedPublicKeyNotAllowedError(UncompressedPublicKeyNotAllowedError),
#[error(transparent)]
ContractBoundedKeyNonBatchError(ContractBoundedKeyNonBatchError),

#[error(transparent)]
ContractBoundedKeyOutOfBoundsError(ContractBoundedKeyOutOfBoundsError),
}

impl From<SignatureError> for ConsensusError {
fn from(err: SignatureError) -> Self {
Self::SignatureError(err)
}
}

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

/// `SignatureError` is encoded by variant position; appending is the only safe change.
fn discriminant_of(error: SignatureError) -> u8 {
let bytes = bincode::encode_to_vec(error, bincode::config::standard())
.expect("expected to encode the signature error");
bytes[0]
}

#[test]
fn signature_error_discriminants_are_frozen() {
assert_eq!(
discriminant_of(SignatureError::IdentityNotFoundError(
IdentityNotFoundError::new(Identifier::from([1; 32]))
)),
0
);
assert_eq!(
discriminant_of(SignatureError::UncompressedPublicKeyNotAllowedError(
UncompressedPublicKeyNotAllowedError::new(65)
)),
12
);
assert_eq!(
discriminant_of(SignatureError::ContractBoundedKeyNonBatchError(
ContractBoundedKeyNonBatchError::new(1)
)),
13
);
assert_eq!(
discriminant_of(SignatureError::ContractBoundedKeyOutOfBoundsError(
ContractBoundedKeyOutOfBoundsError::new(1)
)),
14
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ use crate::identity::identity_public_key::contract_bounds::ContractBounds::{
use crate::serialization::JsonConvertible;
#[cfg(feature = "value-conversion")]
use crate::serialization::ValueConvertible;
#[cfg(feature = "state-transitions")]
use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods;
#[cfg(feature = "state-transitions")]
use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods;
#[cfg(feature = "state-transitions")]
use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef;
use crate::ProtocolError;
use bincode::{Decode, DecodeUntrusted, Encode};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -122,6 +128,32 @@ impl ContractBounds {
// MultipleContractsOfSameOwner { .. } => None,
}
}

/// Whether a batch member is inside these bounds. Consensus uses this to authorize a batch
/// signed by a contract-bound AUTHENTICATION key. Token operations are contract-wide, so a
/// document-type bound never covers them.
#[cfg(feature = "state-transitions")]
pub fn allows_batched_transition(&self, transition: BatchedTransitionRef<'_>) -> bool {
match (self, transition) {
(SingleContract { id }, BatchedTransitionRef::Document(document)) => {
document.data_contract_id() == *id
}
(SingleContract { id }, BatchedTransitionRef::Token(token)) => {
token.data_contract_id() == *id
}
(
SingleContractDocumentType {
id,
document_type_name,
},
BatchedTransitionRef::Document(document),
) => {
document.data_contract_id() == *id
&& document.document_type_name() == document_type_name.as_str()
}
(SingleContractDocumentType { .. }, BatchedTransitionRef::Token(_)) => false,
}
}
//
// /// Gets the cbor value
// pub fn to_cbor_value(&self) -> CborValue {
Expand Down Expand Up @@ -343,3 +375,98 @@ mod tests {
assert_eq!(bounds, restored);
}
}

#[cfg(all(test, feature = "state-transitions"))]
mod batched_transition_tests {
use super::ContractBounds;
use crate::identifier::Identifier;
use crate::state_transition::batch_transition::batched_transition::{
document_create_transition::DocumentCreateTransitionV0,
document_delete_transition::DocumentDeleteTransitionV0,
document_index_only_delete_transition::DocumentIndexOnlyDeleteTransitionV0,
document_purchase_transition::DocumentPurchaseTransitionV0,
document_replace_transition::DocumentReplaceTransitionV0,
document_transfer_transition::DocumentTransferTransitionV0,
document_update_price_transition::DocumentUpdatePriceTransitionV0,
token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef,
DocumentTransition, TokenTransition,
};

// Default transitions target contract [0; 32] and the empty document type name.
fn documents() -> Vec<DocumentTransition> {
vec![
DocumentTransition::Create(DocumentCreateTransitionV0::default().into()),
DocumentTransition::Replace(DocumentReplaceTransitionV0::default().into()),
DocumentTransition::Delete(DocumentDeleteTransitionV0::default().into()),
DocumentTransition::IndexOnlyDelete(
DocumentIndexOnlyDeleteTransitionV0::default().into(),
),
DocumentTransition::Transfer(DocumentTransferTransitionV0::default().into()),
DocumentTransition::UpdatePrice(DocumentUpdatePriceTransitionV0::default().into()),
DocumentTransition::Purchase(DocumentPurchaseTransitionV0::default().into()),
]
}

fn tokens() -> Vec<TokenTransition> {
vec![
TokenTransition::Burn(Default::default()),
TokenTransition::Mint(Default::default()),
TokenTransition::Transfer(TokenTransferTransitionV0::default().into()),
TokenTransition::Freeze(Default::default()),
TokenTransition::Unfreeze(Default::default()),
TokenTransition::DestroyFrozenFunds(Default::default()),
TokenTransition::Claim(Default::default()),
TokenTransition::EmergencyAction(Default::default()),
TokenTransition::ConfigUpdate(Default::default()),
TokenTransition::DirectPurchase(Default::default()),
TokenTransition::SetPriceForDirectPurchase(Default::default()),
]
}

#[test]
fn single_contract_bounds_cover_every_operation_on_that_contract_only() {
let bounds = ContractBounds::SingleContract {
id: Identifier::from([0; 32]),
};
let foreign = ContractBounds::SingleContract {
id: Identifier::from([1; 32]),
};
for document in documents() {
let member = BatchedTransitionRef::Document(&document);
assert!(bounds.allows_batched_transition(member), "{document:?}");
assert!(!foreign.allows_batched_transition(member), "{document:?}");
}
for token in tokens() {
let member = BatchedTransitionRef::Token(&token);
assert!(bounds.allows_batched_transition(member), "{token:?}");
assert!(!foreign.allows_batched_transition(member), "{token:?}");
}
}

#[test]
fn document_type_bounds_cover_that_type_only_and_never_tokens() {
let bounds = ContractBounds::SingleContractDocumentType {
id: Identifier::from([0; 32]),
document_type_name: String::new(),
};
let other_type = ContractBounds::SingleContractDocumentType {
id: Identifier::from([0; 32]),
document_type_name: "other".to_string(),
};
for document in documents() {
let member = BatchedTransitionRef::Document(&document);
assert!(bounds.allows_batched_transition(member), "{document:?}");
assert!(
!other_type.allows_batched_transition(member),
"{document:?}"
);
}
for token in tokens() {
let member = BatchedTransitionRef::Token(&token);
assert!(
!bounds.allows_batched_transition(member),
"token operations are contract-wide: {token:?}"
);
}
}
}
Loading
Loading