diff --git a/standards/dusk-contract-standards/examples/build_signed_authorizations.rs b/standards/dusk-contract-standards/examples/build_signed_authorizations.rs new file mode 100644 index 0000000..05345c8 --- /dev/null +++ b/standards/dusk-contract-standards/examples/build_signed_authorizations.rs @@ -0,0 +1,170 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +use std::vec::Vec; + +use dusk_contract_standards::auth::{ + AuthorizedAction, MoonlightAuthorization, PhoenixSignatureAuthorization, + SignedAuthorization, +}; +use dusk_contract_standards::core::{NonceDomain, Principal}; +use dusk_core::abi::ContractId; +use dusk_core::signatures::bls::{ + PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, +}; +use dusk_core::signatures::schnorr::{ + PublicKey as SchnorrPublicKey, SecretKey as SchnorrSecretKey, +}; +use dusk_core::JubJubScalar; +use dusk_vm::host_queries; +use rand::rngs::StdRng; +use rand::SeedableRng; + +const SIGNED_APPROVE_DOMAIN: NonceDomain = [12u8; 32]; +const SIGNED_APPROVE_ACTION: [u8; 32] = [18u8; 32]; +const NFT_SIGNED_APPROVE_DOMAIN: NonceDomain = [29u8; 32]; +const NFT_SIGNED_APPROVE_ACTION: [u8; 32] = [30u8; 32]; +const CHAIN_ID: u8 = 0xFA; +const EXAMPLE_EXPIRES_AT: u64 = 1_000; + +fn main() { + let contract = ContractId::from_bytes([42u8; 32]); + let spender = Principal::Contract(ContractId::from_bytes([7u8; 32])); + + let moonlight_sk = moonlight_secret(11); + let moonlight_pk = BlsPublicKey::from(&moonlight_sk); + let moonlight_owner = Principal::moonlight(&moonlight_pk); + let moonlight_action = + drc20_signed_approve_action(contract, moonlight_owner, spender, 100, 0); + let moonlight = SignedAuthorization::Moonlight(MoonlightAuthorization { + action: moonlight_action, + public_key: moonlight_pk, + signature: moonlight_sk.sign(&moonlight_action.message_bytes()), + }); + moonlight.assert_action( + CHAIN_ID, + contract, + SIGNED_APPROVE_DOMAIN, + SIGNED_APPROVE_ACTION, + drc20_approve_payload_hash(moonlight_owner, spender, 100), + ); + + let mut rng = StdRng::seed_from_u64(99); + let phoenix_sk = SchnorrSecretKey::from(JubJubScalar::from(12u64)); + let phoenix_pk = SchnorrPublicKey::from(&phoenix_sk); + let phoenix_owner = Principal::phoenix_public_key(&phoenix_pk); + let phoenix_action = + drc20_signed_approve_action(contract, phoenix_owner, spender, 100, 0); + let phoenix = SignedAuthorization::Phoenix(PhoenixSignatureAuthorization { + action: phoenix_action, + public_key: phoenix_pk, + signature: phoenix_sk.sign(&mut rng, phoenix_action.message_hash()), + replay_key: None, + }); + phoenix.assert_action( + CHAIN_ID, + contract, + SIGNED_APPROVE_DOMAIN, + SIGNED_APPROVE_ACTION, + drc20_approve_payload_hash(phoenix_owner, spender, 100), + ); + + let nft_approved = Principal::Contract(ContractId::from_bytes([8u8; 32])); + let nft_action = drc721_signed_approve_action( + contract, + phoenix_owner, + nft_approved, + 42, + 1, + ); + let nft_approval = + SignedAuthorization::Phoenix(PhoenixSignatureAuthorization { + action: nft_action, + public_key: phoenix_pk, + signature: phoenix_sk.sign(&mut rng, nft_action.message_hash()), + replay_key: None, + }); + nft_approval.assert_action( + CHAIN_ID, + contract, + NFT_SIGNED_APPROVE_DOMAIN, + NFT_SIGNED_APPROVE_ACTION, + drc721_approve_payload_hash(phoenix_owner, nft_approved, 42), + ); +} + +fn drc20_signed_approve_action( + contract: ContractId, + owner: Principal, + spender: Principal, + amount: u64, + nonce: u64, +) -> AuthorizedAction { + AuthorizedAction { + chain_id: CHAIN_ID, + contract, + domain: SIGNED_APPROVE_DOMAIN, + action_id: SIGNED_APPROVE_ACTION, + nonce, + expires_at: EXAMPLE_EXPIRES_AT, + principal: owner, + payload_hash: drc20_approve_payload_hash(owner, spender, amount), + } +} + +fn drc20_approve_payload_hash( + owner: Principal, + spender: Principal, + amount: u64, +) -> [u8; 32] { + let mut bytes = Vec::from(&b"drc20.approve"[..]); + push_principal(&mut bytes, owner); + push_principal(&mut bytes, spender); + bytes.extend_from_slice(&amount.to_be_bytes()); + host_queries::keccak256(bytes) +} + +fn drc721_signed_approve_action( + contract: ContractId, + owner: Principal, + approved: Principal, + token_id: u64, + nonce: u64, +) -> AuthorizedAction { + AuthorizedAction { + chain_id: CHAIN_ID, + contract, + domain: NFT_SIGNED_APPROVE_DOMAIN, + action_id: NFT_SIGNED_APPROVE_ACTION, + nonce, + expires_at: EXAMPLE_EXPIRES_AT, + principal: owner, + payload_hash: drc721_approve_payload_hash(owner, approved, token_id), + } +} + +fn drc721_approve_payload_hash( + owner: Principal, + approved: Principal, + token_id: u64, +) -> [u8; 32] { + let mut bytes = Vec::from(&b"drc721.approve"[..]); + push_principal(&mut bytes, owner); + push_principal(&mut bytes, approved); + bytes.extend_from_slice(&token_id.to_be_bytes()); + host_queries::keccak256(bytes) +} + +fn moonlight_secret(seed: u64) -> BlsSecretKey { + let mut rng = StdRng::seed_from_u64(seed); + BlsSecretKey::random(&mut rng) +} + +fn push_principal(bytes: &mut Vec, principal: Principal) { + let principal = principal.to_bytes(); + bytes.extend_from_slice(&(principal.len() as u16).to_be_bytes()); + bytes.extend_from_slice(&principal); +} diff --git a/standards/dusk-contract-standards/src/auth/mod.rs b/standards/dusk-contract-standards/src/auth/mod.rs new file mode 100644 index 0000000..17393e2 --- /dev/null +++ b/standards/dusk-contract-standards/src/auth/mod.rs @@ -0,0 +1,763 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Replay-protected signed authorization helpers. + +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use dusk_core::abi::ContractId; +use dusk_core::signatures::bls::{ + PublicKey as BlsPublicKey, Signature as BlsSignature, +}; +use dusk_core::signatures::schnorr::{ + PublicKey as SchnorrPublicKey, Signature as SchnorrSignature, +}; +use dusk_core::BlsScalar; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::{ + error, CallContext, NonceDomain, NonceEntry, NonceManager, Principal, + PrincipalKind, ReplayEntry, ReplayGuard, ReplayKey, +}; + +/// Stable prefix for authorization messages. +pub const AUTH_MESSAGE_PREFIX: &[u8] = b"dusk-contract-standards/auth/v1"; + +/// Expected call envelope for an action-bound authorization. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct ActionEnvelope { + /// Dusk chain id this authorization is intended for. + pub chain_id: u8, + /// Contract this authorization is intended for. + pub contract: ContractId, + /// Domain-separated nonce stream. + pub domain: NonceDomain, + /// Application-level action id. + pub action_id: [u8; 32], + /// Hash or commitment of the call payload. + pub payload_hash: [u8; 32], +} + +impl ActionEnvelope { + /// Creates a new expected action envelope. + pub const fn new( + chain_id: u8, + contract: ContractId, + domain: NonceDomain, + action_id: [u8; 32], + payload_hash: [u8; 32], + ) -> Self { + Self { + chain_id, + contract, + domain, + action_id, + payload_hash, + } + } + + /// Creates an expected action envelope for the current runtime chain. + #[cfg(all(target_family = "wasm", feature = "contract"))] + pub fn for_current_chain( + contract: ContractId, + domain: NonceDomain, + action_id: [u8; 32], + payload_hash: [u8; 32], + ) -> Self { + Self::new( + dusk_core::abi::chain_id(), + contract, + domain, + action_id, + payload_hash, + ) + } +} + +/// Contract action authorized by a signature. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct AuthorizedAction { + /// Dusk chain id this authorization is intended for. + pub chain_id: u8, + /// Contract this authorization is intended for. + pub contract: ContractId, + /// Domain-separated nonce stream. + pub domain: NonceDomain, + /// Application-level action id. + pub action_id: [u8; 32], + /// Expected nonce. + pub nonce: u64, + /// Expiration timestamp/height unit selected by the contract. + /// + /// A value of zero means no expiry. + pub expires_at: u64, + /// Principal being authorized. + pub principal: Principal, + /// Hash or commitment of the call payload. + pub payload_hash: [u8; 32], +} + +impl AuthorizedAction { + /// Returns true when this action has expired at `now`. + pub const fn is_expired(&self, now: u64) -> bool { + self.expires_at != 0 && now > self.expires_at + } + + /// Builds the stable message bytes signed for this action. + pub fn message_bytes(&self) -> Vec { + let principal = self.principal.to_bytes(); + let mut out = Vec::with_capacity( + AUTH_MESSAGE_PREFIX.len() + + 1 + + 32 + + 32 + + 32 + + 8 + + 8 + + 2 + + principal.len() + + 32, + ); + out.extend_from_slice(AUTH_MESSAGE_PREFIX); + out.push(self.chain_id); + out.extend_from_slice(&self.contract.to_bytes()); + out.extend_from_slice(&self.domain); + out.extend_from_slice(&self.action_id); + out.extend_from_slice(&self.nonce.to_be_bytes()); + out.extend_from_slice(&self.expires_at.to_be_bytes()); + out.extend_from_slice(&(principal.len() as u16).to_be_bytes()); + out.extend_from_slice(&principal); + out.extend_from_slice(&self.payload_hash); + out + } + + /// Hashes the stable message bytes into the scalar signed by Phoenix keys. + pub fn message_hash(&self) -> BlsScalar { + hash_message(self.message_bytes()) + } + + /// Panics unless this action matches the expected contract, nonce domain, + /// action id, and payload hash. + pub fn assert_matches( + &self, + chain_id: u8, + contract: ContractId, + domain: NonceDomain, + action_id: [u8; 32], + payload_hash: [u8; 32], + ) { + self.assert_envelope(ActionEnvelope::new( + chain_id, + contract, + domain, + action_id, + payload_hash, + )); + } + + /// Panics unless this action matches the expected call envelope. + pub fn assert_envelope(&self, envelope: ActionEnvelope) { + if self.chain_id != envelope.chain_id + || self.contract != envelope.contract + || self.domain != envelope.domain + || self.action_id != envelope.action_id + || self.payload_hash != envelope.payload_hash + { + panic!("{}", error::UNAUTHORIZED); + } + } +} + +/// Moonlight BLS authorization. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct MoonlightAuthorization { + /// Authorized action. + pub action: AuthorizedAction, + /// Moonlight public key. + pub public_key: BlsPublicKey, + /// Signature over `action.message_bytes()`. + pub signature: BlsSignature, +} + +/// Phoenix Schnorr authorization. +/// +/// This proves control of the Phoenix Schnorr public key represented by +/// `action.principal`. It does not prove ownership or spending of a specific +/// Phoenix note. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct PhoenixSignatureAuthorization { + /// Authorized action. + pub action: AuthorizedAction, + /// Phoenix Schnorr public key. + pub public_key: SchnorrPublicKey, + /// Signature over `action.message_hash()`. + pub signature: SchnorrSignature, + /// Optional extra replay key consumed with the signature. + pub replay_key: Option, +} + +/// Explicit signed authorization supplied with a call. +#[derive(Archive, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub enum SignedAuthorization { + /// Moonlight BLS signed action. + Moonlight(MoonlightAuthorization), + /// Phoenix Schnorr signed action. + Phoenix(PhoenixSignatureAuthorization), +} + +impl SignedAuthorization { + /// Returns the action covered by the signature. + pub const fn action(&self) -> &AuthorizedAction { + match self { + Self::Moonlight(auth) => &auth.action, + Self::Phoenix(auth) => &auth.action, + } + } + + /// Panics unless the signed action matches the exact call envelope. + pub fn assert_action( + &self, + chain_id: u8, + contract: ContractId, + domain: NonceDomain, + action_id: [u8; 32], + payload_hash: [u8; 32], + ) { + self.assert_envelope(ActionEnvelope::new( + chain_id, + contract, + domain, + action_id, + payload_hash, + )); + } + + /// Panics unless the signed action matches the exact call envelope. + pub fn assert_envelope(&self, envelope: ActionEnvelope) { + self.action().assert_envelope(envelope); + } +} + +/// Ergonomic runtime-or-signature authorizer for composing contracts. +/// +/// Moonlight and contract principals can pass through the observed call +/// context. Phoenix principals must pass through explicit signed +/// authorization because Phoenix calls do not expose a stable caller identity. +pub struct Authorizer<'a> { + authorizations: &'a mut AuthorizationManager, + context: CallContext, + now: u64, +} + +impl<'a> Authorizer<'a> { + /// Creates a reusable authorizer for one contract call. + pub fn new( + authorizations: &'a mut AuthorizationManager, + context: CallContext, + now: u64, + ) -> Self { + Self { + authorizations, + context, + now, + } + } + + /// Returns the observed runtime principal, if any. + pub const fn observed_principal(&self) -> Option { + self.context.principal + } + + /// Verifies an exact expected principal. + /// + /// Signed fallbacks are not checked against a call envelope. Prefer + /// [`Self::require_principal_action`] in public contract methods unless + /// the caller has already bound the action elsewhere. + pub fn require_principal_unbound( + &mut self, + expected: Principal, + authorization: Option<&SignedAuthorization>, + ) -> Principal { + self.authorizations.authorize_principal( + expected, + self.context, + authorization, + self.now, + ) + } + + /// Verifies an exact expected principal and binds signed fallbacks to a + /// call envelope before consuming nonce/replay state. + pub fn require_principal_action( + &mut self, + expected: Principal, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + ) -> Principal { + self.authorizations.authorize_principal_action( + expected, + self.context, + authorization, + envelope, + self.now, + ) + } + + /// Verifies a signed authorization and consumes its nonce/replay state. + /// + /// This is a low-level primitive. Public contract methods should usually + /// prefer `require_signed_action` so the intended call envelope is checked + /// before nonce state is consumed. + pub fn require_unbound_signed( + &mut self, + authorization: &SignedAuthorization, + ) -> Principal { + self.authorizations + .authorize_unbound_signed(authorization, self.now) + } + + /// Verifies a signed authorization, applies an authorization predicate, + /// and only then consumes nonce/replay state. + pub fn require_unbound_signed_if( + &mut self, + authorization: &SignedAuthorization, + is_authorized: impl FnOnce(Principal) -> bool, + ) -> Principal { + let principal = + self.authorizations.verify_signed(authorization, self.now); + if !is_authorized(principal) { + panic!("{}", error::UNAUTHORIZED); + } + self.authorizations.consume_verified(authorization); + principal + } + + /// Verifies a signed authorization for an exact call envelope and consumes + /// its nonce/replay state. + pub fn require_signed_action( + &mut self, + authorization: &SignedAuthorization, + envelope: ActionEnvelope, + ) -> Principal { + self.authorizations.authorize_signed_action( + authorization, + envelope, + self.now, + ) + } + + /// Verifies a signed authorization for an exact call envelope, applies an + /// authorization predicate, and only then consumes nonce/replay state. + pub fn require_signed_action_if( + &mut self, + authorization: &SignedAuthorization, + envelope: ActionEnvelope, + is_authorized: impl FnOnce(Principal) -> bool, + ) -> Principal { + let principal = self.authorizations.verify_signed_action( + authorization, + envelope, + self.now, + ); + if !is_authorized(principal) { + panic!("{}", error::UNAUTHORIZED); + } + self.authorizations.consume_verified(authorization); + principal + } +} + +/// Authorization manager with nonce and replay-key state. +#[derive(Clone, Debug, Default)] +pub struct AuthorizationManager { + nonces: NonceManager, + replays: ReplayGuard, +} + +impl AuthorizationManager { + /// Creates an empty manager. + pub const fn new() -> Self { + Self { + nonces: NonceManager::new(), + replays: ReplayGuard::new(), + } + } + + /// Returns current nonce for a principal/domain stream. + pub fn nonce(&self, principal: Principal, domain: NonceDomain) -> u64 { + self.nonces.current(principal, domain) + } + + /// Returns whether a replay key has been consumed. + pub fn replay_used(&self, principal: Principal, key: ReplayKey) -> bool { + self.replays.is_used(principal, key) + } + + /// Authorizes an exact expected principal. + /// + /// Moonlight and contract principals can be authorized by the runtime call + /// context. Moonlight can also be authorized by a BLS signed action. + /// Phoenix requires a Schnorr signed action because Phoenix does not expose + /// a stable runtime caller identity to contracts. + /// + /// Signed fallbacks are not checked against a call envelope. Prefer + /// [`Self::authorize_principal_action`] in public contract methods unless + /// the caller has already bound the action elsewhere. + pub fn authorize_principal( + &mut self, + expected: Principal, + context: CallContext, + authorization: Option<&SignedAuthorization>, + now: u64, + ) -> Principal { + match expected.kind() { + PrincipalKind::Moonlight | PrincipalKind::Contract => { + if context.principal == Some(expected) { + return expected; + } + } + PrincipalKind::Phoenix => {} + } + + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + let principal = self.verify_signed(authorization, now); + if principal != expected { + panic!("{}", error::UNAUTHORIZED); + } + self.consume_verified(authorization); + principal + } + + /// Authorizes an exact expected principal and binds signed fallbacks to a + /// call envelope before nonce/replay state is consumed. + pub fn authorize_principal_action( + &mut self, + expected: Principal, + context: CallContext, + authorization: Option<&SignedAuthorization>, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + match expected.kind() { + PrincipalKind::Moonlight | PrincipalKind::Contract => { + if context.principal == Some(expected) { + return expected; + } + } + PrincipalKind::Phoenix => {} + } + + let Some(authorization) = authorization else { + panic!("{}", error::UNAUTHORIZED); + }; + let principal = self.verify_signed_action(authorization, envelope, now); + if principal != expected { + panic!("{}", error::UNAUTHORIZED); + } + self.consume_verified(authorization); + principal + } + + /// Verifies and consumes a signed authorization, returning its principal. + /// + /// This is a low-level primitive. Public contract methods should usually + /// prefer `authorize_signed_action` so the intended call envelope is + /// checked before nonce state is consumed. + pub fn authorize_unbound_signed( + &mut self, + authorization: &SignedAuthorization, + now: u64, + ) -> Principal { + let principal = self.verify_signed(authorization, now); + self.consume_verified(authorization); + principal + } + + /// Verifies a signed authorization for an exact call envelope and consumes + /// its nonce/replay state. + pub fn authorize_signed_action( + &mut self, + authorization: &SignedAuthorization, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + let principal = self.verify_signed_action(authorization, envelope, now); + self.consume_verified(authorization); + principal + } + + /// Verifies a signed authorization without consuming nonce/replay state. + fn verify_signed( + &self, + authorization: &SignedAuthorization, + now: u64, + ) -> Principal { + match authorization { + SignedAuthorization::Moonlight(auth) => { + self.verify_moonlight(auth, now) + } + SignedAuthorization::Phoenix(auth) => { + self.verify_phoenix(auth, now) + } + } + } + + /// Verifies a signed authorization for an exact call envelope without + /// consuming nonce/replay state. + /// + /// This is an advanced primitive for higher-level authorization policies + /// such as multisig threshold checks. Callers must only consume the + /// authorization after all policy checks have succeeded. + pub fn verify_signed_action( + &self, + authorization: &SignedAuthorization, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + authorization.assert_envelope(envelope); + self.verify_signed(authorization, now) + } + + /// Verifies and consumes a Moonlight BLS authorization. + /// + /// The signed action is not checked against a call envelope. Prefer + /// [`Self::authorize_moonlight_action`] in public contract methods. + pub fn authorize_unbound_moonlight( + &mut self, + auth: &MoonlightAuthorization, + now: u64, + ) -> Principal { + let principal = self.verify_moonlight(auth, now); + self.consume_action(principal, auth.action.domain, auth.action.nonce); + principal + } + + /// Verifies and consumes a Moonlight BLS authorization for an exact call + /// envelope. + pub fn authorize_moonlight_action( + &mut self, + auth: &MoonlightAuthorization, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + auth.action.assert_envelope(envelope); + let principal = self.verify_moonlight(auth, now); + self.consume_action(principal, auth.action.domain, auth.action.nonce); + principal + } + + /// Verifies a Moonlight BLS authorization without consuming nonce state. + fn verify_moonlight( + &self, + auth: &MoonlightAuthorization, + now: u64, + ) -> Principal { + assert_live(&auth.action, now); + let principal = Principal::moonlight(&auth.public_key); + if auth.action.principal != principal { + panic!("{}", error::UNAUTHORIZED); + } + let message = auth.action.message_bytes(); + if !verify_bls(&message, &auth.public_key, &auth.signature) { + panic!("{}", error::UNAUTHORIZED); + } + self.assert_nonce(principal, auth.action.domain, auth.action.nonce); + principal + } + + /// Verifies and consumes a Phoenix authorization. + /// + /// The signed action is not checked against a call envelope. Prefer + /// [`Self::authorize_phoenix_action`] in public contract methods. + pub fn authorize_unbound_phoenix( + &mut self, + auth: &PhoenixSignatureAuthorization, + now: u64, + ) -> Principal { + let principal = self.verify_phoenix(auth, now); + self.consume_action(principal, auth.action.domain, auth.action.nonce); + if let Some(key) = auth.replay_key { + self.replays.consume(principal, key); + } + principal + } + + /// Verifies and consumes a Phoenix Schnorr authorization for an exact call + /// envelope. + pub fn authorize_phoenix_action( + &mut self, + auth: &PhoenixSignatureAuthorization, + envelope: ActionEnvelope, + now: u64, + ) -> Principal { + auth.action.assert_envelope(envelope); + let principal = self.verify_phoenix(auth, now); + self.consume_action(principal, auth.action.domain, auth.action.nonce); + if let Some(key) = auth.replay_key { + self.replays.consume(principal, key); + } + principal + } + + /// Verifies a Phoenix Schnorr authorization without consuming nonce/replay + /// state. + fn verify_phoenix( + &self, + auth: &PhoenixSignatureAuthorization, + now: u64, + ) -> Principal { + assert_live(&auth.action, now); + let principal = Principal::phoenix_public_key(&auth.public_key); + if auth.action.principal != principal { + panic!("{}", error::UNAUTHORIZED); + } + if !verify_schnorr( + auth.action.message_hash(), + &auth.public_key, + &auth.signature, + ) { + panic!("{}", error::UNAUTHORIZED); + } + if let Some(key) = auth.replay_key { + if self.replays.is_used(principal, key) { + panic!("{}", error::REPLAY); + } + } + self.assert_nonce(principal, auth.action.domain, auth.action.nonce); + principal + } + + /// Exports nonce streams. + pub fn nonce_entries(&self) -> Vec { + self.nonces.entries() + } + + /// Imports nonce streams, keeping the greatest value for duplicates. + pub fn import_nonce_entries( + &mut self, + entries: impl IntoIterator, + ) { + self.nonces.import_entries(entries); + } + + /// Exports replay keys. + pub fn replay_entries(&self) -> Vec { + self.replays.entries() + } + + /// Imports replay keys. + pub fn import_replay_entries( + &mut self, + entries: impl IntoIterator, + ) { + self.replays.import_entries(entries); + } + + fn assert_nonce( + &self, + principal: Principal, + domain: NonceDomain, + expected: u64, + ) { + if self.nonces.current(principal, domain) != expected { + panic!("{}", error::INVALID_NONCE); + } + } + + /// Consumes nonce/replay state for an authorization that was already + /// verified against its call envelope. + /// + /// This is intentionally low level. Public contract methods should usually + /// use `authorize_signed_action` or a higher-level helper so verification + /// and consumption remain coupled. + pub fn consume_verified(&mut self, authorization: &SignedAuthorization) { + let action = authorization.action(); + self.consume_action(action.principal, action.domain, action.nonce); + if let SignedAuthorization::Phoenix(auth) = authorization { + if let Some(key) = auth.replay_key { + self.replays.consume(action.principal, key); + } + } + } + + fn consume_action( + &mut self, + principal: Principal, + domain: NonceDomain, + nonce: u64, + ) { + self.nonces.consume(principal, domain, nonce); + } +} + +fn assert_live(action: &AuthorizedAction, now: u64) { + if action.is_expired(now) { + panic!("Authorization: expired"); + } +} + +#[cfg(all(target_family = "wasm", feature = "contract"))] +fn hash_message(message: Vec) -> BlsScalar { + dusk_core::abi::hash(message) +} + +#[cfg(not(all(target_family = "wasm", feature = "contract")))] +fn hash_message(message: Vec) -> BlsScalar { + BlsScalar::hash_to_scalar(&message) +} + +#[cfg(all(target_family = "wasm", feature = "contract"))] +fn verify_bls( + message: &[u8], + public_key: &BlsPublicKey, + signature: &BlsSignature, +) -> bool { + dusk_core::abi::verify_bls(message.to_vec(), *public_key, *signature) +} + +#[cfg(not(all(target_family = "wasm", feature = "contract")))] +fn verify_bls( + message: &[u8], + public_key: &BlsPublicKey, + signature: &BlsSignature, +) -> bool { + public_key.verify(signature, message).is_ok() +} + +#[cfg(all(target_family = "wasm", feature = "contract"))] +fn verify_schnorr( + message: BlsScalar, + public_key: &SchnorrPublicKey, + signature: &SchnorrSignature, +) -> bool { + dusk_core::abi::verify_schnorr(message, *public_key, *signature) +} + +#[cfg(not(all(target_family = "wasm", feature = "contract")))] +fn verify_schnorr( + message: BlsScalar, + public_key: &SchnorrPublicKey, + signature: &SchnorrSignature, +) -> bool { + public_key.verify(signature, message).is_ok() +} diff --git a/standards/dusk-contract-standards/src/core/context.rs b/standards/dusk-contract-standards/src/core/context.rs new file mode 100644 index 0000000..7b94b49 --- /dev/null +++ b/standards/dusk-contract-standards/src/core/context.rs @@ -0,0 +1,157 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Runtime call context helpers. + +use crate::core::principal::Principal; + +#[cfg(any(all(target_family = "wasm", feature = "contract"), test))] +use dusk_core::abi::ContractId; +#[cfg(any(all(target_family = "wasm", feature = "contract"), test))] +use dusk_core::signatures::bls::PublicKey as BlsPublicKey; +#[cfg(any(all(target_family = "wasm", feature = "contract"), test))] +use dusk_core::transfer::TRANSFER_CONTRACT; + +/// Current call context. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CallContext { + /// Current actor, if the runtime exposes one. + pub principal: Option, +} + +impl CallContext { + /// Creates a context from an explicit principal. + pub const fn from_principal(principal: Principal) -> Self { + Self { + principal: Some(principal), + } + } + + /// Creates an empty context. + pub const fn none() -> Self { + Self { principal: None } + } + + /// Returns the principal or panics with `msg`. + pub fn require_principal(&self, msg: &str) -> Principal { + self.principal.unwrap_or_else(|| panic!("{}", msg)) + } + + /// Reads the current Dusk runtime context. + /// + /// A root Moonlight transaction routed through the transfer contract is + /// represented by `public_sender`. An inter-contract call is represented + /// by the immediate caller contract id. Phoenix transactions do not expose + /// a stable owner identity here; Phoenix authorization should be modeled + /// with an explicit Schnorr signature plus nonce/replay protection. + #[cfg(all(target_family = "wasm", feature = "contract"))] + pub fn current() -> Self { + use dusk_core::abi; + + let caller = abi::caller(); + let callstack_len = abi::callstack().len(); + let public_sender = match caller { + Some(TRANSFER_CONTRACT) if callstack_len <= 1 => { + abi::public_sender() + } + _ => None, + }; + + Self::from_runtime_parts(caller, public_sender, callstack_len) + } + + /// Native tests must inject context explicitly. + #[cfg(not(all(target_family = "wasm", feature = "contract")))] + pub const fn current() -> Self { + Self::none() + } + + #[cfg(any(all(target_family = "wasm", feature = "contract"), test))] + fn from_runtime_parts( + caller: Option, + public_sender: Option, + callstack_len: usize, + ) -> Self { + match caller { + Some(TRANSFER_CONTRACT) if callstack_len <= 1 => { + public_sender.as_ref().map_or_else(Self::none, |pk| { + Self::from_principal(Principal::moonlight(pk)) + }) + } + Some(caller) => Self::from_principal(Principal::Contract(caller)), + None => Self::none(), + } + } +} + +#[cfg(test)] +mod tests { + use rand::rngs::StdRng; + use rand::SeedableRng; + + use dusk_core::signatures::bls::{ + PublicKey as BlsPublicKey, SecretKey as BlsSecretKey, + }; + + use super::*; + + fn moonlight_key(seed: u64) -> BlsPublicKey { + let mut rng = StdRng::seed_from_u64(seed); + BlsPublicKey::from(&BlsSecretKey::random(&mut rng)) + } + + fn contract(byte: u8) -> ContractId { + ContractId::from_bytes([byte; 32]) + } + + #[test] + fn transfer_entrypoint_moonlight_uses_public_sender() { + let pk = moonlight_key(1); + let context = CallContext::from_runtime_parts( + Some(TRANSFER_CONTRACT), + Some(pk), + 1, + ); + assert_eq!(context.principal, Some(Principal::moonlight(&pk))); + } + + #[test] + fn transfer_entrypoint_without_public_sender_has_no_actor() { + let context = + CallContext::from_runtime_parts(Some(TRANSFER_CONTRACT), None, 1); + assert_eq!(context.principal, None); + } + + #[test] + fn nested_contract_call_keeps_immediate_contract_caller() { + let pk = moonlight_key(2); + let caller = contract(7); + let context = + CallContext::from_runtime_parts(Some(caller), Some(pk), 2); + assert_eq!(context.principal, Some(Principal::Contract(caller))); + } + + #[test] + fn nested_transfer_call_is_not_rewritten_to_public_sender() { + let pk = moonlight_key(3); + let context = CallContext::from_runtime_parts( + Some(TRANSFER_CONTRACT), + Some(pk), + 2, + ); + assert_eq!( + context.principal, + Some(Principal::Contract(TRANSFER_CONTRACT)) + ); + } + + #[test] + fn no_caller_has_no_observed_actor() { + let pk = moonlight_key(4); + let context = CallContext::from_runtime_parts(None, Some(pk), 0); + assert_eq!(context.principal, None); + } +} diff --git a/standards/dusk-contract-standards/src/core/error.rs b/standards/dusk-contract-standards/src/core/error.rs new file mode 100644 index 0000000..5874cf3 --- /dev/null +++ b/standards/dusk-contract-standards/src/core/error.rs @@ -0,0 +1,22 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Stable error strings used by the reusable primitives. + +pub const ALREADY_INITIALIZED: &str = "DuskStandards: already initialized"; +pub const NOT_INITIALIZED: &str = "DuskStandards: not initialized"; +pub const UNAUTHORIZED: &str = "DuskStandards: unauthorized"; +pub const INVALID_OWNER: &str = "DuskStandards: invalid owner"; +pub const INVALID_PRINCIPAL: &str = "DuskStandards: invalid principal"; +pub const ZERO_PRINCIPAL: &str = "DuskStandards: zero principal"; +pub const REPLAY: &str = "DuskStandards: replay"; +pub const INVALID_NONCE: &str = "DuskStandards: invalid nonce"; +pub const DELAY_NOT_ELAPSED: &str = "DuskStandards: delay not elapsed"; +pub const OPERATION_DONE: &str = "DuskStandards: operation already done"; +pub const OPERATION_UNKNOWN: &str = "DuskStandards: operation unknown"; +pub const INVALID_OPERATION: &str = "DuskStandards: invalid operation"; +pub const OVERFLOW: &str = "DuskStandards: arithmetic overflow"; +pub const UNDERFLOW: &str = "DuskStandards: arithmetic underflow"; diff --git a/standards/dusk-contract-standards/src/core/mod.rs b/standards/dusk-contract-standards/src/core/mod.rs new file mode 100644 index 0000000..14d2602 --- /dev/null +++ b/standards/dusk-contract-standards/src/core/mod.rs @@ -0,0 +1,18 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Shared identity, context, replay, and error primitives. + +pub mod context; +pub mod error; +pub mod nonce; +pub mod principal; +pub mod replay; + +pub use context::CallContext; +pub use nonce::{NonceDomain, NonceEntry, NonceManager, NonceQuery}; +pub use principal::{Principal, PrincipalKind, BLS_PUBLIC_KEY_BYTES}; +pub use replay::{ReplayEntry, ReplayGuard, ReplayKey}; diff --git a/standards/dusk-contract-standards/src/core/nonce.rs b/standards/dusk-contract-standards/src/core/nonce.rs new file mode 100644 index 0000000..d791ccd --- /dev/null +++ b/standards/dusk-contract-standards/src/core/nonce.rs @@ -0,0 +1,136 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Monotonic nonce management for replay-protected authorizations. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::{error, Principal}; + +/// Domain separator for a nonce stream. +/// +/// A contract can keep independent streams for permits, role delegations, +/// Phoenix signature authorizations, upgrade approvals, or any other signed +/// flow. +pub type NonceDomain = [u8; 32]; + +/// Monotonic nonces keyed by `(principal, domain)`. +#[derive(Clone, Debug, Default)] +pub struct NonceManager { + nonces: BTreeMap<(Principal, NonceDomain), u64>, +} + +/// Portable nonce snapshot entry for migration or tests. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct NonceEntry { + /// Principal owning this nonce stream. + pub principal: Principal, + /// Stream domain. + pub domain: NonceDomain, + /// Current nonce. + pub nonce: u64, +} + +/// Nonce query shared by reference contracts and clients. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct NonceQuery { + /// Principal owning this nonce stream. + pub principal: Principal, + /// Stream domain. + pub domain: NonceDomain, +} + +impl NonceManager { + /// Creates an empty nonce manager. + pub const fn new() -> Self { + Self { + nonces: BTreeMap::new(), + } + } + + /// Returns the current nonce. + pub fn current(&self, principal: Principal, domain: NonceDomain) -> u64 { + self.nonces.get(&(principal, domain)).copied().unwrap_or(0) + } + + /// Consumes exactly `expected` and increments the stream. + pub fn consume( + &mut self, + principal: Principal, + domain: NonceDomain, + expected: u64, + ) -> u64 { + let current = self.current(principal, domain); + if current != expected { + panic!("{}", error::INVALID_NONCE); + } + let next = current.checked_add(1).expect(error::OVERFLOW); + self.nonces.insert((principal, domain), next); + next + } + + /// Consumes the current nonce and returns the consumed value. + pub fn use_next( + &mut self, + principal: Principal, + domain: NonceDomain, + ) -> u64 { + let current = self.current(principal, domain); + self.consume(principal, domain, current); + current + } + + /// Invalidates all nonces below `new_nonce`. + pub fn invalidate_until( + &mut self, + principal: Principal, + domain: NonceDomain, + new_nonce: u64, + ) { + if new_nonce < self.current(principal, domain) { + panic!("{}", error::INVALID_NONCE); + } + self.nonces.insert((principal, domain), new_nonce); + } + + /// Exports all nonce streams. + pub fn entries(&self) -> Vec { + self.nonces + .iter() + .map(|((principal, domain), nonce)| NonceEntry { + principal: *principal, + domain: *domain, + nonce: *nonce, + }) + .collect() + } + + /// Imports nonce streams, keeping the greatest value for duplicates. + pub fn import_entries( + &mut self, + entries: impl IntoIterator, + ) { + for entry in entries { + let current = self.current(entry.principal, entry.domain); + if entry.nonce > current { + self.nonces + .insert((entry.principal, entry.domain), entry.nonce); + } + } + } +} diff --git a/standards/dusk-contract-standards/src/core/principal.rs b/standards/dusk-contract-standards/src/core/principal.rs new file mode 100644 index 0000000..15a531e --- /dev/null +++ b/standards/dusk-contract-standards/src/core/principal.rs @@ -0,0 +1,222 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Dusk principal identity. + +use alloc::vec::Vec; +use core::cmp::Ordering; + +use bytecheck::CheckBytes; +use dusk_core::abi::ContractId; +use dusk_core::signatures::bls::PublicKey as BlsPublicKey; +use dusk_core::signatures::schnorr::PublicKey as SchnorrPublicKey; +use dusk_core::JubJubAffine; +use rkyv::{Archive, Deserialize, Serialize}; + +/// Raw byte length of a Dusk Moonlight BLS public key. +pub const BLS_PUBLIC_KEY_BYTES: usize = 193; + +/// Coarse principal kind for policy decisions and event indexing. +#[derive( + Archive, + Serialize, + Deserialize, + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub enum PrincipalKind { + /// Transparent Moonlight public account. + Moonlight, + /// Privacy-preserving Phoenix authorization identity. + Phoenix, + /// Dusk contract id. + Contract, +} + +/// An actor that can own assets, hold roles, or authorize contract actions. +/// +/// Phoenix identity is represented as compressed Schnorr public-key bytes. The +/// primitive deliberately does not pretend that a Phoenix transaction exposes +/// an Ethereum-like caller address. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[archive_attr(derive(CheckBytes))] +pub enum Principal { + /// Transparent Moonlight public account, stored as raw BLS public-key + /// bytes. + Moonlight([u8; BLS_PUBLIC_KEY_BYTES]), + /// Phoenix Schnorr authorization identity. + Phoenix([u8; 32]), + /// Contract account. + Contract(ContractId), +} + +impl Principal { + /// Returns the principal kind. + pub const fn kind(&self) -> PrincipalKind { + match self { + Self::Moonlight(_) => PrincipalKind::Moonlight, + Self::Phoenix(_) => PrincipalKind::Phoenix, + Self::Contract(_) => PrincipalKind::Contract, + } + } + + /// Constructs a Moonlight principal from a public key. + pub fn moonlight(pk: &BlsPublicKey) -> Self { + Self::Moonlight(pk.to_raw_bytes()) + } + + /// Constructs a contract principal from a contract id. + pub const fn contract(id: ContractId) -> Self { + Self::Contract(id) + } + + /// Constructs a Phoenix principal from raw compressed public-key bytes. + pub const fn phoenix(public_key_bytes: [u8; 32]) -> Self { + Self::Phoenix(public_key_bytes) + } + + /// Constructs a Phoenix principal from a Schnorr public key. + pub fn phoenix_public_key(pk: &SchnorrPublicKey) -> Self { + Self::Phoenix(JubJubAffine::from(pk.as_ref()).to_bytes()) + } + + /// Returns true when this is the reserved all-zero principal value. + pub fn is_zero(&self) -> bool { + match self { + Self::Moonlight(bytes) => bytes.iter().all(|byte| *byte == 0), + Self::Phoenix(bytes) => bytes.iter().all(|byte| *byte == 0), + Self::Contract(id) => id.to_bytes().iter().all(|byte| *byte == 0), + } + } + + /// Stable byte representation used for hashing and replay keys. + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + out.push(match self { + Self::Moonlight(_) => 0, + Self::Phoenix(_) => 1, + Self::Contract(_) => 2, + }); + match self { + Self::Moonlight(bytes) => out.extend_from_slice(bytes), + Self::Phoenix(bytes) => out.extend_from_slice(bytes), + Self::Contract(id) => out.extend_from_slice(&id.to_bytes()), + } + out + } +} + +impl From for Principal { + fn from(value: BlsPublicKey) -> Self { + Self::moonlight(&value) + } +} + +impl From for Principal { + fn from(value: ContractId) -> Self { + Self::Contract(value) + } +} + +impl PartialOrd for Principal { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Principal { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Moonlight(lhs), Self::Moonlight(rhs)) => lhs.cmp(rhs), + (Self::Phoenix(lhs), Self::Phoenix(rhs)) => lhs.cmp(rhs), + (Self::Contract(lhs), Self::Contract(rhs)) => lhs.cmp(rhs), + _ => self.kind().cmp(&other.kind()), + } + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Principal { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("Principal", 2)?; + state.serialize_field("kind", &self.kind())?; + match self { + Self::Moonlight(bytes) => { + state.serialize_field("bytes", &bytes.as_slice())? + } + Self::Phoenix(bytes) => { + state.serialize_field("bytes", &bytes.as_slice())? + } + Self::Contract(id) => { + state.serialize_field("bytes", &id.to_bytes().as_slice())? + } + } + state.end() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Principal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct PrincipalJson { + kind: PrincipalKind, + bytes: Vec, + } + + let principal = + ::deserialize(deserializer)?; + match principal.kind { + PrincipalKind::Moonlight => { + let bytes: [u8; BLS_PUBLIC_KEY_BYTES] = + principal.bytes.try_into().map_err(|bytes: Vec| { + serde::de::Error::invalid_length( + bytes.len(), + &"193 Moonlight public-key bytes", + ) + })?; + Ok(Self::Moonlight(bytes)) + } + PrincipalKind::Phoenix => { + let bytes: [u8; 32] = + principal.bytes.try_into().map_err(|bytes: Vec| { + serde::de::Error::invalid_length( + bytes.len(), + &"32 Phoenix public-key bytes", + ) + })?; + Ok(Self::Phoenix(bytes)) + } + PrincipalKind::Contract => { + let bytes: [u8; 32] = + principal.bytes.try_into().map_err(|bytes: Vec| { + serde::de::Error::invalid_length( + bytes.len(), + &"32 contract-id bytes", + ) + })?; + Ok(Self::Contract(ContractId::from_bytes(bytes))) + } + } + } +} diff --git a/standards/dusk-contract-standards/src/core/replay.rs b/standards/dusk-contract-standards/src/core/replay.rs new file mode 100644 index 0000000..c2edcbb --- /dev/null +++ b/standards/dusk-contract-standards/src/core/replay.rs @@ -0,0 +1,80 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Replay protection for signed authorizations. + +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use bytecheck::CheckBytes; +use rkyv::{Archive, Deserialize, Serialize}; + +use crate::core::error; +use crate::core::principal::Principal; + +/// Replay key supplied by an authorization flow. +pub type ReplayKey = [u8; 32]; + +/// Tracks consumed replay keys per principal. +#[derive(Clone, Debug, Default)] +pub struct ReplayGuard { + used: BTreeSet<(Principal, ReplayKey)>, +} + +/// Portable snapshot entry for migration or tests. +#[derive( + Archive, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[archive_attr(derive(CheckBytes))] +pub struct ReplayEntry { + /// Principal that consumed the key. + pub principal: Principal, + /// Consumed key. + pub key: ReplayKey, +} + +impl ReplayGuard { + /// Creates an empty replay guard. + pub const fn new() -> Self { + Self { + used: BTreeSet::new(), + } + } + + /// Returns whether a key has been consumed. + pub fn is_used(&self, principal: Principal, key: ReplayKey) -> bool { + self.used.contains(&(principal, key)) + } + + /// Consumes a replay key or panics when it was already used. + pub fn consume(&mut self, principal: Principal, key: ReplayKey) { + if !self.used.insert((principal, key)) { + panic!("{}", error::REPLAY); + } + } + + /// Exports all entries for migration or inspection. + pub fn entries(&self) -> Vec { + self.used + .iter() + .map(|(principal, key)| ReplayEntry { + principal: *principal, + key: *key, + }) + .collect() + } + + /// Imports entries, rejecting conflicting duplicates through the set. + pub fn import_entries( + &mut self, + entries: impl IntoIterator, + ) { + for entry in entries { + self.used.insert((entry.principal, entry.key)); + } + } +} diff --git a/standards/dusk-contract-standards/src/lib.rs b/standards/dusk-contract-standards/src/lib.rs index 1a9fe0e..5f625e7 100644 --- a/standards/dusk-contract-standards/src/lib.rs +++ b/standards/dusk-contract-standards/src/lib.rs @@ -4,6 +4,37 @@ // // Copyright (c) DUSK NETWORK. All rights reserved. -//! Dusk-native reusable contract standards. +//! Dusk-native reusable contract standards and examples. +//! +//! This crate intentionally does not provide EVM API compatibility. It ports +//! the security concepts into Dusk's account, contract, and call model. #![no_std] +#![cfg_attr( + not(all(target_family = "wasm", feature = "contract")), + deny(unused_crate_dependencies) +)] +#![deny(unused_extern_crates)] + +extern crate alloc; + +use dusk_forge as _; + +#[cfg(test)] +use dusk_data_driver as _; +#[cfg(test)] +use dusk_vm as _; +#[cfg(test)] +use proptest as _; +#[cfg(test)] +use rand as _; +#[cfg(test)] +use serde_json as _; +#[cfg(feature = "serde")] +use serde_with as _; +#[cfg(feature = "serde")] +use time as _; + +pub mod auth; +pub mod core; +pub mod security; diff --git a/standards/dusk-contract-standards/src/security/mod.rs b/standards/dusk-contract-standards/src/security/mod.rs new file mode 100644 index 0000000..d711556 --- /dev/null +++ b/standards/dusk-contract-standards/src/security/mod.rs @@ -0,0 +1,11 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Security primitives. + +pub mod reentrancy_guard; + +pub use reentrancy_guard::{ReentrancyGuard, ReentrancyLock}; diff --git a/standards/dusk-contract-standards/src/security/reentrancy_guard.rs b/standards/dusk-contract-standards/src/security/reentrancy_guard.rs new file mode 100644 index 0000000..2a86036 --- /dev/null +++ b/standards/dusk-contract-standards/src/security/reentrancy_guard.rs @@ -0,0 +1,68 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Reentrancy guard. + +/// Reentrancy guard module. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ReentrancyGuard { + entered: bool, +} + +/// Active reentrancy lock. +/// +/// Dropping the lock exits the guarded section. This keeps native tests and +/// non-aborting hosts from leaving the guard stuck when a guarded closure +/// panics. +#[derive(Debug)] +pub struct ReentrancyLock<'a> { + entered: &'a mut bool, +} + +impl ReentrancyGuard { + /// Creates a new guard. + pub const fn new() -> Self { + Self { entered: false } + } + + /// Returns whether the guard is currently entered. + pub const fn entered(&self) -> bool { + self.entered + } + + /// Enters the guarded section. + pub fn enter(&mut self) { + if self.entered { + panic!("ReentrancyGuard: reentrant call"); + } + self.entered = true; + } + + /// Exits the guarded section. + pub fn exit(&mut self) { + self.entered = false; + } + + /// Enters the guarded section and returns an RAII lock. + pub fn lock(&mut self) -> ReentrancyLock<'_> { + self.enter(); + ReentrancyLock { + entered: &mut self.entered, + } + } + + /// Runs a closure in a guarded section. + pub fn run(&mut self, f: impl FnOnce() -> R) -> R { + let _lock = self.lock(); + f() + } +} + +impl Drop for ReentrancyLock<'_> { + fn drop(&mut self) { + *self.entered = false; + } +}