use std::sync::Arc;
use bytes::Bytes;
use http::header::{CONTENT_TYPE, HeaderValue};
use http::{Request, StatusCode};
use serde_json::{Value, json};
use zeroize::Zeroizing;
use cratefield_core::{Clock, HttpClient, HttpError};
use cratefield_secrets::{Actor, SecretStore};
use cratefield_signer::SignerError;
use crate::policy::{AllowRule, da_consensus, validate_turnkey_id};
const API_BASE: &str = "https://api.turnkey.com";
const DENIED_STATUSES: [&str; 2] = [
"ACTIVITY_STATUS_REJECTED",
"ACTIVITY_STATUS_CONSENSUS_NEEDED",
];
#[derive(Debug, Clone)]
pub struct PasskeyAttestation {
pub authenticator_name: String,
pub challenge: String,
pub credential_id: String,
pub client_data_json: String,
pub attestation_object: String,
pub transports: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SubOrgSetup {
pub name: String,
pub end_user_name: String,
pub end_user_email: Option<String>,
pub authenticator: PasskeyAttestation,
pub rules: Vec<AllowRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubOrganization {
pub id: String,
pub end_user_id: String,
pub da_user_id: String,
pub wallet_id: String,
pub evm_address: Option<String>,
pub solana_address: Option<String>,
}
struct ListedUser {
id: String,
api_key_public_keys: Vec<String>,
}
pub struct TurnkeySigner {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
organization_id: String,
secrets: Arc<SecretStore>,
actor: Actor,
api_key_secret: String,
base: Option<String>,
}
impl TurnkeySigner {
#[must_use]
pub fn new(
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
organization_id: impl Into<String>,
secrets: Arc<SecretStore>,
actor: Actor,
api_key_secret: impl Into<String>,
) -> Self {
Self {
http,
clock,
organization_id: organization_id.into(),
secrets,
actor,
api_key_secret: api_key_secret.into(),
base: None,
}
}
#[must_use]
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = Some(base.into());
self
}
pub async fn da_public_key(&self) -> Result<String, SignerError> {
Ok(crate::stamp::compressed_public_key_hex(
&self.load_stamp_key().await?,
))
}
pub async fn create_sub_organization(
&self,
setup: &SubOrgSetup,
) -> Result<SubOrganization, SignerError> {
let da_public_key = self.da_public_key().await?;
let activity = self
.submit(
"create_sub_organization",
"ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7",
&self.organization_id,
create_sub_organization_parameters(setup, &da_public_key),
)
.await?;
let result = &activity["createSubOrganizationResult"];
let id = turnkey_id(result["subOrganizationId"].as_str(), "subOrganizationId")?;
let listed = self.list_users(&id).await?;
if listed.len() != 2 {
return Err(SignerError::Provider(format!(
"the new sub-organization holds {} users, not the two that were created; refusing \
to guess which is which",
listed.len()
)));
}
let carriers: Vec<&str> = listed
.iter()
.filter(|user| {
user.api_key_public_keys
.iter()
.any(|key| key == &da_public_key)
})
.map(|user| user.id.as_str())
.collect();
if carriers.len() != 1 {
return Err(SignerError::Provider(format!(
"exactly one user of the new sub-organization must carry the delegated access \
API key, found {}; refusing to guess which is which",
carriers.len()
)));
}
let da_user_id = carriers[0].to_owned();
let end_user_id = listed
.iter()
.map(|user| user.id.as_str())
.find(|user_id| *user_id != da_user_id)
.ok_or_else(|| {
SignerError::Provider(
"the new sub-organization has no second user to be the end user".to_owned(),
)
})?
.to_owned();
let wallet = &result["wallet"];
let wallet_id = wallet["walletId"]
.as_str()
.ok_or_else(|| {
SignerError::Provider("the sub-organization result has no walletId".to_owned())
})?
.to_owned();
let addresses: Vec<&str> = wallet["addresses"]
.as_array()
.map(|list| list.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let sub_org = SubOrganization {
id,
end_user_id,
da_user_id,
wallet_id,
evm_address: addresses.first().map(|address| (*address).to_owned()),
solana_address: addresses.get(1).map(|address| (*address).to_owned()),
};
for (at, rule) in setup.rules.iter().enumerate() {
self.create_policy(
&sub_org.id,
&format!("cratefield-da-allow-{at}"),
"EFFECT_ALLOW",
&sub_org.da_user_id,
Some(&rule.condition()),
"cratefield: allow the delegated access user one rule (issue #761)",
)
.await?;
}
self.update_root_quorum(&sub_org.id, 1, std::slice::from_ref(&sub_org.end_user_id))
.await?;
Ok(sub_org)
}
pub async fn create_policy(
&self,
sub_organization_id: &str,
name: &str,
effect: &str,
da_user_id: &str,
condition: Option<&str>,
notes: &str,
) -> Result<String, SignerError> {
let parameters = json!({
"policyName": name,
"effect": effect,
"consensus": da_consensus(da_user_id)?,
"condition": condition,
"notes": notes,
});
let activity = self
.submit(
"create_policy",
"ACTIVITY_TYPE_CREATE_POLICY_V3",
sub_organization_id,
parameters,
)
.await?;
turnkey_id(
activity["createPolicyResult"]["policyId"].as_str(),
"policyId",
)
}
pub async fn set_kill_switch(
&self,
sub_organization_id: &str,
da_user_id: &str,
) -> Result<String, SignerError> {
self.create_policy(
sub_organization_id,
"cratefield-da-kill-switch",
"EFFECT_DENY",
da_user_id,
Some("true"),
"cratefield: kill switch — deny the delegated access user everything (issue #761)",
)
.await
}
pub async fn clear_kill_switch(
&self,
sub_organization_id: &str,
policy_id: &str,
) -> Result<(), SignerError> {
self.submit(
"delete_policy",
"ACTIVITY_TYPE_DELETE_POLICY",
sub_organization_id,
json!({ "policyId": policy_id }),
)
.await?;
Ok(())
}
pub async fn update_root_quorum(
&self,
sub_organization_id: &str,
threshold: u32,
user_ids: &[String],
) -> Result<(), SignerError> {
for id in user_ids {
validate_turnkey_id(id)?;
}
self.submit(
"update_root_quorum",
"ACTIVITY_TYPE_UPDATE_ROOT_QUORUM",
sub_organization_id,
json!({ "threshold": threshold, "userIds": user_ids }),
)
.await?;
Ok(())
}
pub(crate) async fn sign_transaction(
&self,
sub_organization_id: &str,
sign_with: &str,
unsigned_transaction_hex: &str,
transaction_type: &str,
) -> Result<String, SignerError> {
let activity = self
.submit(
"sign_transaction",
"ACTIVITY_TYPE_SIGN_TRANSACTION_V2",
sub_organization_id,
json!({
"signWith": sign_with,
"unsignedTransaction": unsigned_transaction_hex,
"type": transaction_type,
}),
)
.await?;
activity["signTransactionResult"]["signedTransaction"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| {
SignerError::Provider("the sign result carries no signedTransaction".to_owned())
})
}
pub(crate) async fn sign_raw_payload(
&self,
sub_organization_id: &str,
sign_with: &str,
payload_hex: &str,
hash_function: &str,
) -> Result<(String, String, String), SignerError> {
let activity = self
.submit(
"sign_raw_payload",
"ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
sub_organization_id,
json!({
"signWith": sign_with,
"payload": payload_hex,
"encoding": "PAYLOAD_ENCODING_HEXADECIMAL",
"hashFunction": hash_function,
}),
)
.await?;
let result = &activity["signRawPayloadResult"];
let r = result["r"].as_str().ok_or_else(missing_signature)?;
let s = result["s"].as_str().ok_or_else(missing_signature)?;
let v = result["v"].as_str().ok_or_else(missing_signature)?;
Ok((r.to_owned(), s.to_owned(), v.to_owned()))
}
async fn submit(
&self,
path: &str,
activity_type: &str,
organization_id: &str,
parameters: Value,
) -> Result<Value, SignerError> {
if organization_id.is_empty() {
return Err(SignerError::Invalid(
"a Turnkey organization id is required".to_owned(),
));
}
let timestamp_ms = self.clock.now().unix_timestamp_nanos() / 1_000_000;
let body = serde_json::to_vec(&json!({
"type": activity_type,
"timestampMs": timestamp_ms.to_string(),
"organizationId": organization_id,
"parameters": parameters,
}))
.map_err(|err| SignerError::Provider(format!("the request could not be built: {err}")))?;
let answer = self.post("submit", path, body).await?;
let activity = &answer["activity"];
activity_result(activity_type, activity)
}
async fn list_users(&self, sub_organization_id: &str) -> Result<Vec<ListedUser>, SignerError> {
let body = serde_json::to_vec(&json!({ "organizationId": sub_organization_id })).map_err(
|err| SignerError::Provider(format!("the request could not be built: {err}")),
)?;
let answer = self.post("query", "list_users", body).await?;
let users = answer["users"].as_array().ok_or_else(|| {
SignerError::Provider("the list_users answer carries no users".to_owned())
})?;
let mut listed = Vec::with_capacity(users.len());
for user in users {
listed.push(ListedUser {
id: turnkey_id(user["userId"].as_str(), "userId")?,
api_key_public_keys: user["apiKeys"]
.as_array()
.map(|keys| {
keys.iter()
.filter_map(|key| key["credential"]["publicKey"].as_str())
.map(str::to_owned)
.collect()
})
.unwrap_or_default(),
});
}
Ok(listed)
}
async fn post(&self, kind: &str, name: &str, body: Vec<u8>) -> Result<Value, SignerError> {
let key = self.load_stamp_key().await?;
let stamp = crate::stamp::stamp_with_key(&body, &key);
drop(key);
let header = HeaderValue::from_str(&stamp)
.map_err(|_| SignerError::Provider("the stamp is not a legal header".to_owned()))?;
let uri = format!(
"{}/public/v1/{kind}/{name}",
self.base.as_deref().unwrap_or(API_BASE)
);
let request = Request::builder()
.method(http::Method::POST)
.uri(uri)
.header("X-Stamp", header)
.header(CONTENT_TYPE, "application/json")
.body(Bytes::from(body))
.map_err(|_| SignerError::Provider("the request could not be built".to_owned()))?;
let response = self.http.send(request).await.map_err(|err: HttpError| {
SignerError::Provider(format!("turnkey is unreachable: {err}"))
})?;
let status = response.status();
let text = String::from_utf8_lossy(response.body()).to_string();
if !status.is_success() {
return Err(SignerError::Provider(format!(
"turnkey answered {}: {text}",
StatusCode::as_u16(&status)
)));
}
serde_json::from_str(&text).map_err(|err| {
SignerError::Provider(format!("turnkey answered unparsable JSON: {err}"))
})
}
async fn load_stamp_key(&self) -> Result<p256::ecdsa::SigningKey, SignerError> {
let sealed = self
.secrets
.get(&self.api_key_secret, &self.actor)
.await
.map_err(|err| SignerError::Provider(format!("the secrets store refused: {err}")))?
.ok_or_else(|| SignerError::UnknownKey {
key_ref: self.api_key_secret.clone(),
})?;
let scalar = Zeroizing::new(<[u8; 32]>::try_from(sealed.expose()).map_err(|_| {
SignerError::Provider(format!(
"the Turnkey API key behind `{}` is {} bytes, not the 32-byte P-256 scalar",
self.api_key_secret,
sealed.expose().len()
))
})?);
crate::stamp::signing_key(scalar.as_slice())
}
}
fn activity_result(activity_type: &str, activity: &Value) -> Result<Value, SignerError> {
let status = activity["status"].as_str().unwrap_or("missing");
match status {
"ACTIVITY_STATUS_COMPLETED" => Ok(activity["result"].clone()),
status if DENIED_STATUSES.contains(&status) => Err(SignerError::Denied {
reason: format!(
"turnkey refused the {activity_type}: the activity is {status} — a policy denied \
it, or the delegated user's consensus was not enough"
),
}),
other => Err(SignerError::Provider(format!(
"the turnkey {activity_type} did not complete: status {other}"
))),
}
}
fn missing_signature() -> SignerError {
SignerError::Provider("the sign result carries no (r, s, v)".to_owned())
}
fn turnkey_id(value: Option<&str>, field: &str) -> Result<String, SignerError> {
let id = value
.ok_or_else(|| SignerError::Provider(format!("the activity result has no {field}")))?;
crate::policy::validate_turnkey_id(id)
}
fn create_sub_organization_parameters(setup: &SubOrgSetup, da_public_key: &str) -> Value {
json!({
"subOrganizationName": setup.name,
"rootUsers": [
{
"userName": setup.end_user_name,
"userEmail": setup.end_user_email,
"apiKeys": [],
"authenticators": [{
"authenticatorName": setup.authenticator.authenticator_name,
"challenge": setup.authenticator.challenge,
"attestation": {
"credentialId": setup.authenticator.credential_id,
"clientDataJson": setup.authenticator.client_data_json,
"attestationObject": setup.authenticator.attestation_object,
"transports": setup.authenticator.transports,
},
}],
"oauthProviders": [],
},
{
"userName": "cratefield-delegated",
"userEmail": null,
"apiKeys": [{
"apiKeyName": "cratefield-da",
"publicKey": da_public_key,
"curveType": "API_KEY_CURVE_P256",
}],
"authenticators": [],
"oauthProviders": [],
},
],
"rootQuorumThreshold": 1,
"wallet": {
"walletName": "cratefield",
"accounts": [
{
"curve": "CURVE_SECP256K1",
"pathFormat": "PATH_FORMAT_BIP32",
"path": "m/44'/60'/0'/0/0",
"addressFormat": "ADDRESS_FORMAT_ETHEREUM",
},
{
"curve": "CURVE_ED25519",
"pathFormat": "PATH_FORMAT_BIP32",
"path": "m/44'/501'/0'/0'",
"addressFormat": "ADDRESS_FORMAT_SOLANA",
},
],
},
})
}