1use std::sync::Arc;
10
11use bytes::Bytes;
12use http::header::{CONTENT_TYPE, HeaderValue};
13use http::{Request, StatusCode};
14use serde_json::{Value, json};
15use zeroize::Zeroizing;
16
17use cratefield_core::{Clock, HttpClient, HttpError};
18use cratefield_secrets::{Actor, SecretStore};
19use cratefield_signer::SignerError;
20
21use crate::policy::{AllowRule, da_consensus, validate_turnkey_id};
22
23const API_BASE: &str = "https://api.turnkey.com";
25
26const DENIED_STATUSES: [&str; 2] = [
30 "ACTIVITY_STATUS_REJECTED",
31 "ACTIVITY_STATUS_CONSENSUS_NEEDED",
32];
33
34#[derive(Debug, Clone)]
37pub struct PasskeyAttestation {
38 pub authenticator_name: String,
40 pub challenge: String,
42 pub credential_id: String,
44 pub client_data_json: String,
46 pub attestation_object: String,
48 pub transports: Vec<String>,
50}
51
52#[derive(Debug, Clone)]
56pub struct SubOrgSetup {
57 pub name: String,
59 pub end_user_name: String,
61 pub end_user_email: Option<String>,
63 pub authenticator: PasskeyAttestation,
65 pub rules: Vec<AllowRule>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SubOrganization {
74 pub id: String,
76 pub end_user_id: String,
79 pub da_user_id: String,
82 pub wallet_id: String,
84 pub evm_address: Option<String>,
87 pub solana_address: Option<String>,
90}
91
92struct ListedUser {
96 id: String,
97 api_key_public_keys: Vec<String>,
98}
99
100pub struct TurnkeySigner {
107 http: Arc<dyn HttpClient>,
108 clock: Arc<dyn Clock>,
109 organization_id: String,
113 secrets: Arc<SecretStore>,
115 actor: Actor,
117 api_key_secret: String,
119 base: Option<String>,
121}
122
123impl TurnkeySigner {
124 #[must_use]
129 pub fn new(
130 http: Arc<dyn HttpClient>,
131 clock: Arc<dyn Clock>,
132 organization_id: impl Into<String>,
133 secrets: Arc<SecretStore>,
134 actor: Actor,
135 api_key_secret: impl Into<String>,
136 ) -> Self {
137 Self {
138 http,
139 clock,
140 organization_id: organization_id.into(),
141 secrets,
142 actor,
143 api_key_secret: api_key_secret.into(),
144 base: None,
145 }
146 }
147
148 #[must_use]
150 pub fn with_base(mut self, base: impl Into<String>) -> Self {
151 self.base = Some(base.into());
152 self
153 }
154
155 pub async fn da_public_key(&self) -> Result<String, SignerError> {
164 Ok(crate::stamp::compressed_public_key_hex(
165 &self.load_stamp_key().await?,
166 ))
167 }
168
169 pub async fn create_sub_organization(
190 &self,
191 setup: &SubOrgSetup,
192 ) -> Result<SubOrganization, SignerError> {
193 let da_public_key = self.da_public_key().await?;
194 let activity = self
195 .submit(
196 "create_sub_organization",
197 "ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7",
198 &self.organization_id,
199 create_sub_organization_parameters(setup, &da_public_key),
200 )
201 .await?;
202 let result = &activity["createSubOrganizationResult"];
203 let id = turnkey_id(result["subOrganizationId"].as_str(), "subOrganizationId")?;
204 let listed = self.list_users(&id).await?;
212 if listed.len() != 2 {
213 return Err(SignerError::Provider(format!(
214 "the new sub-organization holds {} users, not the two that were created; refusing \
215 to guess which is which",
216 listed.len()
217 )));
218 }
219 let carriers: Vec<&str> = listed
220 .iter()
221 .filter(|user| {
222 user.api_key_public_keys
223 .iter()
224 .any(|key| key == &da_public_key)
225 })
226 .map(|user| user.id.as_str())
227 .collect();
228 if carriers.len() != 1 {
229 return Err(SignerError::Provider(format!(
230 "exactly one user of the new sub-organization must carry the delegated access \
231 API key, found {}; refusing to guess which is which",
232 carriers.len()
233 )));
234 }
235 let da_user_id = carriers[0].to_owned();
236 let end_user_id = listed
237 .iter()
238 .map(|user| user.id.as_str())
239 .find(|user_id| *user_id != da_user_id)
240 .ok_or_else(|| {
241 SignerError::Provider(
242 "the new sub-organization has no second user to be the end user".to_owned(),
243 )
244 })?
245 .to_owned();
246 let wallet = &result["wallet"];
247 let wallet_id = wallet["walletId"]
248 .as_str()
249 .ok_or_else(|| {
250 SignerError::Provider("the sub-organization result has no walletId".to_owned())
251 })?
252 .to_owned();
253 let addresses: Vec<&str> = wallet["addresses"]
255 .as_array()
256 .map(|list| list.iter().filter_map(Value::as_str).collect())
257 .unwrap_or_default();
258 let sub_org = SubOrganization {
259 id,
260 end_user_id,
261 da_user_id,
262 wallet_id,
263 evm_address: addresses.first().map(|address| (*address).to_owned()),
264 solana_address: addresses.get(1).map(|address| (*address).to_owned()),
265 };
266
267 for (at, rule) in setup.rules.iter().enumerate() {
268 self.create_policy(
269 &sub_org.id,
270 &format!("cratefield-da-allow-{at}"),
271 "EFFECT_ALLOW",
272 &sub_org.da_user_id,
273 Some(&rule.condition()),
274 "cratefield: allow the delegated access user one rule (issue #761)",
275 )
276 .await?;
277 }
278
279 self.update_root_quorum(&sub_org.id, 1, std::slice::from_ref(&sub_org.end_user_id))
280 .await?;
281 Ok(sub_org)
282 }
283
284 pub async fn create_policy(
294 &self,
295 sub_organization_id: &str,
296 name: &str,
297 effect: &str,
298 da_user_id: &str,
299 condition: Option<&str>,
300 notes: &str,
301 ) -> Result<String, SignerError> {
302 let parameters = json!({
303 "policyName": name,
304 "effect": effect,
305 "consensus": da_consensus(da_user_id)?,
306 "condition": condition,
307 "notes": notes,
308 });
309 let activity = self
310 .submit(
311 "create_policy",
312 "ACTIVITY_TYPE_CREATE_POLICY_V3",
313 sub_organization_id,
314 parameters,
315 )
316 .await?;
317 turnkey_id(
318 activity["createPolicyResult"]["policyId"].as_str(),
319 "policyId",
320 )
321 }
322
323 pub async fn set_kill_switch(
333 &self,
334 sub_organization_id: &str,
335 da_user_id: &str,
336 ) -> Result<String, SignerError> {
337 self.create_policy(
338 sub_organization_id,
339 "cratefield-da-kill-switch",
340 "EFFECT_DENY",
341 da_user_id,
342 Some("true"),
343 "cratefield: kill switch — deny the delegated access user everything (issue #761)",
344 )
345 .await
346 }
347
348 pub async fn clear_kill_switch(
354 &self,
355 sub_organization_id: &str,
356 policy_id: &str,
357 ) -> Result<(), SignerError> {
358 self.submit(
359 "delete_policy",
360 "ACTIVITY_TYPE_DELETE_POLICY",
361 sub_organization_id,
362 json!({ "policyId": policy_id }),
363 )
364 .await?;
365 Ok(())
366 }
367
368 pub async fn update_root_quorum(
375 &self,
376 sub_organization_id: &str,
377 threshold: u32,
378 user_ids: &[String],
379 ) -> Result<(), SignerError> {
380 for id in user_ids {
381 validate_turnkey_id(id)?;
382 }
383 self.submit(
384 "update_root_quorum",
385 "ACTIVITY_TYPE_UPDATE_ROOT_QUORUM",
386 sub_organization_id,
387 json!({ "threshold": threshold, "userIds": user_ids }),
388 )
389 .await?;
390 Ok(())
391 }
392
393 pub(crate) async fn sign_transaction(
401 &self,
402 sub_organization_id: &str,
403 sign_with: &str,
404 unsigned_transaction_hex: &str,
405 transaction_type: &str,
406 ) -> Result<String, SignerError> {
407 let activity = self
408 .submit(
409 "sign_transaction",
410 "ACTIVITY_TYPE_SIGN_TRANSACTION_V2",
411 sub_organization_id,
412 json!({
413 "signWith": sign_with,
414 "unsignedTransaction": unsigned_transaction_hex,
415 "type": transaction_type,
416 }),
417 )
418 .await?;
419 activity["signTransactionResult"]["signedTransaction"]
420 .as_str()
421 .map(str::to_owned)
422 .ok_or_else(|| {
423 SignerError::Provider("the sign result carries no signedTransaction".to_owned())
424 })
425 }
426
427 pub(crate) async fn sign_raw_payload(
436 &self,
437 sub_organization_id: &str,
438 sign_with: &str,
439 payload_hex: &str,
440 hash_function: &str,
441 ) -> Result<(String, String, String), SignerError> {
442 let activity = self
443 .submit(
444 "sign_raw_payload",
445 "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
446 sub_organization_id,
447 json!({
448 "signWith": sign_with,
449 "payload": payload_hex,
450 "encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
451 "hashFunction": hash_function,
452 }),
453 )
454 .await?;
455 let result = &activity["signRawPayloadResult"];
456 let r = result["r"].as_str().ok_or_else(missing_signature)?;
457 let s = result["s"].as_str().ok_or_else(missing_signature)?;
458 let v = result["v"].as_str().ok_or_else(missing_signature)?;
459 Ok((r.to_owned(), s.to_owned(), v.to_owned()))
460 }
461
462 async fn submit(
466 &self,
467 path: &str,
468 activity_type: &str,
469 organization_id: &str,
470 parameters: Value,
471 ) -> Result<Value, SignerError> {
472 if organization_id.is_empty() {
473 return Err(SignerError::Invalid(
474 "a Turnkey organization id is required".to_owned(),
475 ));
476 }
477 let timestamp_ms = self.clock.now().unix_timestamp_nanos() / 1_000_000;
480 let body = serde_json::to_vec(&json!({
481 "type": activity_type,
482 "timestampMs": timestamp_ms.to_string(),
483 "organizationId": organization_id,
484 "parameters": parameters,
485 }))
486 .map_err(|err| SignerError::Provider(format!("the request could not be built: {err}")))?;
487 let answer = self.post("submit", path, body).await?;
488 let activity = &answer["activity"];
489 activity_result(activity_type, activity)
490 }
491
492 async fn list_users(&self, sub_organization_id: &str) -> Result<Vec<ListedUser>, SignerError> {
497 let body = serde_json::to_vec(&json!({ "organizationId": sub_organization_id })).map_err(
498 |err| SignerError::Provider(format!("the request could not be built: {err}")),
499 )?;
500 let answer = self.post("query", "list_users", body).await?;
501 let users = answer["users"].as_array().ok_or_else(|| {
502 SignerError::Provider("the list_users answer carries no users".to_owned())
503 })?;
504 let mut listed = Vec::with_capacity(users.len());
505 for user in users {
506 listed.push(ListedUser {
507 id: turnkey_id(user["userId"].as_str(), "userId")?,
508 api_key_public_keys: user["apiKeys"]
509 .as_array()
510 .map(|keys| {
511 keys.iter()
512 .filter_map(|key| key["credential"]["publicKey"].as_str())
513 .map(str::to_owned)
514 .collect()
515 })
516 .unwrap_or_default(),
517 });
518 }
519 Ok(listed)
520 }
521
522 async fn post(&self, kind: &str, name: &str, body: Vec<u8>) -> Result<Value, SignerError> {
526 let key = self.load_stamp_key().await?;
527 let stamp = crate::stamp::stamp_with_key(&body, &key);
528 drop(key);
529 let header = HeaderValue::from_str(&stamp)
530 .map_err(|_| SignerError::Provider("the stamp is not a legal header".to_owned()))?;
531 let uri = format!(
532 "{}/public/v1/{kind}/{name}",
533 self.base.as_deref().unwrap_or(API_BASE)
534 );
535 let request = Request::builder()
536 .method(http::Method::POST)
537 .uri(uri)
538 .header("X-Stamp", header)
539 .header(CONTENT_TYPE, "application/json")
540 .body(Bytes::from(body))
541 .map_err(|_| SignerError::Provider("the request could not be built".to_owned()))?;
542
543 let response = self.http.send(request).await.map_err(|err: HttpError| {
544 SignerError::Provider(format!("turnkey is unreachable: {err}"))
545 })?;
546 let status = response.status();
547 let text = String::from_utf8_lossy(response.body()).to_string();
548 if !status.is_success() {
549 return Err(SignerError::Provider(format!(
550 "turnkey answered {}: {text}",
551 StatusCode::as_u16(&status)
552 )));
553 }
554 serde_json::from_str(&text).map_err(|err| {
555 SignerError::Provider(format!("turnkey answered unparsable JSON: {err}"))
556 })
557 }
558
559 async fn load_stamp_key(&self) -> Result<p256::ecdsa::SigningKey, SignerError> {
563 let sealed = self
564 .secrets
565 .get(&self.api_key_secret, &self.actor)
566 .await
567 .map_err(|err| SignerError::Provider(format!("the secrets store refused: {err}")))?
568 .ok_or_else(|| SignerError::UnknownKey {
569 key_ref: self.api_key_secret.clone(),
570 })?;
571 let scalar = Zeroizing::new(<[u8; 32]>::try_from(sealed.expose()).map_err(|_| {
572 SignerError::Provider(format!(
573 "the Turnkey API key behind `{}` is {} bytes, not the 32-byte P-256 scalar",
574 self.api_key_secret,
575 sealed.expose().len()
576 ))
577 })?);
578 crate::stamp::signing_key(scalar.as_slice())
579 }
580}
581
582fn activity_result(activity_type: &str, activity: &Value) -> Result<Value, SignerError> {
587 let status = activity["status"].as_str().unwrap_or("missing");
588 match status {
589 "ACTIVITY_STATUS_COMPLETED" => Ok(activity["result"].clone()),
590 status if DENIED_STATUSES.contains(&status) => Err(SignerError::Denied {
591 reason: format!(
592 "turnkey refused the {activity_type}: the activity is {status} — a policy denied \
593 it, or the delegated user's consensus was not enough"
594 ),
595 }),
596 other => Err(SignerError::Provider(format!(
597 "the turnkey {activity_type} did not complete: status {other}"
598 ))),
599 }
600}
601
602fn missing_signature() -> SignerError {
604 SignerError::Provider("the sign result carries no (r, s, v)".to_owned())
605}
606
607fn turnkey_id(value: Option<&str>, field: &str) -> Result<String, SignerError> {
610 let id = value
611 .ok_or_else(|| SignerError::Provider(format!("the activity result has no {field}")))?;
612 crate::policy::validate_turnkey_id(id)
613}
614
615fn create_sub_organization_parameters(setup: &SubOrgSetup, da_public_key: &str) -> Value {
620 json!({
621 "subOrganizationName": setup.name,
622 "rootUsers": [
623 {
624 "userName": setup.end_user_name,
625 "userEmail": setup.end_user_email,
626 "apiKeys": [],
627 "authenticators": [{
628 "authenticatorName": setup.authenticator.authenticator_name,
629 "challenge": setup.authenticator.challenge,
630 "attestation": {
631 "credentialId": setup.authenticator.credential_id,
632 "clientDataJson": setup.authenticator.client_data_json,
633 "attestationObject": setup.authenticator.attestation_object,
634 "transports": setup.authenticator.transports,
635 },
636 }],
637 "oauthProviders": [],
638 },
639 {
640 "userName": "cratefield-delegated",
643 "userEmail": null,
644 "apiKeys": [{
645 "apiKeyName": "cratefield-da",
646 "publicKey": da_public_key,
647 "curveType": "API_KEY_CURVE_P256",
648 }],
649 "authenticators": [],
650 "oauthProviders": [],
651 },
652 ],
653 "rootQuorumThreshold": 1,
654 "wallet": {
655 "walletName": "cratefield",
656 "accounts": [
657 {
658 "curve": "CURVE_SECP256K1",
659 "pathFormat": "PATH_FORMAT_BIP32",
660 "path": "m/44'/60'/0'/0/0",
661 "addressFormat": "ADDRESS_FORMAT_ETHEREUM",
662 },
663 {
664 "curve": "CURVE_ED25519",
665 "pathFormat": "PATH_FORMAT_BIP32",
666 "path": "m/44'/501'/0'/0'",
667 "addressFormat": "ADDRESS_FORMAT_SOLANA",
668 },
669 ],
670 },
671 })
672}