Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
3ca176a
feat(platform): add contract-scoped authentication keys
PastaPastaPasta Sep 10, 2026
cf8b57b
fix(wasm-dpp): map scoped authentication errors
PastaPastaPasta Sep 10, 2026
62f9648
fix(wasm): keep scoped bounds bindings exhaustive
PastaPastaPasta Sep 10, 2026
07bf81f
fix(wasm-drive-verify): serialize scoped contract bounds
PastaPastaPasta Sep 10, 2026
a5f512b
Merge branch 'v4.2-dev' into feat/scoped-contract-auth-keys
QuantumExplorer Sep 15, 2026
ccf5b29
fix(wasm-drive-verify): build scoped contract bounds explicitly
QuantumExplorer Sep 15, 2026
2df278c
fix(dpp): accept sighash data version 1 for shielded identity top-ups
QuantumExplorer Sep 15, 2026
68e10e5
fix(drive): version scoped key reference refresh and keep the newest …
QuantumExplorer Sep 15, 2026
8682801
fix(drive-abci): pin the scope check to the signing key and fail clos…
QuantumExplorer Sep 15, 2026
2bc9967
test(dpp): freeze scoped error discriminants, permission mask and doc…
QuantumExplorer Sep 15, 2026
e310292
docs(protocol): state scoped key spending authority and the SDK follo…
QuantumExplorer Sep 15, 2026
b9d2670
fix(drive-abci): drop the needless return in the scoped bounds arm
QuantumExplorer Sep 16, 2026
1924b95
Merge branch 'v4.2-dev' into feat/scoped-contract-auth-keys
QuantumExplorer Sep 16, 2026
3d1890f
fix(drive): coalesce scoped current-key alias writes across an identi…
QuantumExplorer Sep 16, 2026
2a066f0
fix(drive): price scoped key revocation from the stored keys
QuantumExplorer Sep 16, 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
117 changes: 117 additions & 0 deletions docs/protocol/contract-scoped-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Contract-scoped authentication keys

Protocol version 14 adds application authentication scopes. A wallet can register
a separate key for an application while retaining the identity's master key.
Validators enforce the registered scope on every batch member. Existing identity
ownership, key purpose, security level and document rules still apply.

## Registering an application key

The key must have AUTHENTICATION purpose and a non-MASTER security level. A HIGH
key is suitable for normal document operations. Its `contractBounds` is a new
`scoped` variant containing a versioned authentication scope:

- `contracts`: explicit contract IDs with optional document-type restrictions.
- `permissions`: an action bitmask shared by every listed contract.
- `expiresAt`: an optional expiry in milliseconds, checked against block time.

A missing/null document-type restriction authorizes all types in that contract,
including types added by later contract updates. An empty array is invalid.
Contract IDs and document-type names must be sorted and unique on the wire. There
are at most 16 contracts and 16 types per contract, and the encoded scope must not
exceed 2048 bytes. The WASM constructor (SDK follow-up, PR #4655) sorts entries and
rejects duplicates.

For an application that creates, updates and deletes documents and pays their
configured token fees, construct the bounds with the WASM SDK (`ContractBounds.Scoped`
and `AuthenticationPermission` ship with the SDK follow-up, PR #4655; this PR only
keeps the existing bindings compiling):

```javascript
const P = wasm.AuthenticationPermission;
const bounds = wasm.ContractBounds.Scoped(
[
{ id: socialContractId, documentTypes: ['like', 'post'] },
{ id: profileContractId, documentTypes: ['profile'] },
],
P.DocumentCreate | P.DocumentReplace | P.DocumentDelete | P.DocumentTokenPayment,
BigInt(Date.now() + 24 * 60 * 60 * 1000),
);

const keyToAdd = new wasm.IdentityPublicKeyInCreation({
keyId: nextKeyId,
purpose: 'authentication',
securityLevel: 'high',
keyType: 'ecdsa_hash160',
isReadOnly: false,
data: applicationPublicKeyHash160,
signature: new Uint8Array(),
contractBounds: bounds,
});
```

Use the normal wallet-authorized identity-update procedure to register the key.
The registration signature binds the scope as well as the public-key material.
The browser only needs the application key's private material. Registering a
scope requires its referenced contracts/types to exist and its expiry, if any,
to be in the future. No contract encryption-key opt-in or unique-key setting is
required for scoped authentication.

## Permissions and token fees

Document create, replace, delete, ownership transfer, price updates and purchases
have separate bits. Index-only deletion uses the delete bit. Standalone token
transition kinds also have separate bits. New/unknown bits are rejected. Token bits
apply to every token defined on a listed contract; a document-type restriction only
narrows document actions, never token operations.

`DocumentTokenPayment` permits the actual contract-defined token cost of an
otherwise-authorized document action. It also covers fees using a token issued
by another contract. That does not authorize document writes or standalone token
operations on the issuing contract. Without the bit, a document action with a
positive token cost is rejected, even if its create/replace/delete bit is set.

A document-token payment permission does not implicitly authorize token transfer,
burn, mint, purchase or administration transitions. Explicitly granting one of
those bits still cannot override its normal purpose/security/ownership rules.
Contract updates can change document token fees; v0 scopes do not pin the fee
amount or currency.

## Expiry, revocation and failures

A key is expired when executing block time is greater than or equal to its expiry.
Mempool checks use the last committed block information; a transaction may expire
between admission and execution. Disable the key through the normal identity
update to revoke it. Extending expiry or expanding permissions requires a
wallet-authorized replacement. Expired keys are not automatically deleted.

Scoped keys cannot execute non-batch transitions, including identity-key updates,
contract creation/updates, credit transfers/withdrawals or masternode votes.
Expired keys and non-batch use fail in identity-signature authorization.

Batch scope violations follow normal paid validation-failure handling. Requested
document/token operations do not execute, but Platform credit validation fees
can be charged and the first batch member's identity-contract nonce can advance,
even if that member is outside the scope. Replays follow the usual nonce rules.

There are no per-key budgets in scope version 0. Two bits carry more authority than
their names suggest. `DocumentTokenPayment` delegates spending over every token
balance the identity holds: the in-scope contract owner chooses the fee token and
amount, may point at a token issued by any other contract, and may change both by
contract update. `DocumentPurchase` moves credits to the seller at the listed price,
so it amounts to credit-transfer authority towards any seller the key holder controls,
using an authentication key rather than a TRANSFER key. A stolen key can exhaust
credit and token balances through allowed operations. Expiry limits the time window,
not total financial loss.

## Compatibility

The scoped variant is appended to the existing bounds enum; old key encodings
remain unchanged. Older protocols reject scoped registration, and older clients
cannot be assumed to decode scoped keys. SDK signing performs local structural
checks, but validator checks against current state remain authoritative.

The native key ABI carries an encoded scope pointer/length. Native libraries,
generated headers and Swift/Kotlin consumers must be updated together. Key query,
persistence, restore and refresh paths must preserve scope metadata. It must
never be dropped or reconstructed as an unrestricted key.
31 changes: 31 additions & 0 deletions packages/rs-dpp/src/errors/consensus/basic/basic_error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::consensus::basic::identity::InvalidAuthenticationScopeError;
use crate::errors::ProtocolError;
use bincode::{Decode, DecodeUntrusted, Encode};
use platform_serialization_derive::{
Expand Down Expand Up @@ -716,10 +717,40 @@ pub enum BasicError {

#[error(transparent)]
DataContractInvalidRequiredFieldsUpdateError(DataContractInvalidRequiredFieldsUpdateError),
#[error(transparent)]
InvalidAuthenticationScopeError(InvalidAuthenticationScopeError),
}

impl From<BasicError> for ConsensusError {
fn from(error: BasicError) -> Self {
Self::BasicError(error)
}
}

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

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

#[test]
fn basic_error_discriminants_are_frozen() {
assert_eq!(
discriminant_of(BasicError::ProtocolVersionParsingError(
ProtocolVersionParsingError::new("parse".to_string())
)),
0
);
assert_eq!(
discriminant_of(BasicError::InvalidAuthenticationScopeError(
InvalidAuthenticationScopeError::new("scope".to_string())
)),
175
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
use crate::consensus::basic::BasicError;
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("Invalid authentication scope: {reason}")]
#[platform_serialize(unversioned)]
pub struct InvalidAuthenticationScopeError {
reason: String,
}
impl InvalidAuthenticationScopeError {
pub fn new(reason: String) -> Self {
Self { reason }
}
pub fn reason(&self) -> &String {
&self.reason
}
}
impl From<InvalidAuthenticationScopeError> for ConsensusError {
fn from(error: InvalidAuthenticationScopeError) -> Self {
Self::BasicError(BasicError::InvalidAuthenticationScopeError(error))
}
}
3 changes: 3 additions & 0 deletions packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ mod missing_master_public_key_error;
mod not_implemented_credit_withdrawal_transition_pooling_error;
mod too_many_master_public_key_error;
mod withdrawal_output_script_not_allowed_when_signing_with_owner_key;

mod invalid_authentication_scope_error;
pub use invalid_authentication_scope_error::InvalidAuthenticationScopeError;
4 changes: 4 additions & 0 deletions packages/rs-dpp/src/errors/consensus/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ impl ErrorWithCode for BasicError {
Self::WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError(_) => 10532,
Self::InvalidKeyPurposeForContractBoundsError(_) => 10533,
Self::IdentityAssetLockTransactionTooManyInputsError(_) => 10534,
Self::InvalidAuthenticationScopeError(_) => 10535,

// State Transition Errors: 10600-10699
Self::InvalidStateTransitionTypeError { .. } => 10600,
Expand Down Expand Up @@ -265,6 +266,9 @@ impl ErrorWithCode for SignatureError {
Self::BasicBLSError(_) => 20010,
Self::InvalidSignaturePublicKeyPurposeError(_) => 20011,
Self::UncompressedPublicKeyNotAllowedError(_) => 20012,
Self::ScopedKeyOutOfScopeError(_) => 20015,
Self::ScopedKeyExpiredError(_) => 20014,
Self::ScopedKeyNonBatchError(_) => 20013,
}
}
}
Expand Down
9 changes: 9 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,12 @@ 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 scoped_key_non_batch_error;
pub use scoped_key_non_batch_error::ScopedKeyNonBatchError;

mod scoped_key_expired_error;
pub use scoped_key_expired_error::ScopedKeyExpiredError;

mod scoped_key_out_of_scope_error;
pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeError;
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("Scoped key {public_key_id} has expired")]
#[platform_serialize(unversioned)]
pub struct ScopedKeyExpiredError {
public_key_id: u32,
}
impl ScopedKeyExpiredError {
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<ScopedKeyExpiredError> for ConsensusError {
fn from(error: ScopedKeyExpiredError) -> Self {
Self::SignatureError(SignatureError::ScopedKeyExpiredError(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("Scoped key {public_key_id} cannot sign a non-batch transition")]
#[platform_serialize(unversioned)]
pub struct ScopedKeyNonBatchError {
public_key_id: u32,
}
impl ScopedKeyNonBatchError {
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<ScopedKeyNonBatchError> for ConsensusError {
fn from(error: ScopedKeyNonBatchError) -> Self {
Self::SignatureError(SignatureError::ScopedKeyNonBatchError(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 key {public_key_id} scope")]
#[platform_serialize(unversioned)]
pub struct ScopedKeyOutOfScopeError {
public_key_id: u32,
}
impl ScopedKeyOutOfScopeError {
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<ScopedKeyOutOfScopeError> for ConsensusError {
fn from(error: ScopedKeyOutOfScopeError) -> Self {
Self::SignatureError(SignatureError::ScopedKeyOutOfScopeError(error))
}
}
Loading
Loading