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 = format!(
105 "0x{}",
106 hex::encode(signer.credential().verifying_key().to_encoded_point(false).as_bytes()),
107 );
108
109 let code_verifier = random_code_verifier();
110 let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
111 let service = cfg.service_url.trim_end_matches('/');
112
113 let create_req = CreateCodeRequest {
114 chain_id: cfg.chain_id,
115 code_challenge: sha256_b64url(&code_verifier),
116 key_type: "secp256k1",
117 pub_key: pub_key_hex,
118 };
119 let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
120
121 let browser_url = format!("{service}?code={code}");
122 if cfg.no_browser {
123 let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
124 } else {
125 let _ = crate::sh_eprintln!(
126 "Opening wallet.tempo to authorize an access key…\n {browser_url}"
127 );
128 open_browser(&browser_url);
129 }
130
131 let poll = PollRequest { code_verifier };
132 let started = Instant::now();
133 loop {
134 let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
136
137 let resp = match send_res {
138 Ok(r) => r,
139 Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
140 tracing::debug!(error = %e, "transient error polling device code, retrying");
141 tokio::time::sleep(cfg.poll_interval).await;
142 continue;
143 }
144 Err(e) => return Err(e.into()),
145 };
146
147 let status = resp.status();
148 if !status.is_success() {
149 if is_transient_status(status) && started.elapsed() < cfg.timeout {
150 tracing::debug!(%status, "transient HTTP status polling device code, retrying");
151 tokio::time::sleep(cfg.poll_interval).await;
152 continue;
153 }
154 let body = resp.text().await.unwrap_or_default();
155 eyre::bail!("device-code poll failed ({status}): {body}");
156 }
157
158 let body: PollResponse = resp.json().await?;
159 match body {
160 PollResponse::Pending => {
161 if started.elapsed() > cfg.timeout {
162 eyre::bail!("timed out waiting for wallet authorization (code {code})");
163 }
164 tokio::time::sleep(cfg.poll_interval).await;
165 }
166 PollResponse::Expired => {
167 eyre::bail!("device code {code} expired before authorization");
168 }
169 PollResponse::Authorized { account_address, key_authorization } => {
170 let key_authorization = key_authorization.ok_or_else(|| {
171 eyre::eyre!("wallet authorized response missing key_authorization")
172 })?;
173 let signed = key_authorization.into_signed()?;
174 if signed.authorization.key_id != key_address {
177 eyre::bail!(
178 "wallet authorized key {} but the locally generated key is {}",
179 signed.authorization.key_id,
180 key_address,
181 );
182 }
183 if signed.authorization.chain_id != cfg.chain_id {
184 eyre::bail!(
185 "wallet authorized chain {} but {} was requested",
186 signed.authorization.chain_id,
187 cfg.chain_id,
188 );
189 }
190 if signed.authorization.key_type != SignatureType::Secp256k1 {
191 eyre::bail!(
192 "wallet returned keyType {:?} but secp256k1 was requested",
193 signed.authorization.key_type,
194 );
195 }
196 if signed.authorization.is_admin() {
198 eyre::bail!(
199 "wallet returned an admin key authorization, expected a limited access key"
200 );
201 }
202 if let Some(account) = signed.authorization.account
204 && account != account_address
205 {
206 eyre::bail!(
207 "wallet authorized account {account} but the authorizing account is {account_address}",
208 );
209 }
210 let chain_id = signed.authorization.chain_id;
211 TempoAccountsStore::default_path()?.upsert_secp256k1_access_key(
212 account_address,
213 &signer,
214 &signed,
215 )?;
216 return Ok(AccessKeyOutcome {
217 wallet_address: account_address,
218 key_address,
219 chain_id,
220 });
221 }
222 }
223 }
224}
225
226fn is_transient_error(err: &reqwest::Error) -> bool {
227 err.is_timeout() || err.is_connect() || err.is_request()
228}
229
230fn is_transient_status(status: reqwest::StatusCode) -> bool {
231 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
232}
233
234async fn create_code_with_retry(
236 client: &reqwest::Client,
237 service: &str,
238 req: &CreateCodeRequest,
239 timeout: Duration,
240) -> Result<String> {
241 let started = Instant::now();
242 let mut backoff = Duration::from_millis(500);
243 loop {
244 let send_res = client.post(format!("{service}/code")).json(req).send().await;
245
246 match send_res {
247 Ok(resp) => {
248 let status = resp.status();
249 if status.is_success() {
250 let CreateCodeResponse { code } = resp.json().await?;
251 return Ok(code);
252 }
253 if is_transient_status(status) && started.elapsed() < timeout {
254 tracing::debug!(%status, "transient HTTP status creating device code, retrying");
255 tokio::time::sleep(backoff).await;
256 backoff = (backoff * 2).min(Duration::from_secs(5));
257 continue;
258 }
259 let body = resp.text().await.unwrap_or_default();
260 eyre::bail!("device-code create failed ({status}): {body}");
261 }
262 Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
263 tracing::debug!(error = %e, "transient error creating device code, retrying");
264 tokio::time::sleep(backoff).await;
265 backoff = (backoff * 2).min(Duration::from_secs(5));
266 }
267 Err(e) => return Err(e.into()),
268 }
269 }
270}
271
272fn random_code_verifier() -> String {
273 let bytes = B256::random();
274 URL_SAFE_NO_PAD.encode(bytes.as_slice())
275}
276
277fn sha256_b64url(input: &str) -> String {
278 let digest = Sha256::digest(input.as_bytes());
279 URL_SAFE_NO_PAD.encode(digest)
280}
281
282#[derive(Serialize)]
283#[serde(rename_all = "camelCase")]
284struct CreateCodeRequest {
285 #[serde(serialize_with = "serialize_u64_hex")]
287 chain_id: u64,
288 code_challenge: String,
289 key_type: &'static str,
290 pub_key: String,
291}
292
293fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
294 s.serialize_str(&format!("0x{v:x}"))
295}
296
297#[derive(Deserialize)]
298struct CreateCodeResponse {
299 code: String,
300}
301
302#[derive(Serialize)]
303#[serde(rename_all = "camelCase")]
304struct PollRequest {
305 code_verifier: String,
306}
307
308#[derive(Deserialize)]
310#[serde(tag = "status", rename_all = "lowercase")]
311enum PollResponse {
312 Pending,
313 Expired,
314 Authorized {
315 #[serde(rename = "accountAddress", alias = "account_address")]
316 account_address: Address,
317 #[serde(rename = "keyAuthorization", alias = "key_authorization", default)]
318 key_authorization: Option<PollKeyAuthorization>,
319 },
320}
321
322#[derive(Deserialize)]
323#[serde(untagged)]
324enum PollKeyAuthorization {
325 Accounts(Box<TempoAccountsKeyAuthorization>),
326 Legacy(String),
327}
328
329impl PollKeyAuthorization {
330 fn into_signed(self) -> Result<SignedKeyAuthorization> {
331 match self {
332 Self::Accounts(authorization) => Ok(authorization.into_signed()),
333 Self::Legacy(encoded) => decode_key_authorization(&encoded),
334 }
335 }
336}
337
338pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
340 url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::tempo::{TEMPO_HOME_ENV, read_tempo_accounts_store, test_env_mutex};
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 = "0x0000000000000000000000000000000000000042".parse().unwrap();
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 format!("0x{}", hex::encode(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 =
534 "0x0000000000000000000000000000000000000042".parse().unwrap();
535 assert_eq!(outcome.chain_id, 4217);
536 assert_eq!(outcome.wallet_address, expected_wallet);
537
538 let file = read_tempo_accounts_store().expect("store.json written");
539 assert_eq!(file.keys.len(), 1);
540 let entry = &file.keys[0];
541 assert_eq!(entry.wallet_address, outcome.wallet_address);
542 assert_eq!(entry.key_address, outcome.key_address);
543 assert_eq!(entry.chain_id, 4217);
544 assert_eq!(entry.expiry, Some(9_999_999_999));
545 let decoded = entry.key_authorization.as_ref().expect("pending authorization");
546 assert_eq!(decoded.authorization.chain_id, 4217);
547
548 server.abort();
549 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
550 }
551
552 #[tokio::test(flavor = "multi_thread")]
553 async fn ensure_access_key_rejects_wrong_chain_id() {
554 let _g = test_env_mutex().lock().await;
558 let tmp = tempfile::tempdir().unwrap();
559 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
560
561 let (service_url, server) = spawn_mock_wallet(99999).await;
562 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
563 assert!(
564 err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
565 "expected chain mismatch error, got: {err}"
566 );
567 assert!(read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()));
568
569 server.abort();
570 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
571 }
572
573 #[tokio::test(flavor = "multi_thread")]
574 async fn ensure_access_key_rejects_admin_authorization() {
575 let _g = test_env_mutex().lock().await;
577 let tmp = tempfile::tempdir().unwrap();
578 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
579
580 let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
583 let shape = MockAuthShape { admin: true, account: Some(account) };
584 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
585
586 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
587 assert!(
588 err.to_string().contains("admin key authorization"),
589 "expected admin-key rejection, got: {err}"
590 );
591 assert!(
592 read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
593 "an admin authorization must not be persisted to store.json"
594 );
595
596 server.abort();
597 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
598 }
599
600 #[tokio::test(flavor = "multi_thread")]
601 async fn ensure_access_key_rejects_cross_account_binding() {
602 let _g = test_env_mutex().lock().await;
605 let tmp = tempfile::tempdir().unwrap();
606 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
607
608 let other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap();
610 let shape = MockAuthShape { admin: false, account: Some(other) };
611 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
612
613 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
614 assert!(
615 err.to_string().contains("wallet authorized account"),
616 "expected cross-account rejection, got: {err}"
617 );
618 assert!(
619 read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
620 "a cross-account authorization must not be persisted to store.json"
621 );
622
623 server.abort();
624 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
625 }
626}