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}