Skip to main content

cratefield_adapter_turnkey/
policy.rs

1//! Pure policy builders: the `EFFECT_ALLOW` conditions and the
2//! delegated-access consensus Turnkey's policy engine evaluates, plus
3//! the strict validators that make them injection-proof.
4//!
5//! The policy language is a small expression language over single-quoted
6//! strings (only single quotes, no escape syntax — anything carrying a
7//! `'` can only ever break out by *being* a quote), so every caller
8//! string that reaches a condition passes a validator that admits
9//! nothing but the exact shape Turnkey itself would accept: lowercase
10//! `0x` + 40 hex for an address, `0x` + 8 hex for a selector, and a
11//! base58 string decoding to 32 bytes for a Solana program key. What a
12//! builder emits is what the validator let in — nothing more.
13
14use cratefield_signer::SignerError;
15
16/// One allow rule: the shapes of signing the delegated access user may
17/// approve, each turned into one `EFFECT_ALLOW` policy scoped to that
18/// user. Anything not covered by a rule is refused by Turnkey itself —
19/// the default answer out there, as in [`cratefield_signer`]'s own
20/// guardrails, is no.
21///
22/// The string fields are validated newtypes with no other constructor,
23/// so a struct literal cannot forge a rule: [`Self::condition`] can
24/// interpolate them raw because validation happened where they were
25/// built.
26///
27/// ```compile_fail
28/// use cratefield_adapter_turnkey::AllowRule;
29///
30/// // `to` is an `EvmAddress`, not a `String`: a raw string — a quoted
31/// // address, or a policy-expression fragment — has no way in.
32/// let _forged = AllowRule::Evm {
33///     chain_id: 1,
34///     to: String::from("0x000000000000000000000000000000000000aaaa"),
35///     selector: None,
36///     max_value: None,
37/// };
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum AllowRule {
41    /// An EVM transaction: on `chain_id`, to `to`, and — when set —
42    /// calling `selector` and sending at most `max_value` wei.
43    Evm {
44        /// The chain the transaction must commit to.
45        chain_id: u64,
46        /// The recipient address. Not the zero address unless that is
47        /// meant: Turnkey compares the literal string.
48        to: EvmAddress,
49        /// The four-byte function selector, matched against the first
50        /// four calldata bytes.
51        selector: Option<Selector>,
52        /// The largest value the rule allows, in wei. A cap past
53        /// `i128::MAX` is refused by [`Self::evm`]: Turnkey's policy
54        /// engine computes in `i128`, and a condition it cannot
55        /// evaluate is one it errors on.
56        max_value: Option<u128>,
57    },
58    /// A Solana message every one of whose instructions calls
59    /// `program_key`.
60    Solana {
61        /// The program pubkey.
62        program_key: ProgramKey,
63    },
64}
65
66impl AllowRule {
67    /// An EVM rule, validating `to` and `selector` on the way in.
68    ///
69    /// # Errors
70    ///
71    /// [`SignerError::Invalid`] when an address or selector is not the
72    /// strict shape the policy condition embeds.
73    pub fn evm(
74        chain_id: u64,
75        to: &str,
76        selector: Option<&str>,
77        max_value: Option<u128>,
78    ) -> Result<Self, SignerError> {
79        if let Some(max_value) = max_value {
80            // Turnkey's policy engine computes in i128; a cap it cannot
81            // represent is a condition that errors instead of deciding.
82            if max_value > i128::MAX as u128 {
83                return Err(SignerError::Invalid(
84                    "a value cap must fit the policy engine's i128".to_owned(),
85                ));
86            }
87        }
88        Ok(Self::Evm {
89            chain_id,
90            to: EvmAddress::new(to)?,
91            selector: selector.map(Selector::new).transpose()?,
92            max_value,
93        })
94    }
95
96    /// A Solana rule, validating the program key on the way in.
97    ///
98    /// # Errors
99    ///
100    /// [`SignerError::Invalid`] when the program key is not base58
101    /// decoding to exactly 32 bytes.
102    pub fn solana(program_key: &str) -> Result<Self, SignerError> {
103        Ok(Self::Solana {
104            program_key: ProgramKey::new(program_key)?,
105        })
106    }
107
108    /// The condition expression this rule becomes, as sent in
109    /// `ACTIVITY_TYPE_CREATE_POLICY_V3`'s `condition`.
110    #[must_use]
111    pub fn condition(&self) -> String {
112        match self {
113            Self::Evm {
114                chain_id,
115                to,
116                selector,
117                max_value,
118            } => {
119                let mut clauses = vec![
120                    format!("eth.tx.to == '{}'", to.as_str()),
121                    format!("eth.tx.chain_id == {chain_id}"),
122                ];
123                if let Some(selector) = selector {
124                    clauses.push(format!("eth.tx.data[0..10] == '{}'", selector.as_str()));
125                }
126                if let Some(max_value) = max_value {
127                    clauses.push(format!("eth.tx.value <= {max_value}"));
128                }
129                clauses.join(" && ")
130            }
131            Self::Solana { program_key } => format!(
132                "solana.tx.instructions.all(i, i.program_key == '{}')",
133                program_key.as_str()
134            ),
135        }
136    }
137}
138
139/// An EVM address: lowercase `0x` + 40 hex, and nothing else. The inner
140/// string is private and there is no `From`, so a struct literal — or a
141/// caller string that skipped [`Self::new`] — cannot forge one.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct EvmAddress(String);
144
145impl EvmAddress {
146    /// Validates and lowercases `address`.
147    ///
148    /// # Errors
149    ///
150    /// [`SignerError::Invalid`] when it is not `0x` + 40 hex.
151    pub fn new(address: &str) -> Result<Self, SignerError> {
152        validate_address(address).map(Self)
153    }
154
155    /// The validated address, as a condition embeds it.
156    #[must_use]
157    pub fn as_str(&self) -> &str {
158        &self.0
159    }
160}
161
162/// A four-byte function selector: lowercase `0x` + 8 hex, private inner
163/// string, constructible only through [`Self::new`].
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Selector(String);
166
167impl Selector {
168    /// Validates and lowercases `selector`.
169    ///
170    /// # Errors
171    ///
172    /// [`SignerError::Invalid`] when it is not `0x` + 8 hex.
173    pub fn new(selector: &str) -> Result<Self, SignerError> {
174        validate_selector(selector).map(Self)
175    }
176
177    /// The validated selector, as a condition embeds it.
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184/// A Solana program key: base58 decoding to exactly 32 bytes, private
185/// inner string, constructible only through [`Self::new`].
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ProgramKey(String);
188
189impl ProgramKey {
190    /// Validates `program_key` by decoding it.
191    ///
192    /// # Errors
193    ///
194    /// [`SignerError::Invalid`] when it is not base58 decoding to
195    /// exactly 32 bytes.
196    pub fn new(program_key: &str) -> Result<Self, SignerError> {
197        validate_program_key(program_key).map(Self)
198    }
199
200    /// The validated key, as a condition embeds it.
201    #[must_use]
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205}
206
207/// The consensus expression scoping a policy to the delegated access
208/// user: the activity happens when that user's API-key stamp approves
209/// it, and no one else's.
210///
211/// # Errors
212///
213/// [`SignerError::Invalid`] when `da_user_id` is not a Turnkey-style
214/// UUID.
215pub fn da_consensus(da_user_id: &str) -> Result<String, SignerError> {
216    validate_turnkey_id(da_user_id)?;
217    Ok(format!("approvers.any(user, user.id == '{da_user_id}')"))
218}
219
220/// An address: lowercase `0x` + 40 hex. Anything else — uppercase,
221/// wrong length, a quote, a non-hex byte — is refused rather than
222/// embedded.
223///
224/// # Errors
225///
226/// [`SignerError::Invalid`] with the reason.
227pub fn validate_address(address: &str) -> Result<String, SignerError> {
228    hex_word(address, 40).map_err(|reason| {
229        SignerError::Invalid(format!(
230            "an EVM address is 0x + 40 hex, got `{address}`: {reason}"
231        ))
232    })
233}
234
235/// A four-byte function selector: lowercase `0x` + 8 hex.
236///
237/// # Errors
238///
239/// [`SignerError::Invalid`] with the reason.
240pub fn validate_selector(selector: &str) -> Result<String, SignerError> {
241    hex_word(selector, 8).map_err(|reason| {
242        SignerError::Invalid(format!(
243            "a selector is 0x + 8 hex, got `{selector}`: {reason}"
244        ))
245    })
246}
247
248/// A Solana program key: base58 decoding to exactly 32 bytes. Decoding
249/// is the check — a string that only *looks* base58 (`0OIl` are not in
250/// the alphabet, a quote is not either) never reaches a condition.
251///
252/// # Errors
253///
254/// [`SignerError::Invalid`] with the reason.
255pub fn validate_program_key(program_key: &str) -> Result<String, SignerError> {
256    match bs58::decode(program_key).into_vec() {
257        Ok(bytes) if bytes.len() == 32 => Ok(program_key.to_owned()),
258        Ok(bytes) => Err(SignerError::Invalid(format!(
259            "a Solana program key decodes to 32 bytes, `{program_key}` decodes to {}",
260            bytes.len()
261        ))),
262        Err(err) => Err(SignerError::Invalid(format!(
263            "a Solana program key is base58, got `{program_key}`: {err}"
264        ))),
265    }
266}
267
268/// A Turnkey object id (sub-organization, user, policy): a UUID. This is
269/// the injection guard for ids this crate embeds in conditions, paths
270/// and body fields.
271///
272/// # Errors
273///
274/// [`SignerError::Invalid`] when the id is not 8-4-4-4-12 hex.
275pub fn validate_turnkey_id(id: &str) -> Result<String, SignerError> {
276    let valid = id.len() == 36
277        && id.as_bytes()[8] == b'-'
278        && id.as_bytes()[13] == b'-'
279        && id.as_bytes()[18] == b'-'
280        && id.as_bytes()[23] == b'-'
281        && id
282            .bytes()
283            .enumerate()
284            .all(|(at, byte)| matches!(at, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit());
285    if valid {
286        Ok(id.to_owned())
287    } else {
288        Err(SignerError::Invalid(format!(
289            "a Turnkey id is a UUID, got `{id}`"
290        )))
291    }
292}
293
294/// One `0x`-prefixed hex word of exactly `digits` digits, lowercased.
295fn hex_word(text: &str, digits: usize) -> Result<String, String> {
296    let body = text.strip_prefix("0x").ok_or("missing the 0x prefix")?;
297    if body.len() != digits {
298        return Err(format!(
299            "{digits} hex digits expected, found {}",
300            body.len()
301        ));
302    }
303    if !body.bytes().all(|byte| byte.is_ascii_hexdigit()) {
304        return Err("non-hex character".to_owned());
305    }
306    Ok(format!("0x{}", body.to_ascii_lowercase()))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    const ADDRESS: &str = "0x000000000000000000000000000000000000aaaa";
314    /// The SPL Token program id — a public constant, joined from short
315    /// pieces at compile time so the literal never reads as a long
316    /// high-entropy secret to push scanners.
317    const TOKEN_PROGRAM: &str = concat!("TokenkegQfeZ", "yiNwAJbNbGKP", "FXCWuBvf9Ss", "623VQ5DA");
318
319    #[test]
320    fn the_evm_condition_names_to_chain_selector_and_cap() {
321        let rule =
322            AllowRule::evm(1, ADDRESS, Some("0xA9059CBB"), Some(1_000_000)).expect("a valid rule");
323        assert_eq!(
324            rule.condition(),
325            concat!(
326                "eth.tx.to == '0x000000000000000000000000000000000000aaaa'",
327                " && eth.tx.chain_id == 1",
328                " && eth.tx.data[0..10] == '0xa9059cbb'",
329                " && eth.tx.value <= 1000000"
330            )
331        );
332    }
333
334    #[test]
335    fn the_solana_condition_binds_every_instruction() {
336        let rule = AllowRule::solana(TOKEN_PROGRAM).expect("a valid program key");
337        assert_eq!(
338            rule.condition(),
339            format!("solana.tx.instructions.all(i, i.program_key == '{TOKEN_PROGRAM}')")
340        );
341    }
342
343    #[test]
344    fn the_consensus_scopes_to_the_delegated_user() {
345        let id = "11111111-2222-3333-4444-555555555555";
346        assert_eq!(
347            da_consensus(id).expect("a valid id"),
348            format!("approvers.any(user, user.id == '{id}')")
349        );
350    }
351
352    #[test]
353    fn validators_reject_injection_and_typo_shapes() {
354        for bad in [
355            "' OR '1'=='1",                               // a quote never survives validation
356            "0xAAAA",                                     // right prefix, wrong length
357            "0x000000000000000000000000000000000000aaa",  // 39 digits
358            "0x000000000000000000000000000000000000aaag", // `g` is not hex
359            "000000000000000000000000000000000000aaaa",   // no prefix
360        ] {
361            assert!(validate_address(bad).is_err(), "`{bad}` must not pass");
362        }
363        for bad in [
364            "0xA9059CBB0",    // nine digits
365            "0xa9059c",       // four bytes of a twenty-byte address
366            "\"0xa9059cbb\"", // JSON quoting is not hex
367        ] {
368            assert!(validate_selector(bad).is_err(), "`{bad}` must not pass");
369        }
370        for bad in [
371            // 45 characters — decodes past 32 bytes. (A *single-character*
372            // typo decodes to a different but valid 32-byte key: base58
373            // carries no checksum, so shape-validation cannot catch it —
374            // the policies are what bound the key to real programs.)
375            // The real program id with one appended character.
376            concat!(
377                "TokenkegQfeZ",
378                "yiNwAJbNbGKP",
379                "FXCWuBvf9Ss",
380                "623VQ5DA",
381                "A"
382            ),
383            "0xdeadbeef", // hex is not base58 (0, x are not in the alphabet)
384            "l1O0placeholderl1O0placeholderl1O0placeholderxx", // 0, O, l, I excluded
385            "' OR '1'=='1",
386        ] {
387            assert!(validate_program_key(bad).is_err(), "`{bad}` must not pass");
388        }
389        for bad in [
390            "not-a-uuid",
391            "11111111-2222-3333-4444-55555555555",  // 35 chars
392            "11111111-2222-3333-4444-55555555555'", // a quote past the shape
393            "111111112222333344445555555555555555", // no dashes
394        ] {
395            assert!(validate_turnkey_id(bad).is_err(), "`{bad}` must not pass");
396        }
397    }
398
399    #[test]
400    fn validators_normalise_hex_case_and_keep_base58_exactly() {
401        assert_eq!(
402            validate_address("0x000000000000000000000000000000000000AAAA").expect("valid"),
403            ADDRESS
404        );
405        assert_eq!(
406            validate_selector("0xA9059CBB").expect("valid"),
407            "0xa9059cbb"
408        );
409        assert_eq!(
410            validate_program_key(TOKEN_PROGRAM).expect("valid"),
411            TOKEN_PROGRAM
412        );
413    }
414
415    #[test]
416    fn a_rule_cannot_be_forged_by_struct_literal() {
417        // The compile_fail doctest on `AllowRule` pins the type-level
418        // half; this is the runtime twin: the newtype constructors are
419        // the validators, so a rule assembled from their output embeds
420        // exactly what was validated.
421        let rule = AllowRule::Evm {
422            chain_id: 1,
423            to: EvmAddress::new(ADDRESS).expect("a valid address"),
424            selector: Some(Selector::new("0xA9059CBB").expect("a valid selector")),
425            max_value: Some(1_000),
426        };
427        assert_eq!(
428            rule.condition(),
429            concat!(
430                "eth.tx.to == '0x000000000000000000000000000000000000aaaa'",
431                " && eth.tx.chain_id == 1",
432                " && eth.tx.data[0..10] == '0xa9059cbb'",
433                " && eth.tx.value <= 1000"
434            )
435        );
436        // And the constructors themselves validate: a fragment that
437        // would break out of the single-quoted string has nowhere to go.
438        for bad in ["' OR '1'=='1", "0xAAAA"] {
439            assert!(EvmAddress::new(bad).is_err(), "`{bad}` must not pass");
440            assert!(Selector::new(bad).is_err(), "`{bad}` must not pass");
441            assert!(ProgramKey::new(bad).is_err(), "`{bad}` must not pass");
442        }
443    }
444
445    #[test]
446    fn a_cap_past_the_policy_engines_int_width_is_refused() {
447        let err = AllowRule::evm(1, ADDRESS, None, Some(u128::MAX))
448            .expect_err("i128 is the engine's width");
449        assert!(matches!(err, SignerError::Invalid(_)), "got: {err:?}");
450    }
451}