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};
16use std::{
17 env,
18 sync::LazyLock,
19 time::{Duration, Instant},
20};
21use tempo_alloy::accounts::{TempoAccountsKeyAuthorization, TempoAccountsStore};
22use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization};
23use tokio::sync::Mutex;
24
25#[cfg(any(unix, windows))]
26use std::process::Command;
27
28const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth";
30
31const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL";
33
34const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
35const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
36
37static AUTH_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
42
43#[derive(Clone, Debug)]
45pub struct EnsureAccessKeyConfig {
46 pub chain_id: u64,
48 pub(crate) service_url: String,
50 pub(crate) poll_interval: Duration,
52 pub(crate) timeout: Duration,
54 pub no_browser: bool,
57}
58
59impl EnsureAccessKeyConfig {
60 pub fn from_env(chain_id: u64) -> Self {
65 Self {
66 chain_id,
67 service_url: env::var(TEMPO_CLI_AUTH_URL_ENV)
68 .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()),
69 poll_interval: DEFAULT_POLL_INTERVAL,
70 timeout: DEFAULT_TIMEOUT,
71 no_browser: env::var_os("CI").is_some(),
72 }
73 }
74}
75
76fn open_browser(_url: &str) {
79 #[cfg(target_os = "macos")]
80 let _ = Command::new("open").arg(_url).spawn();
81 #[cfg(target_os = "windows")]
82 let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn();
83 #[cfg(all(unix, not(target_os = "macos")))]
84 let _ = Command::new("xdg-open").arg(_url).spawn();
85}
86
87#[derive(Debug, Clone)]
89pub struct AccessKeyOutcome {
90 pub wallet_address: Address,
91 pub key_address: Address,
92 pub chain_id: u64,
93}
94
95pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result<AccessKeyOutcome> {
98 let _guard = AUTH_LOCK.lock().await;
99
100 let signer = PrivateKeySigner::random();
101 let key_address = signer.address();
102 let pub_key_hex = hex::encode_prefixed(
105 signer.credential().verifying_key().to_encoded_point(false).as_bytes(),
106 );
107
108 let code_verifier = random_code_verifier();
109 let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
110 let service = cfg.service_url.trim_end_matches('/');
111
112 let create_req = CreateCodeRequest {
113 chain_id: cfg.chain_id,
114 code_challenge: sha256_b64url(&code_verifier),
115 key_type: "secp256k1",
116 pub_key: pub_key_hex,
117 };
118 let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
119
120 let browser_url = format!("{service}?code={code}");
121 if cfg.no_browser {
122 let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
123 } else {
124 let _ = crate::sh_eprintln!(
125 "Opening wallet.tempo to authorize an access key…\n {browser_url}"
126 );
127 open_browser(&browser_url);
128 }
129
130 let poll = PollRequest { code_verifier };
131 let started = Instant::now();
132 loop {
133 let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
135
136 let resp = match send_res {
137 Ok(r) => r,
138 Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
139 tracing::debug!(error = %e, "transient error polling device code, retrying");
140 tokio::time::sleep(cfg.poll_interval).await;
141 continue;
142 }
143 Err(e) => return Err(e.into()),
144 };
145
146 let status = resp.status();
147 if !status.is_success() {
148 if is_transient_status(status) && started.elapsed() < cfg.timeout {
149 tracing::debug!(%status, "transient HTTP status polling device code, retrying");
150 tokio::time::sleep(cfg.poll_interval).await;
151 continue;
152 }
153 let body = resp.text().await.unwrap_or_default();
154 eyre::bail!("device-code poll failed ({status}): {body}");
155 }
156
157 let body: PollResponse = resp.json().await?;
158 match body {
159 PollResponse::Pending => {
160 if started.elapsed() > cfg.timeout {
161 eyre::bail!("timed out waiting for wallet authorization (code {code})");
162 }
163 tokio::time::sleep(cfg.poll_interval).await;
164 }
165 PollResponse::Expired => {
166 eyre::bail!("device code {code} expired before authorization");
167 }
168 PollResponse::Authorized { account_address, key_authorization } => {
169 let key_authorization = key_authorization.ok_or_else(|| {
170 eyre::eyre!("wallet authorized response missing key_authorization")
171 })?;
172 let signed = key_authorization.into_signed()?;
173 if signed.authorization.key_id != key_address {
176 eyre::bail!(
177 "wallet authorized key {} but the locally generated key is {}",
178 signed.authorization.key_id,
179 key_address,
180 );
181 }
182 if signed.authorization.chain_id != cfg.chain_id {
183 eyre::bail!(
184 "wallet authorized chain {} but {} was requested",
185 signed.authorization.chain_id,
186 cfg.chain_id,
187 );
188 }
189 if signed.authorization.key_type != SignatureType::Secp256k1 {
190 eyre::bail!(
191 "wallet returned keyType {:?} but secp256k1 was requested",
192 signed.authorization.key_type,
193 );
194 }
195 if signed.authorization.is_admin() {
197 eyre::bail!(
198 "wallet returned an admin key authorization, expected a limited access key"
199 );
200 }
201 if let Some(account) = signed.authorization.account
203 && account != account_address
204 {
205 eyre::bail!(
206 "wallet authorized account {account} but the authorizing account is {account_address}",
207 );
208 }
209 let chain_id = signed.authorization.chain_id;
210 TempoAccountsStore::default_path()?.upsert_secp256k1_access_key(
211 account_address,
212 &signer,
213 &signed,
214 )?;
215 return Ok(AccessKeyOutcome {
216 wallet_address: account_address,
217 key_address,
218 chain_id,
219 });
220 }
221 }
222 }
223}
224
225fn is_transient_error(err: &reqwest::Error) -> bool {
226 err.is_timeout() || err.is_connect() || err.is_request()
227}
228
229fn is_transient_status(status: reqwest::StatusCode) -> bool {
230 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
231}
232
233async fn create_code_with_retry(
235 client: &reqwest::Client,
236 service: &str,
237 req: &CreateCodeRequest,
238 timeout: Duration,
239) -> Result<String> {
240 let started = Instant::now();
241 let mut backoff = Duration::from_millis(500);
242 loop {
243 let send_res = client.post(format!("{service}/code")).json(req).send().await;
244
245 match send_res {
246 Ok(resp) => {
247 let status = resp.status();
248 if status.is_success() {
249 let CreateCodeResponse { code } = resp.json().await?;
250 return Ok(code);
251 }
252 if is_transient_status(status) && started.elapsed() < timeout {
253 tracing::debug!(%status, "transient HTTP status creating device code, retrying");
254 tokio::time::sleep(backoff).await;
255 backoff = (backoff * 2).min(Duration::from_secs(5));
256 continue;
257 }
258 let body = resp.text().await.unwrap_or_default();
259 eyre::bail!("device-code create failed ({status}): {body}");
260 }
261 Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
262 tracing::debug!(error = %e, "transient error creating device code, retrying");
263 tokio::time::sleep(backoff).await;
264 backoff = (backoff * 2).min(Duration::from_secs(5));
265 }
266 Err(e) => return Err(e.into()),
267 }
268 }
269}
270
271fn random_code_verifier() -> String {
272 let bytes = B256::random();
273 URL_SAFE_NO_PAD.encode(bytes.as_slice())
274}
275
276fn sha256_b64url(input: &str) -> String {
277 let digest = Sha256::digest(input.as_bytes());
278 URL_SAFE_NO_PAD.encode(digest)
279}
280
281#[derive(Serialize)]
282#[serde(rename_all = "camelCase")]
283struct CreateCodeRequest {
284 #[serde(serialize_with = "serialize_u64_hex")]
286 chain_id: u64,
287 code_challenge: String,
288 key_type: &'static str,
289 pub_key: String,
290}
291
292fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
293 s.serialize_str(&format!("0x{v:x}"))
294}
295
296#[derive(Deserialize)]
297struct CreateCodeResponse {
298 code: String,
299}
300
301#[derive(Serialize)]
302#[serde(rename_all = "camelCase")]
303struct PollRequest {
304 code_verifier: String,
305}
306
307#[derive(Deserialize)]
309#[serde(tag = "status", rename_all = "lowercase")]
310enum PollResponse {
311 Pending,
312 Expired,
313 Authorized {
314 #[serde(rename = "accountAddress", alias = "account_address")]
315 account_address: Address,
316 #[serde(rename = "keyAuthorization", alias = "key_authorization", default)]
317 key_authorization: Option<PollKeyAuthorization>,
318 },
319}
320
321#[derive(Deserialize)]
322#[serde(untagged)]
323enum PollKeyAuthorization {
324 Accounts(Box<TempoAccountsKeyAuthorization>),
325 Legacy(String),
326}
327
328impl PollKeyAuthorization {
329 fn into_signed(self) -> Result<SignedKeyAuthorization> {
330 match self {
331 Self::Accounts(authorization) => Ok(authorization.into_signed()),
332 Self::Legacy(encoded) => decode_key_authorization(&encoded),
333 }
334 }
335}
336
337pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
339 url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
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 alloy_primitives::address;
347 use axum::{Json, Router, extract::State, routing::post};
348 use std::sync::{Arc, Mutex};
349
350 #[test]
351 fn pkce_challenge_matches_sdk_format() {
352 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
354 let challenge = sha256_b64url(verifier);
355 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
356 }
357
358 fn address_from_sec1_hex(s: &str) -> Address {
361 let stripped = s.strip_prefix("0x").unwrap_or(s);
362 let bytes = hex::decode(stripped).expect("valid hex");
363 let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
364 Address::from_public_key(&vk)
365 }
366
367 #[derive(Clone, Copy, Default)]
369 struct MockAuthShape {
370 admin: bool,
372 account: Option<Address>,
374 }
375
376 #[derive(Clone)]
377 struct MockState {
378 wallet: Arc<Mutex<Option<Address>>>,
379 key_id: Arc<Mutex<Option<Address>>>,
382 poll_chain_id: u64,
384 shape: MockAuthShape,
386 current_wire: bool,
389 }
390
391 async fn create_code_handler(
392 State(state): State<MockState>,
393 Json(body): Json<serde_json::Value>,
394 ) -> Json<serde_json::Value> {
395 let pub_key = body
398 .get("pubKey")
399 .and_then(|v| v.as_str())
400 .unwrap_or_else(|| panic!("pubKey missing: {body}"));
401 assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}");
402 let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}"));
403 let chain_str = chain_id
404 .as_str()
405 .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}"));
406 assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}");
407 let wallet = Address::with_last_byte(0x42);
408 *state.wallet.lock().unwrap() = Some(wallet);
409 *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key));
410 Json(serde_json::json!({ "code": "ABCDEFGH" }))
411 }
412
413 fn signed_key_auth_hex(
416 chain_id: u64,
417 key_id: Address,
418 expiry: u64,
419 shape: MockAuthShape,
420 ) -> String {
421 use alloy_rlp::Encodable;
422 use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature};
423 let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id);
424 if shape.admin {
425 auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO));
428 } else {
429 auth = auth.with_expiry(expiry);
430 if let Some(account) = shape.account {
431 auth = auth.with_account(account);
432 }
433 }
434 let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({
435 "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0
436 }))
437 .unwrap();
438 let signed = auth.into_signed(sig);
439 let mut buf = Vec::new();
440 signed.encode(&mut buf);
441 hex::encode_prefixed(buf)
442 }
443
444 async fn poll_handler(State(state): State<MockState>) -> Json<serde_json::Value> {
445 let wallet = state.wallet.lock().unwrap().expect("create_code must be called first");
446 let key_id = state.key_id.lock().unwrap().expect("create_code must be called first");
447 if state.current_wire {
448 Json(serde_json::json!({
449 "status": "authorized",
450 "accountAddress": wallet,
451 "keyAuthorization": {
452 "address": key_id,
453 "chainId": state.poll_chain_id,
454 "expiry": 9_999_999_999u64,
455 "keyId": key_id,
456 "keyType": "secp256k1",
457 "limits": [],
458 "signature": {
459 "type": "secp256k1",
460 "r": "0x0",
461 "s": "0x0",
462 "yParity": 0,
463 },
464 },
465 }))
466 } else {
467 Json(serde_json::json!({
468 "status": "authorized",
469 "account_address": wallet,
470 "key_authorization":
471 signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape),
472 }))
473 }
474 }
475
476 async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
478 spawn_mock_wallet_inner(poll_chain_id, MockAuthShape::default(), true).await
479 }
480
481 async fn spawn_mock_wallet_with(
483 poll_chain_id: u64,
484 shape: MockAuthShape,
485 ) -> (String, tokio::task::JoinHandle<()>) {
486 spawn_mock_wallet_inner(poll_chain_id, shape, false).await
487 }
488
489 async fn spawn_mock_wallet_inner(
490 poll_chain_id: u64,
491 shape: MockAuthShape,
492 current_wire: bool,
493 ) -> (String, tokio::task::JoinHandle<()>) {
494 let app = Router::new()
495 .route("/code", post(create_code_handler))
496 .route("/poll/{code}", post(poll_handler))
497 .with_state(MockState {
498 wallet: Arc::default(),
499 key_id: Arc::default(),
500 poll_chain_id,
501 shape,
502 current_wire,
503 });
504
505 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
506 let addr = listener.local_addr().unwrap();
507 let handle = tokio::spawn(async move {
508 axum::serve(listener, app).await.unwrap();
509 });
510 (format!("http://{addr}"), handle)
511 }
512
513 fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
514 EnsureAccessKeyConfig {
515 chain_id: 4217,
516 service_url,
517 poll_interval: Duration::from_millis(10),
518 timeout: Duration::from_secs(2),
519 no_browser: true,
520 }
521 }
522
523 #[tokio::test(flavor = "multi_thread")]
524 async fn ensure_access_key_happy_path_writes_accounts_store() {
525 let _g = test_env_mutex().lock().await;
527 let tmp = tempfile::tempdir().unwrap();
528 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
529
530 let (service_url, server) = spawn_mock_wallet(4217).await;
531 let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
532
533 let expected_wallet = Address::with_last_byte(0x42);
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::with_last_byte(0x42);
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");
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}