1use crate::tempo::decode_key_authorization;
10use alloy_primitives::{Address, B256, hex};
11use alloy_signer_local::PrivateKeySigner;
12use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
13use eyre::Result;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16#[cfg(any(unix, windows))]
17use std::process::Command;
18use std::{
19 env,
20 sync::LazyLock,
21 time::{Duration, Instant},
22};
23use tempo_alloy::accounts::{TempoAccountsKeyAuthorization, TempoAccountsStore};
24use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization};
25use tokio::sync::Mutex;
26
27const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth";
29
30pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
32 url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
33}
34
35const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL";
37
38const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
39const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
40
41static AUTH_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
46
47#[derive(Clone, Debug)]
49pub struct EnsureAccessKeyConfig {
50 pub chain_id: u64,
52 pub(crate) service_url: String,
54 pub(crate) poll_interval: Duration,
56 pub(crate) timeout: Duration,
58 pub no_browser: bool,
61}
62
63impl EnsureAccessKeyConfig {
64 pub fn from_env(chain_id: u64) -> Self {
69 Self {
70 chain_id,
71 service_url: env::var(TEMPO_CLI_AUTH_URL_ENV)
72 .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()),
73 poll_interval: DEFAULT_POLL_INTERVAL,
74 timeout: DEFAULT_TIMEOUT,
75 no_browser: env::var_os("CI").is_some(),
76 }
77 }
78}
79
80fn open_browser(_url: &str) {
83 #[cfg(target_os = "macos")]
84 let _ = Command::new("open").arg(_url).spawn();
85 #[cfg(target_os = "windows")]
86 let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn();
87 #[cfg(all(unix, not(target_os = "macos")))]
88 let _ = Command::new("xdg-open").arg(_url).spawn();
89}
90
91#[derive(Debug, Clone)]
93pub struct AccessKeyOutcome {
94 pub wallet_address: Address,
95 pub key_address: Address,
96 pub chain_id: u64,
97}
98
99pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result<AccessKeyOutcome> {
102 let _guard = AUTH_LOCK.lock().await;
103
104 let signer = PrivateKeySigner::random();
105 let key_address = signer.address();
106 let pub_key_hex = format!(
109 "0x{}",
110 hex::encode(signer.credential().verifying_key().to_encoded_point(false).as_bytes()),
111 );
112
113 let code_verifier = random_code_verifier();
114 let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
115 let service = cfg.service_url.trim_end_matches('/');
116
117 let create_req = CreateCodeRequest {
118 chain_id: cfg.chain_id,
119 code_challenge: sha256_b64url(&code_verifier),
120 key_type: "secp256k1",
121 pub_key: pub_key_hex,
122 };
123 let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
124
125 let browser_url = format!("{service}?code={code}");
126 if cfg.no_browser {
127 let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
128 } else {
129 let _ = crate::sh_eprintln!(
130 "Opening wallet.tempo to authorize an access key…\n {browser_url}"
131 );
132 open_browser(&browser_url);
133 }
134
135 let poll = PollRequest { code_verifier };
136 let started = Instant::now();
137 loop {
138 let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
140
141 let resp = match send_res {
142 Ok(r) => r,
143 Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
144 tracing::debug!(error = %e, "transient error polling device code, retrying");
145 tokio::time::sleep(cfg.poll_interval).await;
146 continue;
147 }
148 Err(e) => return Err(e.into()),
149 };
150
151 let status = resp.status();
152 if !status.is_success() {
153 if is_transient_status(status) && started.elapsed() < cfg.timeout {
154 tracing::debug!(%status, "transient HTTP status polling device code, retrying");
155 tokio::time::sleep(cfg.poll_interval).await;
156 continue;
157 }
158 let body = resp.text().await.unwrap_or_default();
159 eyre::bail!("device-code poll failed ({status}): {body}");
160 }
161
162 let body: PollResponse = resp.json().await?;
163 match body {
164 PollResponse::Pending => {
165 if started.elapsed() > cfg.timeout {
166 eyre::bail!("timed out waiting for wallet authorization (code {code})");
167 }
168 tokio::time::sleep(cfg.poll_interval).await;
169 }
170 PollResponse::Expired => {
171 eyre::bail!("device code {code} expired before authorization");
172 }
173 PollResponse::Authorized { account_address, key_authorization } => {
174 let key_authorization = key_authorization.ok_or_else(|| {
175 eyre::eyre!("wallet authorized response missing key_authorization")
176 })?;
177 let signed = key_authorization.into_signed()?;
178 if signed.authorization.key_id != key_address {
181 eyre::bail!(
182 "wallet authorized key {} but the locally generated key is {}",
183 signed.authorization.key_id,
184 key_address,
185 );
186 }
187 if signed.authorization.chain_id != cfg.chain_id {
188 eyre::bail!(
189 "wallet authorized chain {} but {} was requested",
190 signed.authorization.chain_id,
191 cfg.chain_id,
192 );
193 }
194 if signed.authorization.key_type != SignatureType::Secp256k1 {
195 eyre::bail!(
196 "wallet returned keyType {:?} but secp256k1 was requested",
197 signed.authorization.key_type,
198 );
199 }
200 if signed.authorization.is_admin() {
202 eyre::bail!(
203 "wallet returned an admin key authorization, expected a limited access key"
204 );
205 }
206 if let Some(account) = signed.authorization.account
208 && account != account_address
209 {
210 eyre::bail!(
211 "wallet authorized account {account} but the authorizing account is {account_address}",
212 );
213 }
214 let chain_id = signed.authorization.chain_id;
215 TempoAccountsStore::default_path()?.upsert_secp256k1_access_key(
216 account_address,
217 &signer,
218 &signed,
219 )?;
220 return Ok(AccessKeyOutcome {
221 wallet_address: account_address,
222 key_address,
223 chain_id,
224 });
225 }
226 }
227 }
228}
229
230fn is_transient_error(err: &reqwest::Error) -> bool {
231 err.is_timeout() || err.is_connect() || err.is_request()
232}
233
234fn is_transient_status(status: reqwest::StatusCode) -> bool {
235 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
236}
237
238async fn create_code_with_retry(
240 client: &reqwest::Client,
241 service: &str,
242 req: &CreateCodeRequest,
243 timeout: Duration,
244) -> Result<String> {
245 let started = Instant::now();
246 let mut backoff = Duration::from_millis(500);
247 loop {
248 let send_res = client.post(format!("{service}/code")).json(req).send().await;
249
250 match send_res {
251 Ok(resp) => {
252 let status = resp.status();
253 if status.is_success() {
254 let CreateCodeResponse { code } = resp.json().await?;
255 return Ok(code);
256 }
257 if is_transient_status(status) && started.elapsed() < timeout {
258 tracing::debug!(%status, "transient HTTP status creating device code, retrying");
259 tokio::time::sleep(backoff).await;
260 backoff = (backoff * 2).min(Duration::from_secs(5));
261 continue;
262 }
263 let body = resp.text().await.unwrap_or_default();
264 eyre::bail!("device-code create failed ({status}): {body}");
265 }
266 Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
267 tracing::debug!(error = %e, "transient error creating device code, retrying");
268 tokio::time::sleep(backoff).await;
269 backoff = (backoff * 2).min(Duration::from_secs(5));
270 }
271 Err(e) => return Err(e.into()),
272 }
273 }
274}
275
276fn random_code_verifier() -> String {
277 let bytes = B256::random();
278 URL_SAFE_NO_PAD.encode(bytes.as_slice())
279}
280
281fn sha256_b64url(input: &str) -> String {
282 let digest = Sha256::digest(input.as_bytes());
283 URL_SAFE_NO_PAD.encode(digest)
284}
285
286#[derive(Serialize)]
287#[serde(rename_all = "camelCase")]
288struct CreateCodeRequest {
289 #[serde(serialize_with = "serialize_u64_hex")]
291 chain_id: u64,
292 code_challenge: String,
293 key_type: &'static str,
294 pub_key: String,
295}
296
297fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
298 s.serialize_str(&format!("0x{v:x}"))
299}
300
301#[derive(Deserialize)]
302struct CreateCodeResponse {
303 code: String,
304}
305
306#[derive(Serialize)]
307#[serde(rename_all = "camelCase")]
308struct PollRequest {
309 code_verifier: String,
310}
311
312#[derive(Deserialize)]
314#[serde(tag = "status", rename_all = "lowercase")]
315enum PollResponse {
316 Pending,
317 Expired,
318 Authorized {
319 #[serde(rename = "accountAddress", alias = "account_address")]
320 account_address: Address,
321 #[serde(rename = "keyAuthorization", alias = "key_authorization", default)]
322 key_authorization: Option<PollKeyAuthorization>,
323 },
324}
325
326#[derive(Deserialize)]
327#[serde(untagged)]
328enum PollKeyAuthorization {
329 Accounts(Box<TempoAccountsKeyAuthorization>),
330 Legacy(String),
331}
332
333impl PollKeyAuthorization {
334 fn into_signed(self) -> Result<SignedKeyAuthorization> {
335 match self {
336 Self::Accounts(authorization) => Ok(authorization.into_signed()),
337 Self::Legacy(encoded) => decode_key_authorization(&encoded),
338 }
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::tempo::{TEMPO_HOME_ENV, read_tempo_accounts_store, test_env_mutex};
346 use axum::{Json, Router, extract::State, routing::post};
347 use std::sync::{Arc, Mutex};
348
349 #[test]
350 fn pkce_challenge_matches_sdk_format() {
351 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
353 let challenge = sha256_b64url(verifier);
354 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
355 }
356
357 fn address_from_sec1_hex(s: &str) -> Address {
360 let stripped = s.strip_prefix("0x").unwrap_or(s);
361 let bytes = hex::decode(stripped).expect("valid hex");
362 let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
363 Address::from_public_key(&vk)
364 }
365
366 #[derive(Clone, Copy, Default)]
368 struct MockAuthShape {
369 admin: bool,
371 account: Option<Address>,
373 }
374
375 #[derive(Clone)]
376 struct MockState {
377 wallet: Arc<Mutex<Option<Address>>>,
378 key_id: Arc<Mutex<Option<Address>>>,
381 poll_chain_id: u64,
383 shape: MockAuthShape,
385 current_wire: bool,
388 }
389
390 async fn create_code_handler(
391 State(state): State<MockState>,
392 Json(body): Json<serde_json::Value>,
393 ) -> Json<serde_json::Value> {
394 let pub_key = body
397 .get("pubKey")
398 .and_then(|v| v.as_str())
399 .unwrap_or_else(|| panic!("pubKey missing: {body}"));
400 assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}");
401 let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}"));
402 let chain_str = chain_id
403 .as_str()
404 .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}"));
405 assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}");
406 let wallet: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
407 *state.wallet.lock().unwrap() = Some(wallet);
408 *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key));
409 Json(serde_json::json!({ "code": "ABCDEFGH" }))
410 }
411
412 fn signed_key_auth_hex(
415 chain_id: u64,
416 key_id: Address,
417 expiry: u64,
418 shape: MockAuthShape,
419 ) -> String {
420 use alloy_rlp::Encodable;
421 use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature};
422 let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id);
423 if shape.admin {
424 auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO));
427 } else {
428 auth = auth.with_expiry(expiry);
429 if let Some(account) = shape.account {
430 auth = auth.with_account(account);
431 }
432 }
433 let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({
434 "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0
435 }))
436 .unwrap();
437 let signed = auth.into_signed(sig);
438 let mut buf = Vec::new();
439 signed.encode(&mut buf);
440 format!("0x{}", hex::encode(buf))
441 }
442
443 async fn poll_handler(State(state): State<MockState>) -> Json<serde_json::Value> {
444 let wallet = state.wallet.lock().unwrap().expect("create_code must be called first");
445 let key_id = state.key_id.lock().unwrap().expect("create_code must be called first");
446 if state.current_wire {
447 Json(serde_json::json!({
448 "status": "authorized",
449 "accountAddress": wallet,
450 "keyAuthorization": {
451 "address": key_id,
452 "chainId": state.poll_chain_id,
453 "expiry": 9_999_999_999u64,
454 "keyId": key_id,
455 "keyType": "secp256k1",
456 "limits": [],
457 "signature": {
458 "type": "secp256k1",
459 "r": "0x0",
460 "s": "0x0",
461 "yParity": 0,
462 },
463 },
464 }))
465 } else {
466 Json(serde_json::json!({
467 "status": "authorized",
468 "account_address": wallet,
469 "key_authorization":
470 signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape),
471 }))
472 }
473 }
474
475 async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
477 spawn_mock_wallet_inner(poll_chain_id, MockAuthShape::default(), true).await
478 }
479
480 async fn spawn_mock_wallet_with(
482 poll_chain_id: u64,
483 shape: MockAuthShape,
484 ) -> (String, tokio::task::JoinHandle<()>) {
485 spawn_mock_wallet_inner(poll_chain_id, shape, false).await
486 }
487
488 async fn spawn_mock_wallet_inner(
489 poll_chain_id: u64,
490 shape: MockAuthShape,
491 current_wire: bool,
492 ) -> (String, tokio::task::JoinHandle<()>) {
493 let app = Router::new()
494 .route("/code", post(create_code_handler))
495 .route("/poll/{code}", post(poll_handler))
496 .with_state(MockState {
497 wallet: Arc::default(),
498 key_id: Arc::default(),
499 poll_chain_id,
500 shape,
501 current_wire,
502 });
503
504 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
505 let addr = listener.local_addr().unwrap();
506 let handle = tokio::spawn(async move {
507 axum::serve(listener, app).await.unwrap();
508 });
509 (format!("http://{addr}"), handle)
510 }
511
512 fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
513 EnsureAccessKeyConfig {
514 chain_id: 4217,
515 service_url,
516 poll_interval: Duration::from_millis(10),
517 timeout: Duration::from_secs(2),
518 no_browser: true,
519 }
520 }
521
522 #[tokio::test(flavor = "multi_thread")]
523 async fn ensure_access_key_happy_path_writes_accounts_store() {
524 let _g = test_env_mutex().lock().await;
526 let tmp = tempfile::tempdir().unwrap();
527 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
528
529 let (service_url, server) = spawn_mock_wallet(4217).await;
530 let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
531
532 let expected_wallet: Address =
533 "0x0000000000000000000000000000000000000042".parse().unwrap();
534 assert_eq!(outcome.chain_id, 4217);
535 assert_eq!(outcome.wallet_address, expected_wallet);
536
537 let file = read_tempo_accounts_store().expect("store.json written");
538 assert_eq!(file.keys.len(), 1);
539 let entry = &file.keys[0];
540 assert_eq!(entry.wallet_address, outcome.wallet_address);
541 assert_eq!(entry.key_address, outcome.key_address);
542 assert_eq!(entry.chain_id, 4217);
543 assert_eq!(entry.expiry, Some(9_999_999_999));
544 let decoded = entry.key_authorization.as_ref().expect("pending authorization");
545 assert_eq!(decoded.authorization.chain_id, 4217);
546
547 server.abort();
548 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
549 }
550
551 #[tokio::test(flavor = "multi_thread")]
552 async fn ensure_access_key_rejects_wrong_chain_id() {
553 let _g = test_env_mutex().lock().await;
557 let tmp = tempfile::tempdir().unwrap();
558 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
559
560 let (service_url, server) = spawn_mock_wallet(99999).await;
561 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
562 assert!(
563 err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
564 "expected chain mismatch error, got: {err}"
565 );
566 assert!(read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()));
567
568 server.abort();
569 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
570 }
571
572 #[tokio::test(flavor = "multi_thread")]
573 async fn ensure_access_key_rejects_admin_authorization() {
574 let _g = test_env_mutex().lock().await;
576 let tmp = tempfile::tempdir().unwrap();
577 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
578
579 let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
582 let shape = MockAuthShape { admin: true, account: Some(account) };
583 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
584
585 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
586 assert!(
587 err.to_string().contains("admin key authorization"),
588 "expected admin-key rejection, got: {err}"
589 );
590 assert!(
591 read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
592 "an admin authorization must not be persisted to store.json"
593 );
594
595 server.abort();
596 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
597 }
598
599 #[tokio::test(flavor = "multi_thread")]
600 async fn ensure_access_key_rejects_cross_account_binding() {
601 let _g = test_env_mutex().lock().await;
604 let tmp = tempfile::tempdir().unwrap();
605 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
606
607 let other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap();
609 let shape = MockAuthShape { admin: false, account: Some(other) };
610 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
611
612 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
613 assert!(
614 err.to_string().contains("wallet authorized account"),
615 "expected cross-account rejection, got: {err}"
616 );
617 assert!(
618 read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
619 "a cross-account authorization must not be persisted to store.json"
620 );
621
622 server.abort();
623 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
624 }
625}