1use crate::tempo::{
10 KeyEntry, KeyType, StoredTokenLimit, WalletType, decode_key_authorization, upsert_key_entry,
11};
12use alloy_primitives::{Address, B256, hex};
13use alloy_signer_local::PrivateKeySigner;
14use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
15use eyre::Result;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18#[cfg(any(unix, windows))]
19use std::process::Command;
20use std::{
21 env,
22 sync::LazyLock,
23 time::{Duration, Instant},
24};
25use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization};
26use tokio::sync::Mutex;
27
28const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth";
30
31pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
33 url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
34}
35
36const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL";
38
39const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
40const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
41
42static AUTH_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
47
48#[derive(Clone, Debug)]
50pub struct EnsureAccessKeyConfig {
51 pub chain_id: u64,
53 pub(crate) service_url: String,
55 pub(crate) poll_interval: Duration,
57 pub(crate) timeout: Duration,
59 pub no_browser: bool,
62}
63
64impl EnsureAccessKeyConfig {
65 pub fn from_env(chain_id: u64) -> Self {
70 Self {
71 chain_id,
72 service_url: env::var(TEMPO_CLI_AUTH_URL_ENV)
73 .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()),
74 poll_interval: DEFAULT_POLL_INTERVAL,
75 timeout: DEFAULT_TIMEOUT,
76 no_browser: env::var_os("CI").is_some(),
77 }
78 }
79}
80
81fn open_browser(_url: &str) {
84 #[cfg(target_os = "macos")]
85 let _ = Command::new("open").arg(_url).spawn();
86 #[cfg(target_os = "windows")]
87 let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn();
88 #[cfg(all(unix, not(target_os = "macos")))]
89 let _ = Command::new("xdg-open").arg(_url).spawn();
90}
91
92#[derive(Debug, Clone)]
94pub struct AccessKeyOutcome {
95 pub wallet_address: Address,
96 pub key_address: Address,
97 pub chain_id: u64,
98}
99
100pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result<AccessKeyOutcome> {
103 let _guard = AUTH_LOCK.lock().await;
104
105 let signer = PrivateKeySigner::random();
106 let key_address = signer.address();
107 let pub_key_hex = format!(
110 "0x{}",
111 hex::encode(signer.credential().verifying_key().to_encoded_point(false).as_bytes()),
112 );
113
114 let code_verifier = random_code_verifier();
115 let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
116 let service = cfg.service_url.trim_end_matches('/');
117
118 let create_req = CreateCodeRequest {
119 chain_id: cfg.chain_id,
120 code_challenge: sha256_b64url(&code_verifier),
121 key_type: "secp256k1",
122 pub_key: pub_key_hex,
123 };
124 let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
125
126 let browser_url = format!("{service}?code={code}");
127 if cfg.no_browser {
128 let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
129 } else {
130 let _ = crate::sh_eprintln!(
131 "Opening wallet.tempo to authorize an access key…\n {browser_url}"
132 );
133 open_browser(&browser_url);
134 }
135
136 let poll = PollRequest { code_verifier };
137 let started = Instant::now();
138 loop {
139 let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
141
142 let resp = match send_res {
143 Ok(r) => r,
144 Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
145 tracing::debug!(error = %e, "transient error polling device code, retrying");
146 tokio::time::sleep(cfg.poll_interval).await;
147 continue;
148 }
149 Err(e) => return Err(e.into()),
150 };
151
152 let status = resp.status();
153 if !status.is_success() {
154 if is_transient_status(status) && started.elapsed() < cfg.timeout {
155 tracing::debug!(%status, "transient HTTP status polling device code, retrying");
156 tokio::time::sleep(cfg.poll_interval).await;
157 continue;
158 }
159 let body = resp.text().await.unwrap_or_default();
160 eyre::bail!("device-code poll failed ({status}): {body}");
161 }
162
163 let body: PollResponse = resp.json().await?;
164 match body {
165 PollResponse::Pending => {
166 if started.elapsed() > cfg.timeout {
167 eyre::bail!("timed out waiting for wallet authorization (code {code})");
168 }
169 tokio::time::sleep(cfg.poll_interval).await;
170 }
171 PollResponse::Expired => {
172 eyre::bail!("device code {code} expired before authorization");
173 }
174 PollResponse::Authorized { account_address, key_authorization } => {
175 let hex_str = key_authorization.ok_or_else(|| {
176 eyre::eyre!("wallet authorized response missing key_authorization")
177 })?;
178 let signed: SignedKeyAuthorization = decode_key_authorization(&hex_str)?;
179 if signed.authorization.key_id != key_address {
182 eyre::bail!(
183 "wallet authorized key {} but the locally generated key is {}",
184 signed.authorization.key_id,
185 key_address,
186 );
187 }
188 if signed.authorization.chain_id != cfg.chain_id {
189 eyre::bail!(
190 "wallet authorized chain {} but {} was requested",
191 signed.authorization.chain_id,
192 cfg.chain_id,
193 );
194 }
195 if signed.authorization.key_type != SignatureType::Secp256k1 {
196 eyre::bail!(
197 "wallet returned keyType {:?} but secp256k1 was requested",
198 signed.authorization.key_type,
199 );
200 }
201 let chain_id = signed.authorization.chain_id;
202 let key_authorization =
203 if hex_str.starts_with("0x") { hex_str } else { format!("0x{hex_str}") };
204 let entry = KeyEntry {
205 wallet_type: WalletType::Passkey,
206 wallet_address: account_address,
207 chain_id,
208 key_type: match signed.authorization.key_type {
209 SignatureType::P256 => KeyType::P256,
210 SignatureType::WebAuthn => KeyType::WebAuthn,
211 _ => KeyType::Secp256k1,
212 },
213 key_address: Some(key_address),
214 key: Some(format!("0x{}", hex::encode(signer.to_bytes()))),
215 key_authorization: Some(key_authorization),
216 expiry: signed.authorization.expiry.map(|n| n.get()),
217 limits: signed
218 .authorization
219 .limits
220 .unwrap_or_default()
221 .into_iter()
222 .map(|l| StoredTokenLimit { currency: l.token, limit: l.limit.to_string() })
223 .collect(),
224 };
225 upsert_key_entry(entry)?;
226 return Ok(AccessKeyOutcome {
227 wallet_address: account_address,
228 key_address,
229 chain_id,
230 });
231 }
232 }
233 }
234}
235
236fn is_transient_error(err: &reqwest::Error) -> bool {
237 err.is_timeout() || err.is_connect() || err.is_request()
238}
239
240fn is_transient_status(status: reqwest::StatusCode) -> bool {
241 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
242}
243
244async fn create_code_with_retry(
246 client: &reqwest::Client,
247 service: &str,
248 req: &CreateCodeRequest,
249 timeout: Duration,
250) -> Result<String> {
251 let started = Instant::now();
252 let mut backoff = Duration::from_millis(500);
253 loop {
254 let send_res = client.post(format!("{service}/code")).json(req).send().await;
255
256 match send_res {
257 Ok(resp) => {
258 let status = resp.status();
259 if status.is_success() {
260 let CreateCodeResponse { code } = resp.json().await?;
261 return Ok(code);
262 }
263 if is_transient_status(status) && started.elapsed() < timeout {
264 tracing::debug!(%status, "transient HTTP status creating device code, retrying");
265 tokio::time::sleep(backoff).await;
266 backoff = (backoff * 2).min(Duration::from_secs(5));
267 continue;
268 }
269 let body = resp.text().await.unwrap_or_default();
270 eyre::bail!("device-code create failed ({status}): {body}");
271 }
272 Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
273 tracing::debug!(error = %e, "transient error creating device code, retrying");
274 tokio::time::sleep(backoff).await;
275 backoff = (backoff * 2).min(Duration::from_secs(5));
276 }
277 Err(e) => return Err(e.into()),
278 }
279 }
280}
281
282fn random_code_verifier() -> String {
283 let bytes = B256::random();
284 URL_SAFE_NO_PAD.encode(bytes.as_slice())
285}
286
287fn sha256_b64url(input: &str) -> String {
288 let digest = Sha256::digest(input.as_bytes());
289 URL_SAFE_NO_PAD.encode(digest)
290}
291
292#[derive(Serialize)]
293#[serde(rename_all = "camelCase")]
294struct CreateCodeRequest {
295 #[serde(serialize_with = "serialize_u64_hex")]
297 chain_id: u64,
298 code_challenge: String,
299 key_type: &'static str,
300 pub_key: String,
301}
302
303fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
304 s.serialize_str(&format!("0x{v:x}"))
305}
306
307#[derive(Deserialize)]
308struct CreateCodeResponse {
309 code: String,
310}
311
312#[derive(Serialize)]
313#[serde(rename_all = "camelCase")]
314struct PollRequest {
315 code_verifier: String,
316}
317
318#[derive(Deserialize)]
320#[serde(tag = "status", rename_all = "lowercase")]
321enum PollResponse {
322 Pending,
323 Expired,
324 Authorized {
325 account_address: Address,
326 #[serde(default)]
327 key_authorization: Option<String>,
328 },
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::tempo::{TEMPO_HOME_ENV, read_tempo_keys_file, test_env_mutex};
335 use axum::{Json, Router, extract::State, routing::post};
336 use std::sync::{Arc, Mutex};
337
338 #[test]
339 fn pkce_challenge_matches_sdk_format() {
340 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
342 let challenge = sha256_b64url(verifier);
343 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
344 }
345
346 fn address_from_sec1_hex(s: &str) -> Address {
349 let stripped = s.strip_prefix("0x").unwrap_or(s);
350 let bytes = hex::decode(stripped).expect("valid hex");
351 let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
352 Address::from_public_key(&vk)
353 }
354
355 #[derive(Clone)]
356 struct MockState {
357 wallet: Arc<Mutex<Option<Address>>>,
358 key_id: Arc<Mutex<Option<Address>>>,
361 poll_chain_id: u64,
363 }
364
365 async fn create_code_handler(
366 State(state): State<MockState>,
367 Json(body): Json<serde_json::Value>,
368 ) -> Json<serde_json::Value> {
369 let pub_key = body
372 .get("pubKey")
373 .and_then(|v| v.as_str())
374 .unwrap_or_else(|| panic!("pubKey missing: {body}"));
375 assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}");
376 let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}"));
377 let chain_str = chain_id
378 .as_str()
379 .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}"));
380 assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}");
381 let wallet: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
382 *state.wallet.lock().unwrap() = Some(wallet);
383 *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key));
384 Json(serde_json::json!({ "code": "ABCDEFGH" }))
385 }
386
387 fn signed_key_auth_hex(chain_id: u64, key_id: Address, expiry: u64) -> String {
390 use alloy_rlp::Encodable;
391 use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature};
392 let auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id)
393 .with_expiry(expiry);
394 let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({
395 "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0
396 }))
397 .unwrap();
398 let signed = auth.into_signed(sig);
399 let mut buf = Vec::new();
400 signed.encode(&mut buf);
401 format!("0x{}", hex::encode(buf))
402 }
403
404 async fn poll_handler(State(state): State<MockState>) -> Json<serde_json::Value> {
405 let wallet = state.wallet.lock().unwrap().expect("create_code must be called first");
406 let key_id = state.key_id.lock().unwrap().expect("create_code must be called first");
407 Json(serde_json::json!({
408 "status": "authorized",
409 "account_address": wallet,
410 "key_authorization": signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999),
411 }))
412 }
413
414 async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
416 let app = Router::new()
417 .route("/code", post(create_code_handler))
418 .route("/poll/{code}", post(poll_handler))
419 .with_state(MockState {
420 wallet: Arc::default(),
421 key_id: Arc::default(),
422 poll_chain_id,
423 });
424
425 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
426 let addr = listener.local_addr().unwrap();
427 let handle = tokio::spawn(async move {
428 axum::serve(listener, app).await.unwrap();
429 });
430 (format!("http://{addr}"), handle)
431 }
432
433 fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
434 EnsureAccessKeyConfig {
435 chain_id: 4217,
436 service_url,
437 poll_interval: Duration::from_millis(10),
438 timeout: Duration::from_secs(2),
439 no_browser: true,
440 }
441 }
442
443 #[tokio::test(flavor = "multi_thread")]
444 async fn ensure_access_key_happy_path_writes_keys_toml() {
445 let _g = test_env_mutex().lock().await;
447 let tmp = tempfile::tempdir().unwrap();
448 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
449
450 let (service_url, server) = spawn_mock_wallet(4217).await;
451 let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
452
453 let expected_wallet: Address =
454 "0x0000000000000000000000000000000000000042".parse().unwrap();
455 assert_eq!(outcome.chain_id, 4217);
456 assert_eq!(outcome.wallet_address, expected_wallet);
457
458 let file = read_tempo_keys_file().expect("keys.toml written");
459 assert_eq!(file.keys.len(), 1);
460 let entry = &file.keys[0];
461 assert_eq!(entry.wallet_address, outcome.wallet_address);
462 assert_eq!(entry.key_address, Some(outcome.key_address));
463 assert_eq!(entry.chain_id, 4217);
464 assert_eq!(entry.expiry, Some(9_999_999_999));
465 let decoded: tempo_primitives::transaction::SignedKeyAuthorization =
466 crate::tempo::decode_key_authorization(entry.key_authorization.as_deref().unwrap())
467 .expect("RLP roundtrip");
468 assert_eq!(decoded.authorization.chain_id, 4217);
469
470 server.abort();
471 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
472 }
473
474 #[tokio::test(flavor = "multi_thread")]
475 async fn ensure_access_key_rejects_wrong_chain_id() {
476 let _g = test_env_mutex().lock().await;
480 let tmp = tempfile::tempdir().unwrap();
481 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
482
483 let (service_url, server) = spawn_mock_wallet(99999).await;
484 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
485 assert!(
486 err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
487 "expected chain mismatch error, got: {err}"
488 );
489 assert!(read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()));
490
491 server.abort();
492 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
493 }
494}