diff --git a/contracts/multisig/src/lib.rs b/contracts/multisig/src/lib.rs
index 5be7b6e7..7354eb1a 100644
--- a/contracts/multisig/src/lib.rs
+++ b/contracts/multisig/src/lib.rs
@@ -2,9 +2,7 @@
mod storage;
-use soroban_sdk::{
- contract, contracterror, contractimpl, Address, Bytes, Env, Symbol, Vec,
-};
+use soroban_sdk::{contract, contracterror, contractimpl, Address, Bytes, Env, Symbol, Vec};
use storage::{
bump_instance, increment_id, load_config, load_proposal, save_config, save_proposal, Config,
PolicySnapshot, Proposal,
@@ -49,11 +47,7 @@ impl MultisigContract {
owners: Vec
,
threshold: u32,
) -> Result<(), Error> {
- if env
- .storage()
- .instance()
- .has(&storage::DataKey::Config)
- {
+ if env.storage().instance().has(&storage::DataKey::Config) {
return Err(Error::AlreadyInitialized);
}
if threshold == 0 || threshold as usize > owners.len() as usize {
@@ -186,13 +180,8 @@ impl MultisigContract {
// Invoke the target contract. The args are already encoded – pass as
// a raw Vec by deserialising from Bytes via the environment.
- let args_vec: soroban_sdk::Vec =
- soroban_sdk::Vec::from_array(&env, []);
- env.invoke_contract::(
- &proposal.target,
- &proposal.function,
- args_vec,
- );
+ let args_vec: soroban_sdk::Vec = soroban_sdk::Vec::from_array(&env, []);
+ env.invoke_contract::(&proposal.target, &proposal.function, args_vec);
Ok(())
}
diff --git a/contracts/multisig/tests/multisig_test.rs b/contracts/multisig/tests/multisig_test.rs
index 0978bc59..5f077008 100644
--- a/contracts/multisig/tests/multisig_test.rs
+++ b/contracts/multisig/tests/multisig_test.rs
@@ -1,9 +1,7 @@
#![cfg(test)]
use soroban_sdk::{
- contract, contractimpl,
- testutils::Address as _,
- vec, Address, Bytes, Env, Symbol,
+ contract, contractimpl, testutils::Address as _, vec, Address, Bytes, Env, Symbol,
};
use multisig::{MultisigContract, MultisigContractClient};
@@ -24,7 +22,15 @@ impl NoopContract {
// Helpers
// ---------------------------------------------------------------------------
-fn setup(env: &Env) -> (MultisigContractClient<'_>, Address, Address, Address, Address) {
+fn setup(
+ env: &Env,
+) -> (
+ MultisigContractClient<'_>,
+ Address,
+ Address,
+ Address,
+ Address,
+) {
let contract_id = env.register(MultisigContract, ());
let client = MultisigContractClient::new(env, &contract_id);
@@ -60,7 +66,12 @@ fn basic_proposal_reaches_threshold() {
let (client, _admin, owner_a, owner_b, _owner_c) = setup(&env);
let target = register_noop(&env);
- let id = client.propose(&owner_a, &target, &Symbol::new(&env, "noop"), &empty_args(&env));
+ let id = client.propose(
+ &owner_a,
+ &target,
+ &Symbol::new(&env, "noop"),
+ &empty_args(&env),
+ );
assert_eq!(client.approve(&owner_b, &id), 2);
client.execute(&id);
@@ -76,7 +87,12 @@ fn reconfigure_does_not_affect_open_proposal() {
let (client, admin, owner_a, owner_b, _owner_c) = setup(&env);
let target = register_noop(&env);
- let id = client.propose(&owner_a, &target, &Symbol::new(&env, "noop"), &empty_args(&env));
+ let id = client.propose(
+ &owner_a,
+ &target,
+ &Symbol::new(&env, "noop"),
+ &empty_args(&env),
+ );
// Reconfigure: remove owner_b, add a brand-new owner, keep threshold 2.
let new_owner = Address::generate(&env);
@@ -125,7 +141,12 @@ fn execute_before_threshold_fails() {
env.mock_all_auths();
let (client, _admin, owner_a, _owner_b, _owner_c) = setup(&env);
let target = register_noop(&env);
- let id = client.propose(&owner_a, &target, &Symbol::new(&env, "noop"), &empty_args(&env));
+ let id = client.propose(
+ &owner_a,
+ &target,
+ &Symbol::new(&env, "noop"),
+ &empty_args(&env),
+ );
// Only 1 approval (threshold = 2).
let result = client.try_execute(&id);
assert!(result.is_err());
@@ -138,7 +159,12 @@ fn duplicate_approval_rejected() {
env.mock_all_auths();
let (client, _admin, owner_a, _owner_b, _owner_c) = setup(&env);
let target = register_noop(&env);
- let id = client.propose(&owner_a, &target, &Symbol::new(&env, "noop"), &empty_args(&env));
+ let id = client.propose(
+ &owner_a,
+ &target,
+ &Symbol::new(&env, "noop"),
+ &empty_args(&env),
+ );
let result = client.try_approve(&owner_a, &id);
assert!(result.is_err());
}
@@ -152,7 +178,12 @@ fn threshold_snapshot_isolation() {
let target = register_noop(&env);
// Propose with threshold=2 snapshotted.
- let id = client.propose(&owner_a, &target, &Symbol::new(&env, "noop"), &empty_args(&env));
+ let id = client.propose(
+ &owner_a,
+ &target,
+ &Symbol::new(&env, "noop"),
+ &empty_args(&env),
+ );
// Raise threshold to 3 via reconfigure.
let owners = vec![&env, owner_a.clone(), owner_b.clone(), owner_c.clone()];
diff --git a/contracts/stellar_insights/src/binding.rs b/contracts/stellar_insights/src/binding.rs
index 7e9a43c1..716124b3 100644
--- a/contracts/stellar_insights/src/binding.rs
+++ b/contracts/stellar_insights/src/binding.rs
@@ -14,4 +14,4 @@ pub fn signed_payload(
payload.append(&Bytes::from_array(env, &snapshot_hash.to_array()));
payload.append(&Bytes::from_array(env, &source_data_hash.to_array()));
payload
-}
\ No newline at end of file
+}
diff --git a/contracts/tests/privilege_escalation_test.rs b/contracts/tests/privilege_escalation_test.rs
index c3555216..7d55f182 100644
--- a/contracts/tests/privilege_escalation_test.rs
+++ b/contracts/tests/privilege_escalation_test.rs
@@ -1,9 +1,52 @@
+//! Privilege Escalation Verification Suite
+//! Refer to `contracts/upgrade/tests/privilege_escalation_test.rs` for full Soroban host test execution.
+
#[cfg(test)]
mod tests {
+ /// Verify that contract privilege graph documentation specifies all required formal invariants
+ /// across the contract inventory.
#[test]
- fn test_privilege_escalation_docs_and_artifacts() {
- assert!(std::path::Path::new("../docs/contract-privilege-graph.md").exists() || std::path::Path::new("docs/contract-privilege-graph.md").exists());
- assert!(std::path::Path::new("upgrade/src/scope.rs").exists() || std::path::Path::new("contracts/upgrade/src/scope.rs").exists());
- assert!(std::path::Path::new("upgrade/tests/privilege_escalation_test.rs").exists() || std::path::Path::new("contracts/upgrade/tests/privilege_escalation_test.rs").exists());
+ fn test_privilege_graph_formal_invariants_specification() {
+ let doc_path = if std::path::Path::new("docs/contract-privilege-graph.md").exists() {
+ "docs/contract-privilege-graph.md"
+ } else {
+ "../docs/contract-privilege-graph.md"
+ };
+ let content = std::fs::read_to_string(doc_path).expect("failed to read privilege graph doc");
+
+ // Verify all contract systems are audited in the privilege graph
+ assert!(content.contains("UpgradeManager"));
+ assert!(content.contains("MultisigContract"));
+ assert!(content.contains("StellarInsights"));
+ assert!(content.contains("TimeLockedTransactions"));
+ assert!(content.contains("EscrowContract"));
+ assert!(content.contains("TokenSwap"));
+ assert!(content.contains("Analytics"));
+
+ // Verify formal invariant rules are documented
+ assert!(content.contains("Governance Root Isolation Invariant"));
+ assert!(content.contains("Upgrade Manager Non-Self-Modification Invariant"));
+ assert!(content.contains("Transitive Authority Non-Redirection Invariant"));
+ assert!(content.contains("Non-Delegation of Governance Authority Invariant"));
+ assert!(content.contains("UpgradeManagerAlreadySet"));
+ }
+
+ /// Verify that scope.rs defines formal invariants and restrictions.
+ #[test]
+ fn test_scope_formal_specification() {
+ let scope_path = if std::path::Path::new("contracts/upgrade/src/scope.rs").exists() {
+ "contracts/upgrade/src/scope.rs"
+ } else {
+ "upgrade/src/scope.rs"
+ };
+ let content = std::fs::read_to_string(scope_path).expect("failed to read scope.rs");
+
+ assert!(content.contains("TargetScope"));
+ assert!(content.contains("validate_target_scope"));
+ assert!(content.contains("is_restricted_target"));
+ assert!(content.contains("TargetOutOfScope"));
+ assert!(content.contains("Governance Root Isolation Invariant"));
+ assert!(content.contains("Upgrade Manager Non-Self-Modification Invariant"));
+ assert!(content.contains("Transitive Authority Non-Redirection Invariant"));
}
}
diff --git a/contracts/upgrade/src/scope.rs b/contracts/upgrade/src/scope.rs
index 41a5d9c1..d8762db4 100644
--- a/contracts/upgrade/src/scope.rs
+++ b/contracts/upgrade/src/scope.rs
@@ -2,14 +2,36 @@ use soroban_sdk::{Address, Env};
use crate::{Error, GovernanceConfig};
+/// Formal invariants governing target scope validation and transitive authorization boundaries:
+///
+/// 1. **Governance Root Isolation Invariant**:
+/// $$\forall t \in \text{Address}, t = \text{config.governance} \implies \text{validate\_target\_scope}(t) = \text{Err}(\text{TargetOutOfScope})$$
+/// Prevents upgrade proposals from targeting the Governance multi-signature contract. This guarantees that
+/// governance rules, threshold configurations, and owner sets cannot be overridden via upgrade proposals.
+///
+/// 2. **Upgrade Manager Non-Self-Modification Invariant**:
+/// $$\forall t \in \text{Address}, t = \text{env.current\_contract\_address}() \implies \text{validate\_target\_scope}(t) = \text{Err}(\text{TargetOutOfScope})$$
+/// Prevents `UpgradeManager` from modifying its own executable WASM. This guarantees that proposal lifecycles,
+/// approver thresholds, storage test evidence validation, and target scope checks cannot be bypassed or dismantled.
+///
+/// 3. **Transitive Authority Non-Redirection Invariant**:
+/// Governed target contracts (such as `StellarInsights`) bind their `UpgradeManager` reference during initialization
+/// as a write-once value (`UpgradeManagerAlreadySet`). Code installed at a governed target cannot reassign or
+/// redirect upgrade authority to rogue managers, nor can it bypass the UpgradeManager authentication required for
+/// subsequent `governance_upgrade` or `migrate_schema` calls.
+///
+/// 4. **Non-Delegation of Governance Authority Invariant**:
+/// Governed target contracts hold domain-specific capabilities (e.g. snapshot storage) and cannot acquire
+/// or delegate governance administrative powers over other contracts in the privilege graph.
pub struct TargetScope;
impl TargetScope {
/// Validates whether a target contract address is eligible for upgrade proposals.
///
- /// # Restrictions
- /// - An upgrade proposal MUST NOT target the Governance contract (`config.governance`).
- /// - An upgrade proposal MUST NOT target the UpgradeManager contract itself (`env.current_contract_address()`).
+ /// # Enforced Invariants
+ /// - **Governance Isolation**: Proposal MUST NOT target the Governance contract (`config.governance`).
+ /// - **Manager Self-Upgrade Defense**: Proposal MUST NOT target the UpgradeManager contract itself (`env.current_contract_address()`).
+ /// - **Transitive Scope Boundary**: Prevents capture or replacement of core authorization infrastructure.
pub fn validate_target_scope(
env: &Env,
target: &Address,
@@ -21,7 +43,7 @@ impl TargetScope {
Ok(())
}
- /// Returns `true` if the target address matches any restricted contract address.
+ /// Returns `true` if the target address matches any restricted contract address in the privilege graph.
pub fn is_restricted_target(env: &Env, target: &Address, governance: &Address) -> bool {
if target == governance {
return true;
diff --git a/contracts/upgrade/tests/privilege_escalation_test.rs b/contracts/upgrade/tests/privilege_escalation_test.rs
index 5d2001e5..f87d0df7 100644
--- a/contracts/upgrade/tests/privilege_escalation_test.rs
+++ b/contracts/upgrade/tests/privilege_escalation_test.rs
@@ -1,13 +1,29 @@
#![cfg(feature = "testutils")]
+mod test_support;
+
use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, Vec};
-use upgrade::{Error, UpgradeManager, UpgradeManagerArgs, UpgradeManagerClient};
+use test_support::{
+ expect_manager_error, expect_target_error, fixture_wasm, hash, sign, signed_payload,
+ signing_key, ProductionClient, TargetError, V2Client,
+};
+use upgrade::{
+ Error as ManagerError, ProposalStatus, UpgradeManager, UpgradeManagerArgs, UpgradeManagerClient,
+};
fn dummy_wasm_hash(env: &Env, val: u8) -> BytesN<32> {
BytesN::from_array(env, &[val; 32])
}
-fn setup_manager(env: &Env) -> (Address, Address, Address, Address, UpgradeManagerClient<'static>) {
+fn setup_manager(
+ env: &Env,
+) -> (
+ Address,
+ Address,
+ Address,
+ Address,
+ UpgradeManagerClient<'static>,
+) {
let governance = Address::generate(env);
let approver1 = Address::generate(env);
let approver2 = Address::generate(env);
@@ -28,9 +44,10 @@ fn test_legitimate_target_upgrade_creation_succeeds() {
let (_governance, _approver1, _approver2, _manager_id, manager) = setup_manager(&env);
let valid_target = Address::generate(&env);
- let proposal_id = manager
- .mock_all_auths()
- .create_proposal(&valid_target, &dummy_wasm_hash(&env, 1), &1, &2);
+ let proposal_id =
+ manager
+ .mock_all_auths()
+ .create_proposal(&valid_target, &dummy_wasm_hash(&env, 1), &1, &2);
let proposal = manager.get_proposal(&proposal_id);
assert_eq!(proposal.target, valid_target);
@@ -43,12 +60,15 @@ fn test_self_upgrade_escalation_is_blocked() {
let (_governance, _approver1, _approver2, manager_id, manager) = setup_manager(&env);
// Attempt to propose upgrading the UpgradeManager itself
- let result = manager
- .mock_all_auths()
- .try_create_proposal(&manager_id, &dummy_wasm_hash(&env, 0xAA), &1, &2);
+ let result = manager.mock_all_auths().try_create_proposal(
+ &manager_id,
+ &dummy_wasm_hash(&env, 0xAA),
+ &1,
+ &2,
+ );
match result {
- Err(Ok(error)) => assert_eq!(error, Error::TargetOutOfScope),
+ Err(Ok(error)) => assert_eq!(error, ManagerError::TargetOutOfScope),
other => panic!("expected TargetOutOfScope error, got {:?}", other),
}
}
@@ -59,12 +79,15 @@ fn test_governance_upgrade_escalation_is_blocked() {
let (governance, _approver1, _approver2, _manager_id, manager) = setup_manager(&env);
// Attempt to propose upgrading the Governance contract address
- let result = manager
- .mock_all_auths()
- .try_create_proposal(&governance, &dummy_wasm_hash(&env, 0xBB), &1, &2);
+ let result = manager.mock_all_auths().try_create_proposal(
+ &governance,
+ &dummy_wasm_hash(&env, 0xBB),
+ &1,
+ &2,
+ );
match result {
- Err(Ok(error)) => assert_eq!(error, Error::TargetOutOfScope),
+ Err(Ok(error)) => assert_eq!(error, ManagerError::TargetOutOfScope),
other => panic!("expected TargetOutOfScope error, got {:?}", other),
}
}
@@ -75,9 +98,10 @@ fn test_execution_blocked_before_threshold_reached() {
let (_governance, approver1, _approver2, _manager_id, manager) = setup_manager(&env);
let valid_target = Address::generate(&env);
- let proposal_id = manager
- .mock_all_auths()
- .create_proposal(&valid_target, &dummy_wasm_hash(&env, 2), &1, &2);
+ let proposal_id =
+ manager
+ .mock_all_auths()
+ .create_proposal(&valid_target, &dummy_wasm_hash(&env, 2), &1, &2);
let evidence = dummy_wasm_hash(&env, 9);
manager
@@ -92,7 +116,160 @@ fn test_execution_blocked_before_threshold_reached() {
// Execution must be rejected because threshold (2) is not met
let exec_result = manager.try_execute_upgrade(&proposal_id);
match exec_result {
- Err(Ok(error)) => assert_eq!(error, Error::ThresholdNotReached),
+ Err(Ok(error)) => assert_eq!(error, ManagerError::ThresholdNotReached),
other => panic!("expected ThresholdNotReached error, got {:?}", other),
}
}
+
+#[test]
+fn test_governed_target_upgrade_anti_redirection_and_exploit_rejection() {
+ let env = Env::default();
+ let governance = Address::generate(&env);
+ let approver_one = Address::generate(&env);
+ let approver_two = Address::generate(&env);
+ let approvers = Vec::from_array(&env, [approver_one.clone(), approver_two.clone()]);
+
+ let manager_wasm = fixture_wasm("upgrade");
+ let manager_args = UpgradeManagerArgs::__constructor(&governance, &approvers, &2);
+ env.mock_all_auths_allowing_non_root_auth();
+ let manager_id = env.register(manager_wasm.as_slice(), manager_args);
+ env.set_auths(&[]);
+ let manager = UpgradeManagerClient::new(&env, &manager_id);
+
+ // Deploy and bootstrap real StellarInsights v1 target
+ let admin = Address::generate(&env);
+ let (signing_public_key, signing_key) = signing_key(&env);
+ let v1_wasm = fixture_wasm("stellar_insights");
+ let target_id = env.register(v1_wasm.as_slice(), ());
+ let v1 = ProductionClient::new(&env, &target_id);
+
+ v1.mock_all_auths().initialize(&admin, &signing_public_key);
+ v1.mock_all_auths().set_upgrade_manager(&manager_id);
+
+ // Establish legitimate pre-upgrade protocol state
+ let snapshot_hash = hash(&env, 1);
+ let source_data_hash = hash(&env, 11);
+ let payload = signed_payload(&env, 1, &snapshot_hash, &source_data_hash);
+ let signature = sign(&env, &signing_key, &payload);
+ v1.mock_all_auths()
+ .submit_snapshot(&1, &snapshot_hash, &source_data_hash, &signature, &admin);
+ assert_eq!(v1.latest_epoch(), 1);
+
+ // Upload candidate V2 wasm and execute governed upgrade
+ let v2_wasm = fixture_wasm("stellar_insights_v2");
+ let v2_hash = env.deployer().upload_contract_wasm(v2_wasm.as_slice());
+
+ let proposal_id = manager
+ .mock_all_auths()
+ .create_proposal(&target_id, &v2_hash, &1, &2);
+ let evidence = hash(&env, 42);
+ manager
+ .mock_all_auths()
+ .record_realistic_storage_test(&proposal_id, &evidence);
+ manager
+ .mock_all_auths()
+ .approve_upgrade(&proposal_id, &approver_one);
+ manager
+ .mock_all_auths()
+ .approve_upgrade(&proposal_id, &approver_two);
+
+ assert_eq!(
+ manager.get_proposal(&proposal_id).status,
+ ProposalStatus::Approved
+ );
+
+ // Execute the upgrade via the real manager flow
+ manager.execute_upgrade(&proposal_id);
+ assert_eq!(
+ manager.get_proposal(&proposal_id).status,
+ ProposalStatus::UpgradeApplied
+ );
+
+ // Bind V2 client to the upgraded target instance
+ let v2 = V2Client::new(&env, &target_id);
+
+ // EXPLOIT ATTEMPT 1: Upgraded target cannot redirect upgrade authority to a rogue manager
+ let rogue_manager = Address::generate(&env);
+ expect_target_error(
+ v2.mock_all_auths().try_set_upgrade_manager(&rogue_manager),
+ TargetError::UpgradeManagerAlreadySet,
+ );
+
+ // EXPLOIT ATTEMPT 2: Direct unauthenticated or rogue caller cannot invoke governance_upgrade
+ let rogue_wasm_hash = hash(&env, 0xEE);
+ match v2.try_governance_upgrade(&rogue_wasm_hash, &2, &3) {
+ Err(Err(soroban_sdk::InvokeError::Abort)) => {}
+ other => panic!("unauthorized direct governance_upgrade was not aborted: {other:?}"),
+ }
+
+ // EXPLOIT ATTEMPT 3: Direct unauthorized migrate_schema is rejected
+ match v2.try_migrate_schema(&1, &2) {
+ Err(Err(soroban_sdk::InvokeError::Abort)) => {}
+ other => panic!("unauthorized direct migrate_schema was not aborted: {other:?}"),
+ }
+
+ // Legitimate migration succeeds via UpgradeManager
+ manager.migrate_upgrade(&proposal_id);
+ assert_eq!(
+ manager.get_proposal(&proposal_id).status,
+ ProposalStatus::Executed
+ );
+
+ // Target continues operating safely under V2 schema
+ assert_eq!(v2.latest_epoch(), 1);
+ let new_epoch = 2u64;
+ let new_snapshot_hash = hash(&env, 2);
+ let new_source_data_hash = hash(&env, 12);
+ let new_payload = signed_payload(&env, new_epoch, &new_snapshot_hash, &new_source_data_hash);
+ let new_signature = sign(&env, &signing_key, &new_payload);
+ v2.mock_all_auths().submit_snapshot(
+ &new_epoch,
+ &new_snapshot_hash,
+ &new_source_data_hash,
+ &new_signature,
+ &admin,
+ );
+ assert_eq!(v2.latest_epoch(), 2);
+}
+
+#[test]
+fn test_transitive_governance_takeover_prevention() {
+ let env = Env::default();
+ let governance = Address::generate(&env);
+ let approver_one = Address::generate(&env);
+ let approvers = Vec::from_array(&env, [approver_one.clone()]);
+
+ let manager_wasm = fixture_wasm("upgrade");
+ let manager_args = UpgradeManagerArgs::__constructor(&governance, &approvers, &1);
+ env.mock_all_auths_allowing_non_root_auth();
+ let manager_id = env.register(manager_wasm.as_slice(), manager_args);
+ env.set_auths(&[]);
+ let manager = UpgradeManagerClient::new(&env, &manager_id);
+
+ // An attacker / non-governance entity cannot create upgrade proposals
+ let _attacker = Address::generate(&env);
+ let valid_target = Address::generate(&env);
+ let fake_hash = dummy_wasm_hash(&env, 5);
+
+ // Proposal without governance authorization fails
+ match manager.try_create_proposal(&valid_target, &fake_hash, &1, &2) {
+ Err(Err(soroban_sdk::InvokeError::Abort)) => {}
+ other => panic!("unauthorized create_proposal was not aborted: {other:?}"),
+ }
+
+ // Upgrading governance directly is rejected by TargetScope
+ expect_manager_error(
+ manager
+ .mock_all_auths()
+ .try_create_proposal(&governance, &fake_hash, &1, &2),
+ ManagerError::TargetOutOfScope,
+ );
+
+ // Upgrading UpgradeManager itself is rejected by TargetScope
+ expect_manager_error(
+ manager
+ .mock_all_auths()
+ .try_create_proposal(&manager_id, &fake_hash, &1, &2),
+ ManagerError::TargetOutOfScope,
+ );
+}
diff --git a/contracts/upgrade/tests/test_support/mod.rs b/contracts/upgrade/tests/test_support/mod.rs
index d54cb9e6..886fe473 100644
--- a/contracts/upgrade/tests/test_support/mod.rs
+++ b/contracts/upgrade/tests/test_support/mod.rs
@@ -85,6 +85,12 @@ pub trait V2Target {
expected_source_schema: u32,
target_schema: u32,
) -> Result<(), TargetError>;
+ fn governance_upgrade(
+ env: Env,
+ new_wasm_hash: BytesN<32>,
+ expected_source_schema: u32,
+ target_schema: u32,
+ ) -> Result<(), TargetError>;
}
#[contractclient(name = "LegacyClient")]
diff --git a/docs/contract-privilege-graph.md b/docs/contract-privilege-graph.md
index 5b0e53db..ad7baff3 100644
--- a/docs/contract-privilege-graph.md
+++ b/docs/contract-privilege-graph.md
@@ -2,75 +2,125 @@
## Overview
-This document specifies the complete cross-contract invocation and authorization graph for the **Stellar Insights** smart contract system. It provides a formal model of privilege reachability, transitive closure, attack surface analysis, and explicit scope boundaries designed to prevent unintended privilege escalation.
+This document specifies the formal cross-contract invocation, authorization graph, and transitive privilege boundaries for the **Stellar Insights** smart contract system. It provides a mathematical and operational model of privilege reachability, transitive closure, attack surface analysis, and explicit scope boundaries designed to prevent unintended privilege escalation.
---
-## Contract Inventory & Entry Points
+## Contract Inventory & Privileged Entry Points
-| Contract | Purpose | Privileged Entry Points | Authorization Model |
-| :--- | :--- | :--- | :--- |
-| **`UpgradeManager`** | Governed contract upgrade orchestration | `create_proposal`, `record_realistic_storage_test`, `approve_upgrade`, `execute_upgrade`, `migrate_upgrade` | Requires `Governance` auth to create proposals, multi-approver signatures to approve, and enforces target scope checks. |
-| **`MultisigContract`** (Governance) | Multi-owner governance wallet & execution engine | `initialize`, `reconfigure`, `propose`, `approve`, `execute` | Requires threshold approval from snapshotted owners to invoke arbitrary downstream functions as the Governance entity. |
-| **`StellarInsights`** | Core protocol data & snapshot storage | `initialize`, `set_upgrade_manager`, `submit_snapshot`, `governance_upgrade`, `migrate_schema` | Admin auth for snapshot submission and upgrade manager setup; `UpgradeManager` auth for WASM upgrade & schema migration. |
-| **`TimeLockedTransactions`** | Time-delayed execution module | `initialize`, `queue_transaction`, `execute_transaction`, `cancel_transaction` | Requires admin/proposer authorization and enforces delay locks before execution. |
-| **`EscrowContract`** | Conditional fund custody & release | `initialize`, `deposit`, `release`, `refund` | Requires authorized depositor/arbiter sign-off to execute funds transfer. |
-| **`TokenSwap`** | Automated liquidity & swap execution | `initialize`, `swap`, `add_liquidity`, `remove_liquidity` | User signatures for swaps; admin for pool parameters. |
-| **`Analytics`** | Off-chain indexer query aggregator | `record_metric`, `query_analytics` | Authorized metric providers; public queries. |
+| Contract | Purpose | Privileged Entry Points | Authorization Model | Transitive Capability Boundary |
+| :--- | :--- | :--- | :--- | :--- |
+| **`UpgradeManager`** | Governed contract upgrade orchestration | `create_proposal`, `record_realistic_storage_test`, `approve_upgrade`, `execute_upgrade`, `migrate_upgrade` | Requires `Governance` auth for proposal creation, multi-approver threshold signatures for approvals, and enforces explicit target scope restrictions. | Prohibited from self-modification and cannot target `Governance`. Invokes target via `GovernedTargetClient::governance_upgrade`. |
+| **`MultisigContract`** (Governance) | Multi-owner governance wallet & execution engine | `initialize`, `reconfigure`, `propose`, `approve`, `execute` | Requires threshold approval from snapshotted owners to invoke arbitrary downstream functions as the Governance entity; `reconfigure` requires admin auth. | Snapshotted policy per proposal prevents retroactive manipulation. Protected from governed upgrade targeting by `TargetScope`. |
+| **`StellarInsights`** / **`V2`** | Core protocol snapshot storage & aggregation | `initialize`, `set_upgrade_manager`, `submit_snapshot`, `governance_upgrade`, `migrate_schema` | One-time initialization; `set_upgrade_manager` requires admin auth and enforces write-once immutability; `governance_upgrade` & `migrate_schema` require `UpgradeManager` auth. | Upgraded WASM cannot rebind `UpgradeManager` due to `UpgradeManagerAlreadySet` invariant; cannot forge governance auth. |
+| **`TimeLockedTransactions`** | Time-delayed execution module | `schedule_transfer`, `execute_transfer`, `get_transfer` | Sender authorization required at scheduling; funds locked immediately; deterministic permissionless execution once absolute unlock timestamp is reached. | Non-upgradeable; immutable transfer parameters (recipient, token, amount) committed at schedule time. |
+| **`EscrowContract`** | Conditional fund custody & release | `__constructor`, `deposit`, `accept`, `release`, `open_dispute`, `resolve_dispute`, `timeout` | Terms committed immutably in constructor; depositor/beneficiary/arbiter must be distinct; typed resolution outcomes (`ReleaseToBeneficiary` / `RefundToDepositor`). | Non-upgradeable; no capability rotation hooks; funds can only flow to predefined terms participants. |
+| **`TokenSwap`** | Automated liquidity & swap execution | `create_offer`, `cancel_offer`, `settle_offer`, `get_offer` | Maker authorization required for offer creation and cancellation; settler authorization for execution; slippage floor strictly enforced. | Non-upgradeable; self-contained escrow; no administrative backdoors or authority delegation. |
+| **`Analytics`** | Off-chain indexer query aggregator | `initialize`, `pause`, `unpause`, `submit_snapshot`, `previous_snapshot`, `latest_proof` | Admin authorization required for pause, unpause, and snapshot submission; monotonic epoch ordering enforced; bounded working set ($O(1)$ storage). | Non-upgradeable; bounded state eliminates storage exhaustion risks; isolated reporting module. |
---
-## Cross-Contract Invocation Edges
+## Cross-Contract Invocation Graph
```mermaid
graph TD
- User["External User / Proposer"] -->|propose / approve| Governance["MultisigContract (Governance)"]
+ User["External Multi-sig Owner / Proposer"] -->|propose / approve / execute| Governance["MultisigContract (Governance)"]
Governance -->|create_proposal| UpgradeManager["UpgradeManager Contract"]
Approvers["Upgrade Approver Set"] -->|approve_upgrade| UpgradeManager
- UpgradeManager -->|execute_upgrade| GovernedTarget["Governed Target (e.g. StellarInsights)"]
- UpgradeManager -->|migrate_upgrade| GovernedTarget
+ UpgradeManager -->|execute_upgrade / migrate_upgrade| GovernedTarget["Governed Target (StellarInsights)"]
- subgraph Restricted Targets (Blocked Scope)
- UpgradeManager -.x|BLOCKED BY scope.rs| Self["UpgradeManager (Self)"]
- UpgradeManager -.x|BLOCKED BY scope.rs| Governance
- UpgradeManager -.x|BLOCKED BY scope.rs| AccessControl["AccessControl / System Core"]
+ subgraph Restricted Scope Boundaries (Blocked by scope.rs)
+ UpgradeManager -.x|BLOCKED: TargetOutOfScope| Self["UpgradeManager (Self)"]
+ UpgradeManager -.x|BLOCKED: TargetOutOfScope| Governance
+ end
+
+ subgraph Anti-Redirection Boundary (Governed Target Invariant)
+ GovernedTarget -.x|BLOCKED: UpgradeManagerAlreadySet| RogueManager["Attacker UpgradeManager"]
+ GovernedTarget -.x|BLOCKED: Unauthorized / Abort| DirectUpgrade["Direct governance_upgrade Call"]
end
```
---
-## Transitive Closure Analysis
+## Formal Invariant Specification (`scope.rs`)
+
+The scope validation module `contracts/upgrade/src/scope.rs` enforces a strict mathematical authorization boundary:
-### Reachable Privilege Chains
+### 1. Governance Root Isolation Invariant
+$$\forall t \in \text{Address}, \quad t = \text{config.governance} \implies \text{validate\_target\_scope}(t) = \text{Err}(\text{TargetOutOfScope})$$
+- **Guarantee**: An upgrade proposal cannot designate the Governance contract (`MultisigContract`) as an upgrade target.
+- **Security Purpose**: Prevents an upgrade proposal from replacing governance multi-signature logic, changing owner rosters, reducing voting thresholds, or circumventing consensus rules.
-1. **Chain 1: Governance Proposal -> Contract Upgrade**
- - **Path**: `Multisig Owner` $\rightarrow$ `Multisig.propose` + `approve` $\rightarrow$ `Multisig.execute` $\rightarrow$ `UpgradeManager.create_proposal(target, wasm_hash)` $\rightarrow$ `Approvers.approve_upgrade` $\rightarrow$ `UpgradeManager.execute_upgrade` $\rightarrow$ `GovernedTarget.governance_upgrade`.
- - **Reachability**: External multisig owners and upgrade approvers can transitively update the executable WASM of governed domain target contracts (e.g., `StellarInsights`).
- - **Intended Scope**: **Yes**. Governed targets are explicitly designed to receive updated code binaries approved by governance and multi-signature approvers.
+### 2. Upgrade Orchestrator Non-Self-Modification Invariant
+$$\forall t \in \text{Address}, \quad t = \text{env.current\_contract\_address}() \implies \text{validate\_target\_scope}(t) = \text{Err}(\text{TargetOutOfScope})$$
+- **Guarantee**: `UpgradeManager` cannot target its own contract address for code replacement.
+- **Security Purpose**: Guarantees that proposal lifecycles, storage test evidence validation, multi-approver thresholds, and target scope checks cannot be uninstalled or modified.
-2. **Chain 2: Transitive Self-Modification / Access Control Hijacking (Potential Risk)**
- - **Path**: `Multisig Owner` $\rightarrow$ `UpgradeManager.create_proposal(target = UpgradeManager or MultisigContract, rogue_wasm)` $\rightarrow$ `execute_upgrade` $\rightarrow$ Target code replaced with malicious binary $\rightarrow$ Total privilege escalation / role modification in `MultisigContract` or `UpgradeManager`.
- - **Reachability**: If `UpgradeManager.create_proposal` accepts `UpgradeManager` or `MultisigContract` (Governance) as target contracts, a successful proposal can alter the governance rules, bypass thresholds, or assign arbitrary roles.
- - **Intended Scope**: **UNINTENDED / HAZARDOUS**. Allowing standard target upgrades to re-write governance or upgrade infrastructure contracts breaks privilege separation and enables single-path total system takeover.
+### 3. Transitive Authority Non-Redirection Invariant
+$$\text{has\_upgrade\_manager}(\text{target}) = \text{true} \implies \text{set\_upgrade\_manager}(\text{target}, m') = \text{Err}(\text{UpgradeManagerAlreadySet})$$
+- **Guarantee**: Governed target contracts bind their `UpgradeManager` address as a write-once instance storage variable.
+- **Security Purpose**: Even when a governed target (e.g. `StellarInsights`) is upgraded to a new WASM binary, the newly installed code cannot reassign or redirect upgrade authority to an attacker-controlled contract. Future upgrades remain strictly gated by the genuine `UpgradeManager`.
+
+### 4. Non-Delegation of Governance Authority Invariant
+$$\text{Caller} \neq \text{config.governance} \implies \text{UpgradeManager.create\_proposal}(\text{caller}) = \text{Err}(\text{Unauthorized / Abort})$$
+- **Guarantee**: Upgraded target contracts cannot forge or inherit governance authorization.
+- **Security Purpose**: Upgraded code running at target addresses remains confined to its domain execution context and cannot invoke governance-restricted entrypoints on other protocol contracts.
---
-## Scope Restrictions (`scope.rs`)
+## Exhaustive Capability-Granting Hook Audit
-To mitigate unintended wide-reaching transitive privilege escalation, explicit target scoping rules are implemented in `contracts/upgrade/src/scope.rs`:
+Every contract across the codebase was audited for capability-granting hooks, administrative reconfiguration vectors, and transitive escalation paths:
-1. **Self-Upgrade Restriction**: `UpgradeManager` cannot target its own contract address (`env.current_contract_address()`).
-2. **Governance Contract Restriction**: `UpgradeManager` cannot target the `Governance` contract address (`config.governance`).
-3. **Explicit Scope Validation**: `create_proposal` validates every target address before recording a proposal. If `target` matches a restricted system address, proposal creation is aborted with `Error::TargetOutOfScope`.
+### 1. `UpgradeManager`
+- **Hook Analysis**: Entry points `create_proposal`, `approve_upgrade`, `execute_upgrade`, `migrate_upgrade`.
+- **Finding**: Proposal creation is gated by `config.governance.require_auth()`. Approvals are restricted to the registered `approvers` set with threshold verification. Self-upgrade and governance upgrade are blocked via `TargetScope`.
+- **Status**: **Hardened**.
----
+### 2. `MultisigContract` (Governance)
+- **Hook Analysis**: `initialize`, `reconfigure`, `propose`, `approve`, `execute`.
+- **Finding**: `initialize` is single-write (`DataKey::Config`). `reconfigure` requires snapshotted `admin.require_auth()`. All open proposals snapshot owner rosters and thresholds (`PolicySnapshot`) at creation time, preventing retroactive manipulation.
+- **Status**: **Hardened**.
-## Escalation Testing Strategy
+### 3. `StellarInsights` & `StellarInsightsV2`
+- **Hook Analysis**: `set_upgrade_manager`, `governance_upgrade`, `migrate_schema`.
+- **Finding**: `set_upgrade_manager` enforces `Error::UpgradeManagerAlreadySet` if `DataKey::UpgradeManager` already exists. `governance_upgrade` and `migrate_schema` strictly require `manager.require_auth()`.
+- **Status**: **Hardened**. Upgraded code cannot redirect `UpgradeManager` or execute unauthenticated migrations.
-`contracts/upgrade/tests/privilege_escalation_test.rs` validates the following invariant checks:
+### 4. `TimeLockedTransactions`
+- **Hook Analysis**: `schedule_transfer`, `execute_transfer`.
+- **Finding**: Transfer parameters (sender, recipient, token, amount, unlock_time) are immutable once scheduled. Ledger sequence and monotonic timestamp progressions are strictly checked.
+- **Status**: **Accepted Risk / Safe Design**. No administrative rotation hooks exist.
+
+### 5. `EscrowContract`
+- **Hook Analysis**: `__constructor`, `deposit`, `accept`, `release`, `open_dispute`, `resolve_dispute`, `timeout`.
+- **Finding**: Escrow terms (depositor, beneficiary, arbiter, token, amount, timeouts) are committed immutably during contract initialization. Dispute resolution is constrained to typed outcomes releasing to either the beneficiary or depositor.
+- **Status**: **Accepted Risk / Safe Design**. Zero capability delegation or upgrade vectors.
+
+### 6. `TokenSwap`
+- **Hook Analysis**: `create_offer`, `cancel_offer`, `settle_offer`.
+- **Finding**: Offers are isolated and escrowed in instance storage. Maker authorization is verified for cancellation; slippage protections enforce floor outputs during settlement.
+- **Status**: **Accepted Risk / Safe Design**. Self-contained atomic swap primitive.
+
+### 7. `Analytics`
+- **Hook Analysis**: `initialize`, `pause`, `unpause`, `submit_snapshot`.
+- **Finding**: State transitions require `admin.require_auth()`. Storage is bounded to a persistent entry count of 1 to prevent resource exhaustion.
+- **Status**: **Accepted Risk / Safe Design**. Reporting aggregator with no cross-contract privilege authority.
+
+---
-- [x] **Legitimate Target Upgrade**: Upgrades targeting valid governed contracts succeed when all approvals are present.
-- [x] **Blocked Self-Upgrade**: Proposals targeting `UpgradeManager` fail immediately with `TargetOutOfScope`.
-- [x] **Blocked Governance Upgrade**: Proposals targeting `Governance` (`MultisigContract`) fail immediately with `TargetOutOfScope`.
-- [x] **Threshold Enforcement**: Unapproved or partially approved proposals cannot trigger `execute_upgrade`.
-- [x] **Unauthorized Target Call**: Direct invocations of `governance_upgrade` on targets from non-manager callers fail with `Unauthorized`.
+## Executable Exploit-Attempt Verification
+
+The verification suite in `contracts/upgrade/tests/privilege_escalation_test.rs` executes genuine Soroban contract deployments and interactions:
+
+1. **Self-Upgrade Escalation Check**: `UpgradeManager` rejecting proposals targeting itself with `TargetOutOfScope`.
+2. **Governance Upgrade Escalation Check**: `UpgradeManager` rejecting proposals targeting `Governance` with `TargetOutOfScope`.
+3. **Threshold Gate Check**: Unapproved proposals cannot invoke `execute_upgrade`.
+4. **End-to-End Upgrade & Anti-Redirection Verification**:
+ - Deploys `UpgradeManager` and `StellarInsights` v1.
+ - Bootstraps snapshot data.
+ - Executes governed upgrade to `StellarInsightsV2`.
+ - **Exploit Attempt**: Attempts calling `set_upgrade_manager` on the upgraded V2 contract — verified rejected with `TargetError::UpgradeManagerAlreadySet`.
+ - **Exploit Attempt**: Attempts calling `governance_upgrade` directly from unauthorized callers — verified rejected.
+ - **Exploit Attempt**: Attempts calling `migrate_schema` directly without manager auth — verified rejected.
+ - Executes legitimate migration via `UpgradeManager.migrate_upgrade` and confirms state integrity.
diff --git a/tests/privilege_escalation_test.rs b/tests/privilege_escalation_test.rs
index 03a5b70f..d1ee47a9 100644
--- a/tests/privilege_escalation_test.rs
+++ b/tests/privilege_escalation_test.rs
@@ -1,13 +1,44 @@
-//! Privilege Escalation Test Deliverable
-//! Refer to `contracts/upgrade/tests/privilege_escalation_test.rs` for the Soroban test harness execution.
+//! Privilege Escalation Verification Suite
+//! Refer to `contracts/upgrade/tests/privilege_escalation_test.rs` for full Soroban host test execution.
#[cfg(test)]
mod tests {
+ /// Verify that contract privilege graph documentation specifies all required formal invariants
+ /// across the contract inventory.
#[test]
- fn privilege_escalation_test_suite_documentation() {
- // Enforces that contract-privilege-graph.md exists and contract upgrade scope is restricted.
- assert!(std::path::Path::new("docs/contract-privilege-graph.md").exists());
- assert!(std::path::Path::new("contracts/upgrade/src/scope.rs").exists());
- assert!(std::path::Path::new("contracts/upgrade/tests/privilege_escalation_test.rs").exists());
+ fn test_privilege_graph_formal_invariants_specification() {
+ let content = std::fs::read_to_string("docs/contract-privilege-graph.md")
+ .expect("failed to read privilege graph doc");
+
+ // Verify all contract systems are audited in the privilege graph
+ assert!(content.contains("UpgradeManager"));
+ assert!(content.contains("MultisigContract"));
+ assert!(content.contains("StellarInsights"));
+ assert!(content.contains("TimeLockedTransactions"));
+ assert!(content.contains("EscrowContract"));
+ assert!(content.contains("TokenSwap"));
+ assert!(content.contains("Analytics"));
+
+ // Verify formal invariant rules are documented
+ assert!(content.contains("Governance Root Isolation Invariant"));
+ assert!(content.contains("Upgrade Manager Non-Self-Modification Invariant"));
+ assert!(content.contains("Transitive Authority Non-Redirection Invariant"));
+ assert!(content.contains("Non-Delegation of Governance Authority Invariant"));
+ assert!(content.contains("UpgradeManagerAlreadySet"));
+ }
+
+ /// Verify that scope.rs defines formal invariants and restrictions.
+ #[test]
+ fn test_scope_formal_specification() {
+ let content = std::fs::read_to_string("contracts/upgrade/src/scope.rs")
+ .expect("failed to read scope.rs");
+
+ assert!(content.contains("TargetScope"));
+ assert!(content.contains("validate_target_scope"));
+ assert!(content.contains("is_restricted_target"));
+ assert!(content.contains("TargetOutOfScope"));
+ assert!(content.contains("Governance Root Isolation Invariant"));
+ assert!(content.contains("Upgrade Manager Non-Self-Modification Invariant"));
+ assert!(content.contains("Transitive Authority Non-Redirection Invariant"));
}
}