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 if signed.authorization.is_admin() {
203 eyre::bail!(
204 "wallet returned an admin key authorization, expected a limited access key"
205 );
206 }
207 if let Some(account) = signed.authorization.account
209 && account != account_address
210 {
211 eyre::bail!(
212 "wallet authorized account {account} but the authorizing account is {account_address}",
213 );
214 }
215 let chain_id = signed.authorization.chain_id;
216 let key_authorization =
217 if hex_str.starts_with("0x") { hex_str } else { format!("0x{hex_str}") };
218 let entry = KeyEntry {
219 wallet_type: WalletType::Passkey,
220 wallet_address: account_address,
221 chain_id,
222 key_type: match signed.authorization.key_type {
223 SignatureType::P256 => KeyType::P256,
224 SignatureType::WebAuthn => KeyType::WebAuthn,
225 _ => KeyType::Secp256k1,
226 },
227 key_address: Some(key_address),
228 key: Some(format!("0x{}", hex::encode(signer.to_bytes()))),
229 key_authorization: Some(key_authorization),
230 expiry: signed.authorization.expiry.map(|n| n.get()),
231 limits: signed
232 .authorization
233 .limits
234 .unwrap_or_default()
235 .into_iter()
236 .map(|l| StoredTokenLimit { currency: l.token, limit: l.limit.to_string() })
237 .collect(),
238 };
239 upsert_key_entry(entry)?;
240 return Ok(AccessKeyOutcome {
241 wallet_address: account_address,
242 key_address,
243 chain_id,
244 });
245 }
246 }
247 }
248}
249
250fn is_transient_error(err: &reqwest::Error) -> bool {
251 err.is_timeout() || err.is_connect() || err.is_request()
252}
253
254fn is_transient_status(status: reqwest::StatusCode) -> bool {
255 status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
256}
257
258async fn create_code_with_retry(
260 client: &reqwest::Client,
261 service: &str,
262 req: &CreateCodeRequest,
263 timeout: Duration,
264) -> Result<String> {
265 let started = Instant::now();
266 let mut backoff = Duration::from_millis(500);
267 loop {
268 let send_res = client.post(format!("{service}/code")).json(req).send().await;
269
270 match send_res {
271 Ok(resp) => {
272 let status = resp.status();
273 if status.is_success() {
274 let CreateCodeResponse { code } = resp.json().await?;
275 return Ok(code);
276 }
277 if is_transient_status(status) && started.elapsed() < timeout {
278 tracing::debug!(%status, "transient HTTP status creating device code, retrying");
279 tokio::time::sleep(backoff).await;
280 backoff = (backoff * 2).min(Duration::from_secs(5));
281 continue;
282 }
283 let body = resp.text().await.unwrap_or_default();
284 eyre::bail!("device-code create failed ({status}): {body}");
285 }
286 Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
287 tracing::debug!(error = %e, "transient error creating device code, retrying");
288 tokio::time::sleep(backoff).await;
289 backoff = (backoff * 2).min(Duration::from_secs(5));
290 }
291 Err(e) => return Err(e.into()),
292 }
293 }
294}
295
296fn random_code_verifier() -> String {
297 let bytes = B256::random();
298 URL_SAFE_NO_PAD.encode(bytes.as_slice())
299}
300
301fn sha256_b64url(input: &str) -> String {
302 let digest = Sha256::digest(input.as_bytes());
303 URL_SAFE_NO_PAD.encode(digest)
304}
305
306#[derive(Serialize)]
307#[serde(rename_all = "camelCase")]
308struct CreateCodeRequest {
309 #[serde(serialize_with = "serialize_u64_hex")]
311 chain_id: u64,
312 code_challenge: String,
313 key_type: &'static str,
314 pub_key: String,
315}
316
317fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
318 s.serialize_str(&format!("0x{v:x}"))
319}
320
321#[derive(Deserialize)]
322struct CreateCodeResponse {
323 code: String,
324}
325
326#[derive(Serialize)]
327#[serde(rename_all = "camelCase")]
328struct PollRequest {
329 code_verifier: String,
330}
331
332#[derive(Deserialize)]
334#[serde(tag = "status", rename_all = "lowercase")]
335enum PollResponse {
336 Pending,
337 Expired,
338 Authorized {
339 account_address: Address,
340 #[serde(default)]
341 key_authorization: Option<String>,
342 },
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::tempo::{TEMPO_HOME_ENV, read_tempo_keys_file, test_env_mutex};
349 use axum::{Json, Router, extract::State, routing::post};
350 use std::sync::{Arc, Mutex};
351
352 #[test]
353 fn pkce_challenge_matches_sdk_format() {
354 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
356 let challenge = sha256_b64url(verifier);
357 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
358 }
359
360 fn address_from_sec1_hex(s: &str) -> Address {
363 let stripped = s.strip_prefix("0x").unwrap_or(s);
364 let bytes = hex::decode(stripped).expect("valid hex");
365 let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
366 Address::from_public_key(&vk)
367 }
368
369 #[derive(Clone, Copy, Default)]
371 struct MockAuthShape {
372 admin: bool,
374 account: Option<Address>,
376 }
377
378 #[derive(Clone)]
379 struct MockState {
380 wallet: Arc<Mutex<Option<Address>>>,
381 key_id: Arc<Mutex<Option<Address>>>,
384 poll_chain_id: u64,
386 shape: MockAuthShape,
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 Json(serde_json::json!({
447 "status": "authorized",
448 "account_address": wallet,
449 "key_authorization":
450 signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape),
451 }))
452 }
453
454 async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
456 spawn_mock_wallet_with(poll_chain_id, MockAuthShape::default()).await
457 }
458
459 async fn spawn_mock_wallet_with(
461 poll_chain_id: u64,
462 shape: MockAuthShape,
463 ) -> (String, tokio::task::JoinHandle<()>) {
464 let app = Router::new()
465 .route("/code", post(create_code_handler))
466 .route("/poll/{code}", post(poll_handler))
467 .with_state(MockState {
468 wallet: Arc::default(),
469 key_id: Arc::default(),
470 poll_chain_id,
471 shape,
472 });
473
474 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
475 let addr = listener.local_addr().unwrap();
476 let handle = tokio::spawn(async move {
477 axum::serve(listener, app).await.unwrap();
478 });
479 (format!("http://{addr}"), handle)
480 }
481
482 fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
483 EnsureAccessKeyConfig {
484 chain_id: 4217,
485 service_url,
486 poll_interval: Duration::from_millis(10),
487 timeout: Duration::from_secs(2),
488 no_browser: true,
489 }
490 }
491
492 #[tokio::test(flavor = "multi_thread")]
493 async fn ensure_access_key_happy_path_writes_keys_toml() {
494 let _g = test_env_mutex().lock().await;
496 let tmp = tempfile::tempdir().unwrap();
497 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
498
499 let (service_url, server) = spawn_mock_wallet(4217).await;
500 let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
501
502 let expected_wallet: Address =
503 "0x0000000000000000000000000000000000000042".parse().unwrap();
504 assert_eq!(outcome.chain_id, 4217);
505 assert_eq!(outcome.wallet_address, expected_wallet);
506
507 let file = read_tempo_keys_file().expect("keys.toml written");
508 assert_eq!(file.keys.len(), 1);
509 let entry = &file.keys[0];
510 assert_eq!(entry.wallet_address, outcome.wallet_address);
511 assert_eq!(entry.key_address, Some(outcome.key_address));
512 assert_eq!(entry.chain_id, 4217);
513 assert_eq!(entry.expiry, Some(9_999_999_999));
514 let decoded: tempo_primitives::transaction::SignedKeyAuthorization =
515 crate::tempo::decode_key_authorization(entry.key_authorization.as_deref().unwrap())
516 .expect("RLP roundtrip");
517 assert_eq!(decoded.authorization.chain_id, 4217);
518
519 server.abort();
520 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
521 }
522
523 #[tokio::test(flavor = "multi_thread")]
524 async fn ensure_access_key_rejects_wrong_chain_id() {
525 let _g = test_env_mutex().lock().await;
529 let tmp = tempfile::tempdir().unwrap();
530 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
531
532 let (service_url, server) = spawn_mock_wallet(99999).await;
533 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
534 assert!(
535 err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
536 "expected chain mismatch error, got: {err}"
537 );
538 assert!(read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()));
539
540 server.abort();
541 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
542 }
543
544 #[tokio::test(flavor = "multi_thread")]
545 async fn ensure_access_key_rejects_admin_authorization() {
546 let _g = test_env_mutex().lock().await;
548 let tmp = tempfile::tempdir().unwrap();
549 unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
550
551 let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
554 let shape = MockAuthShape { admin: true, account: Some(account) };
555 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
556
557 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
558 assert!(
559 err.to_string().contains("admin key authorization"),
560 "expected admin-key rejection, got: {err}"
561 );
562 assert!(
563 read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()),
564 "an admin authorization must not be persisted to keys.toml"
565 );
566
567 server.abort();
568 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
569 }
570
571 #[tokio::test(flavor = "multi_thread")]
572 async fn ensure_access_key_rejects_cross_account_binding() {
573 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 other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap();
581 let shape = MockAuthShape { admin: false, account: Some(other) };
582 let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
583
584 let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
585 assert!(
586 err.to_string().contains("wallet authorized account"),
587 "expected cross-account rejection, got: {err}"
588 );
589 assert!(
590 read_tempo_keys_file().is_none_or(|f| f.keys.is_empty()),
591 "a cross-account authorization must not be persisted to keys.toml"
592 );
593
594 server.abort();
595 unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
596 }
597}