soroban_env_host/host/prng.rs
1use super::{declared_size::DeclaredSizeForMetering, metered_clone::MeteredContainer};
2use crate::{
3 budget::Budget,
4 crypto::{chacha20_fill_bytes, unbias_prng_seed},
5 host::metered_clone::MeteredClone,
6 host_object::HostVec,
7 xdr::{ContractCostType, ScBytes, ScErrorCode, ScErrorType},
8 HostError,
9};
10use rand::{distributions::Uniform, prelude::Distribution, seq::SliceRandom, RngCore};
11use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng};
12use std::ops::RangeInclusive;
13
14/// PRNG subsystem in the host, which provides best-effort pseudo-randomness to
15/// guest contracts using a combination of features that guests cannot easily
16/// implement themselves:
17///
18/// - The host itself maintains one "base" PRNG which should be seeded by the
19/// embedding environment and should be different for each host instance, or
20/// roughly "each transaction in a block of transactions" so that
21/// transactions from different submitters cannot guess one another's seeds.
22/// It is the embedder's responsibility to set this to something hard to
23/// predict. In the stellar-core embedding, S is set to the combination of
24/// the txset hash and the apply-order _position_ of the transaction in the
25/// txset, which is itself defined in terms of an xor of each transaction
26/// hash and the previous ledger hash. While it is theoretically possible
27/// for a validator to guess or influence this, being able to do so also
28/// grants validators the ability to front-run the orderbook and is
29/// therefore already an attack vector for the whole stellar network's
30/// financial integrity. In other words: reusing it here doesn't make
31/// anything worse, we're already obliged to make transaction apply-order
32/// hard to guess or control.
33///
34/// - Each frame (which is to say: each contract invocation or sub-invocation)
35/// will get a new PRNG instance separately seeded from the host's "base"
36/// PRNG, and guest code can only access the frame's PRNG, not the "base"
37/// PRNG or those of any other frame. This doesn't eliminate _all_ attack
38/// vectors or mechanisms for misuse, but it's the best we can give the user
39/// for buiding on. In particular it means that a "random" contract will not
40/// behave the same way from one call to the next inside the same txset, nor
41/// can a caller control the seed for a "random" callee (since they can't
42/// observe the state of the "base" PRNG).
43///
44/// - Users _can_ reseed their frame-local PRNG if they want, which is a
45/// useful building block for random-commitment schemes. In particular if a
46/// contract is trying to make a "single random decision", and avoid having
47/// callers retry that decision repeatedly while aborting the transaction on
48/// any random decision the caller doesn't like, the contract can operate as
49/// a state machine like so:
50///
51/// - tx1: write commitment finalizing all inputs to "random action", plus
52/// N = current ledger and S = prng_bytes_new(32).
53///
54/// - tx2: re-read all committed values, if ledger > N, prng_reseed(S),
55/// and use PRNG to take "random" action committed-to.
56///
57/// With this design, assuming the contract does not expose any _online_
58/// method for its caller to observe the commitment it made in tx1, the
59/// caller (situated in the same transaction) won't know from its position
60/// whether it's to its advantage or not to abort tx1, so will naturally let
61/// it commit. Once the commitment is saved, it includes a "locked in"
62/// choice of seed, essentially propagating PRNG state out of a context the
63/// caller might have been able to influence its state via selective aborts
64/// (but didn't know whether to) into a context where the caller can no
65/// longer influence its state. The contract can then be re-invoked in the
66/// next ledger to load and execute the commitment, in tx2.
67///
68/// - We also include 3 building blocks for _using_ the PRNG: one basic one
69/// that just "generates a random BytesObject" (that the user can do
70/// anything they like with, including copying to guest linear memory), and
71/// two slightly more subtle but very standard operations that are easy to
72/// get wrong: an inclusive-range uniform u64 sampler and a Fisher-Yates
73/// vector shuffle. The latter is also hard to do cheaply in guest code
74/// without copying the vector into the guest and copying it back.
75///
76/// - All these PRNGs are ChaCha20: a strong, cheap, standard CSPRNG.
77///
78#[derive(Debug, Clone)]
79pub(crate) struct Prng(pub(crate) ChaCha20Rng);
80
81pub type Seed = <rand_chacha::ChaCha20Rng as rand::SeedableRng>::Seed;
82pub const SEED_BYTES: u64 = <Seed as DeclaredSizeForMetering>::DECLARED_SIZE;
83static_assertions::const_assert_eq!(SEED_BYTES, 32);
84
85impl std::hash::Hash for Prng {
86 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
87 self.0.get_seed().hash(state);
88 self.0.get_stream().hash(state);
89 self.0.get_word_pos().hash(state);
90 }
91}
92
93impl Prng {
94 fn charge_prng_bytes(&self, budget: &Budget, count: u64) -> Result<(), HostError> {
95 budget.charge(ContractCostType::ChaCha20DrawBytes, Some(count))
96 }
97
98 pub(crate) fn new_from_seed(seed: Seed, budget: &Budget) -> Result<Self, HostError> {
99 let seed = unbias_prng_seed(&seed, budget)?;
100 Ok(Self(ChaCha20Rng::from_seed(seed)))
101 }
102
103 pub(crate) fn u64_in_inclusive_range(
104 &mut self,
105 range: RangeInclusive<u64>,
106 budget: &Budget,
107 ) -> Result<u64, HostError> {
108 // rand::Uniform panics if start > end.
109 if range.start() > range.end() {
110 return Err((ScErrorType::Value, ScErrorCode::InvalidInput).into());
111 }
112
113 // We over-estimate the number of bytes drawn by a factor of 2, to
114 // account for the fact that a range sample is rejection-sampling which
115 // is expected to only do one draw but might do more than one.
116 self.charge_prng_bytes(budget, 2 * <u64 as DeclaredSizeForMetering>::DECLARED_SIZE)?;
117 let u = Uniform::from(range);
118 Ok(u.sample(&mut self.0))
119 }
120
121 pub(crate) fn vec_shuffle(
122 &mut self,
123 v: &HostVec,
124 budget: &Budget,
125 ) -> Result<HostVec, HostError> {
126 // A Fisher-Yates shuffle essentially does one call to u64_in_range for
127 // each element of the input vector, followed by an optional swap. Since
128 // u64_in_range is itself a rejection sampling operation (to avoid bias)
129 // we can't be 100% sure how many draws it'll make, but the expected
130 // number of draws is 1. To give ourselves a little more safety we'll
131 // double that number. We also give the implementation freedom to draw a
132 // 64-bit (8-byte) value per index, meaning we charge for generating 2 *
133 // 8 * len bytes.
134 let mut v2 = v.metered_clone(budget)?;
135 // We charge for both the PRNG draws and the swaps here (as "memcpys").
136 self.charge_prng_bytes(budget, 16u64.saturating_mul(v.len() as u64))?;
137 budget.charge(ContractCostType::MemCpy, Some(v.len() as u64))?;
138 v2.as_mut_slice().shuffle(&mut self.0);
139 Ok(v2)
140 }
141
142 pub(crate) fn bytes_new(&mut self, size: u32, budget: &Budget) -> Result<ScBytes, HostError> {
143 Vec::<u8>::charge_bulk_init_cpy(size as u64, budget)?;
144 let mut vec = vec![0u8; size as usize];
145 chacha20_fill_bytes(&mut self.0, vec.as_mut_slice(), budget)?;
146 Ok(ScBytes::try_from(vec)?)
147 }
148
149 pub(crate) fn sub_prng(&mut self, budget: &Budget) -> Result<Prng, HostError> {
150 let mut new_seed: Seed = [0; SEED_BYTES as usize];
151 chacha20_fill_bytes(&mut self.0, &mut new_seed, budget)?;
152 budget.charge(ContractCostType::MemCpy, Some(SEED_BYTES))?;
153 Ok(Self(ChaCha20Rng::from_seed(new_seed)))
154 }
155
156 pub(crate) fn unmetered_raw_sub_prng(&mut self) -> ChaCha20Rng {
157 let mut new_seed: Seed = [0; SEED_BYTES as usize];
158 self.0.fill_bytes(&mut new_seed);
159 ChaCha20Rng::from_seed(new_seed)
160 }
161}