newton_cli/commands/
newton_dashboard.rs

1//! Newton Dashboard management module for authentication and policy secret management
2//!
3//! This module provides functionality to:
4//! - Authenticate with Newton Dashboard via browser-based email + OTP login
5//! - Store bearer tokens for subsequent API calls
6//! - Encrypt secrets using RSA-OAEP with SHA-256 (compatible with AWS KMS)
7//! - Upload encrypted secrets to the Newton Dashboard policy management API
8//!
9//! # Authentication Flow
10//!
11//! ## Step 1: Login to Dashboard
12//!
13//! First, authenticate with the Newton Dashboard to obtain and store a bearer token:
14//!
15//! ```bash
16//! cargo run --bin newton-cli -- --chain-id 11155111 newton-dashboard login \
17//!     --dashboard-url http://localhost:3000 \
18//!     --api-base-url http://localhost:8000 \
19//!     --poll-interval 3 \
20//!     --max-poll-time 300
21//! ```
22//!
23//! This command will:
24//! 1. Generate a unique session ID
25//! 2. Open your browser to the dashboard login page at `{dashboard-url}/cli-login?session_id={id}`
26//! 3. Wait for you to complete email + OTP authentication in the browser
27//! 4. Poll the backend API endpoint `/v1/authn/cli-token/{session_id}` every 3 seconds
28//! 5. Once authenticated, save the bearer token to `~/.newton/newton-cli.toml`
29//!
30//! The bearer token will be automatically loaded for subsequent commands.
31//!
32//! ## Step 2: Upload Policy Secrets
33//!
34//! After logging in, you can upload encrypted secrets without providing the bearer token:
35//!
36//! ```bash
37//! # Bearer token loaded automatically from ~/.newton/newton-cli.toml
38//! cargo run --bin newton-cli -- --chain-id 11155111 newton-dashboard upload-policy-and-secret \
39//!     --policy-name my-policy \
40//!     --policy-address 0x1234567890abcdef1234567890abcdef12345678 \
41//!     --policy-data-address 0xabcdef1234567890abcdef1234567890abcdef12 \
42//!     --secret-key my-secret-key \
43//!     --secret-value "my-secret-value"
44//! ```
45//!
46//! Or provide the bearer token explicitly:
47//!
48//! ```bash
49//! cargo run --bin newton-cli -- --chain-id 11155111 newton-dashboard upload-policy-and-secret \
50//!     --api-base-url http://localhost:8000 \
51//!     --policy-name my-policy \
52//!     --policy-address 0x1234567890abcdef1234567890abcdef12345678 \
53//!     --policy-data-address 0xabcdef1234567890abcdef1234567890abcdef12 \
54//!     --secret-key my-secret-key \
55//!     --secret-value "my-secret-value" \
56//!     --bearer-token "your-bearer-token" \
57//!     --skip-tls-verify
58//! ```
59//!
60//! # Notes
61//!
62//! - The public key for secret encryption is hardcoded in the `KMS_PUBLIC_KEY_PEM` constant.
63//!   Update it with your actual KMS public key before use.
64//! - The bearer token and API base URL are stored in `~/.newton/newton-cli.toml`
65//! - Use `--skip-tls-verify` flag if connecting to localhost with self-signed certificates.
66//! - The `--bearer-token` and `--api-base-url` flags are optional if already set via login.
67
68use base64::{engine::general_purpose, Engine as _};
69use clap::{Parser, Subcommand};
70use eyre::{Context, Result};
71use newton_prover_core::config::NewtonAvsConfig;
72use pkcs8::DecodePublicKey;
73use rsa::{rand_core::OsRng, Oaep, RsaPublicKey};
74use serde::{Deserialize, Serialize};
75use sha2::Sha256;
76use tracing::{self, info};
77
78use crate::config::NewtonCliConfig;
79
80/// Hardcoded KMS public key for encryption
81/// Replace this with your actual KMS public key
82const KMS_PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
83MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5aOjaOmoIiITeXSIe5Yt
849vmugRSahtf+MyMXEKB/kY96/UFIKCPGHXO7XF7RsccTgCYSi6JgTH2DiD1saazd
85mJaHWMnz/UDY7/omFnW6RxGmGQxxXVI2bHgUhvf50JEculJvuUiljBXTUnofrfJI
86LhZ2bAls3BBDDCxW8OFeJ3zQhJdOL9JP4AaYyiXlZZ1xpmDB3IEW+s9ma4524lqg
87j/dmSVk40IB2K1EhaPj/zKs37nH2uOnLBkE2OWzQoEAVTdd6B1TLcFiGFrR9d4Xk
889RqHifqyaIlDWjUZ1lO9GX++2x6YIq9q+jDzidOzi43QLfBKjHELmM6xIXQ+Vwqa
89WwIDAQAB
90-----END PUBLIC KEY-----"#;
91
92/// Encrypt data using RSA-OAEP with SHA-256
93///
94/// This function encrypts the provided data using RSA-OAEP padding with SHA-256,
95/// which is compatible with AWS KMS RSA_2048 ENCRYPT_DECRYPT keys.
96///
97/// # Arguments
98/// * `data` - The data to encrypt (as bytes)
99/// * `public_key_pem` - The RSA public key in PEM format
100///
101/// # Returns
102/// Base64-encoded ciphertext
103pub fn encrypt_data(data: &[u8], public_key_pem: &str) -> Result<String> {
104    // Parse the RSA public key from PEM (supports both PKCS#1 and PKCS#8 formats)
105    let public_key =
106        RsaPublicKey::from_public_key_pem(public_key_pem).context("Failed to parse RSA public key from PEM")?;
107
108    // Encrypt using RSAES-OAEP with SHA-256
109    let mut rng = OsRng;
110    let padding = Oaep::new::<Sha256>();
111    let ciphertext = public_key
112        .encrypt(&mut rng, padding, data)
113        .context("Failed to encrypt data with RSA-OAEP")?;
114
115    // Encode to base64 for easy transport
116    let b64 = general_purpose::STANDARD.encode(ciphertext);
117    Ok(b64)
118}
119
120/// Policy creation response from the API
121#[derive(Debug, Serialize, Deserialize)]
122pub struct PolicyResponse {
123    pub id: String,
124}
125
126/// Secret upload request
127#[derive(Debug, Serialize, Deserialize)]
128pub struct SecretRequest {
129    pub key: String,
130    pub value: String,
131}
132
133/// CLI token response from polling API
134#[derive(Debug, Serialize, Deserialize)]
135pub struct CliTokenResponse {
136    pub token: Option<String>,
137    pub status: String, // "pending", "completed", "expired"
138}
139
140/// Create a policy data via API
141///
142/// # Arguments
143/// * `api_base_url` - Base URL of the API (e.g., "http://localhost:8000")
144/// * `policy_name` - Name of the policy to create
145/// * `policy_data_address` - Address of the policy data
146/// * `bearer_token` - Bearer token for API authentication
147/// * `verify_tls` - Whether to verify TLS certificates (set to false for localhost with self-signed certs)
148///
149/// # Returns
150/// The policy ID
151pub async fn create_policy_data(
152    api_base_url: &str,
153    policy_name: &str,
154    policy_data_address: &str,
155    bearer_token: &str,
156    verify_tls: bool,
157) -> Result<String> {
158    let url = format!("{}/v1/policy-data", api_base_url.trim_end_matches('/'));
159
160    let client = if verify_tls {
161        reqwest::Client::new()
162    } else {
163        reqwest::Client::builder()
164            .danger_accept_invalid_certs(true)
165            .build()
166            .context("Failed to create HTTP client")?
167    };
168
169    let response = client
170        .post(&url)
171        .header("Authorization", format!("Bearer {}", bearer_token))
172        .json(&serde_json::json!({
173            "name": policy_name,
174            "address": policy_data_address
175        }))
176        .send()
177        .await
178        .with_context(|| format!("Failed to send request to {}", url))?;
179
180    let status = response.status();
181    if !status.is_success() {
182        let error_text = response.text().await.unwrap_or_default();
183        return Err(eyre::eyre!("API request failed with status {}: {}", status, error_text));
184    }
185
186    let policy_response: PolicyResponse = response.json().await.context("Failed to parse policy response")?;
187
188    Ok(policy_response.id)
189}
190
191/// Create a policy call via API
192///
193/// # Arguments
194/// * `api_base_url` - Base URL of the API (e.g., "http://localhost:8000")
195/// * `policy_name` - Name of the policy to create
196/// * `policy_address` - Address of the policy
197/// * `bearer_token` - Bearer token for API authentication
198/// * `verify_tls` - Whether to verify TLS certificates (set to false for localhost with self-signed certs)
199///
200/// # Returns
201/// The policy ID
202pub async fn create_policy_call(
203    api_base_url: &str,
204    policy_name: &str,
205    policy_address: &str,
206    bearer_token: &str,
207    verify_tls: bool,
208) -> Result<String> {
209    let url = format!("{}/v1/policy", api_base_url.trim_end_matches('/'));
210
211    let client = if verify_tls {
212        reqwest::Client::new()
213    } else {
214        reqwest::Client::builder()
215            .danger_accept_invalid_certs(true)
216            .build()
217            .context("Failed to create HTTP client")?
218    };
219
220    let response = client
221        .post(&url)
222        .header("Authorization", format!("Bearer {}", bearer_token))
223        .json(&serde_json::json!({
224            "name": policy_name,
225            "address": policy_address
226        }))
227        .send()
228        .await
229        .with_context(|| format!("Failed to send request to {}", url))?;
230
231    let status = response.status();
232    if !status.is_success() {
233        let error_text = response.text().await.unwrap_or_default();
234        return Err(eyre::eyre!("API request failed with status {}: {}", status, error_text));
235    }
236
237    let policy_response: PolicyResponse = response.json().await.context("Failed to parse policy response")?;
238
239    Ok(policy_response.id)
240}
241
242/// Upload a secret to a policy data via API
243///
244/// # Arguments
245/// * `api_base_url` - Base URL of the API (e.g., "http://localhost:8000")
246/// * `policy_data_id` - The policy data ID to upload the secret to
247/// * `key` - The secret key
248/// * `encrypted_value` - The encrypted secret value (base64-encoded)
249/// * `bearer_token` - Bearer token for API authentication
250/// * `verify_tls` - Whether to verify TLS certificates (set to false for localhost with self-signed certs)
251///
252/// # Returns
253/// Success result
254pub async fn upload_policy_data_secret(
255    api_base_url: &str,
256    policy_data_id: &str,
257    key: &str,
258    encrypted_value: &str,
259    bearer_token: &str,
260    verify_tls: bool,
261) -> Result<()> {
262    let url = format!(
263        "{}/v1/policy-data/{}/secret",
264        api_base_url.trim_end_matches('/'),
265        policy_data_id
266    );
267
268    let client = if verify_tls {
269        reqwest::Client::new()
270    } else {
271        reqwest::Client::builder()
272            .danger_accept_invalid_certs(true)
273            .build()
274            .context("Failed to create HTTP client")?
275    };
276
277    let secret_request = SecretRequest {
278        key: key.to_string(),
279        value: encrypted_value.to_string(),
280    };
281
282    let response = client
283        .post(&url)
284        .header("Authorization", format!("Bearer {}", bearer_token))
285        .json(&secret_request)
286        .send()
287        .await
288        .with_context(|| format!("Failed to send request to {}", url))?;
289
290    let status = response.status();
291    if !status.is_success() {
292        let error_text = response.text().await.unwrap_or_default();
293        return Err(eyre::eyre!("API request failed with status {}: {}", status, error_text));
294    }
295
296    Ok(())
297}
298
299/// Newton Dashboard commands
300#[derive(Debug, Parser)]
301#[command(name = "newton-dashboard")]
302pub struct NewtonDashboardCommand {
303    #[command(subcommand)]
304    pub subcommand: NewtonDashboardSubcommand,
305}
306
307#[derive(Debug, Subcommand)]
308pub enum NewtonDashboardSubcommand {
309    /// Login to Newton Dashboard and store bearer token
310    Login(LoginCommand),
311    /// Upload policy and secret to Newton Dashboard
312    #[command(name = "upload-policy-and-secret")]
313    UploadPolicyAndSecret(UploadPolicyAndSecretCommand),
314}
315
316/// Login command
317#[derive(Debug, Parser)]
318pub struct LoginCommand {
319    /// Dashboard URL (e.g., "http://localhost:3000")
320    #[arg(long, default_value = "http://localhost:3000")]
321    dashboard_url: String,
322
323    /// API base URL (e.g., "http://localhost:8000")
324    #[arg(long, default_value = "http://localhost:8000")]
325    api_base_url: String,
326
327    /// Polling interval in seconds
328    #[arg(long, default_value = "3")]
329    poll_interval: u64,
330
331    /// Maximum polling time in seconds
332    #[arg(long, default_value = "300")]
333    max_poll_time: u64,
334}
335
336/// Upload policy and secret command
337#[derive(Debug, Parser)]
338pub struct UploadPolicyAndSecretCommand {
339    /// API base URL (e.g., "http://localhost:8000")
340    #[arg(long)]
341    api_base_url: Option<String>,
342
343    /// Policy name to create
344    #[arg(long)]
345    policy_name: String,
346
347    /// Policy address
348    #[arg(long)]
349    policy_address: String,
350
351    /// Policy data address
352    #[arg(long)]
353    policy_data_address: String,
354
355    /// Secret key name
356    #[arg(long)]
357    secret_key: String,
358
359    /// Secret value to encrypt and upload
360    #[arg(long)]
361    secret_value: String,
362
363    /// Bearer token for API authentication (optional if already logged in)
364    #[arg(long)]
365    bearer_token: Option<String>,
366
367    /// Skip TLS certificate verification (useful for localhost with self-signed certs)
368    #[arg(long, default_value = "false")]
369    skip_tls_verify: bool,
370}
371
372/// Poll for CLI token from the backend API
373async fn poll_for_token(
374    api_base_url: &str,
375    session_id: &str,
376    poll_interval: u64,
377    max_poll_time: u64,
378) -> eyre::Result<String> {
379    let url = format!(
380        "{}/v1/authn/cli-token/{}",
381        api_base_url.trim_end_matches('/'),
382        session_id
383    );
384
385    let client = reqwest::Client::new();
386    let start_time = std::time::Instant::now();
387    let max_duration = std::time::Duration::from_secs(max_poll_time);
388    let poll_interval_duration = std::time::Duration::from_secs(poll_interval);
389
390    info!("Polling for token (timeout: {}s)...", max_poll_time);
391
392    loop {
393        if start_time.elapsed() > max_duration {
394            return Err(eyre::eyre!(
395                "Login timeout: no token received within {} seconds. Please try again.",
396                max_poll_time
397            ));
398        }
399
400        let response = client.get(&url).send().await;
401
402        match response {
403            Ok(resp) if resp.status().is_success() => {
404                match resp.json::<CliTokenResponse>().await {
405                    Ok(token_response) => {
406                        match token_response.status.as_str() {
407                            "completed" => {
408                                if let Some(token) = token_response.token {
409                                    return Ok(token);
410                                } else {
411                                    return Err(eyre::eyre!("Token marked as completed but no token provided"));
412                                }
413                            }
414                            "expired" => {
415                                return Err(eyre::eyre!("Login session expired. Please try again."));
416                            }
417                            "pending" => {
418                                // Continue polling
419                                info!("Waiting for login... ({:.0}s elapsed)", start_time.elapsed().as_secs());
420                            }
421                            status => {
422                                tracing::warn!("Unknown status: {}", status);
423                            }
424                        }
425                    }
426                    Err(e) => {
427                        tracing::warn!("Failed to parse token response: {}", e);
428                    }
429                }
430            }
431            Ok(resp) => {
432                tracing::warn!("Polling failed with status: {}", resp.status());
433            }
434            Err(e) => {
435                tracing::warn!("Polling request failed: {}", e);
436            }
437        }
438
439        tokio::time::sleep(poll_interval_duration).await;
440    }
441}
442
443/// Save the configuration to a TOML file
444fn save_config(config: &NewtonCliConfig, config_path: &std::path::Path) -> eyre::Result<()> {
445    let toml_string = toml::to_string_pretty(config).context("Failed to serialize config to TOML")?;
446
447    // Create parent directories if they don't exist
448    if let Some(parent) = config_path.parent() {
449        std::fs::create_dir_all(parent).with_context(|| format!("Failed to create config directory: {:?}", parent))?;
450    }
451
452    std::fs::write(config_path, toml_string)
453        .with_context(|| format!("Failed to write config file: {:?}", config_path))?;
454
455    Ok(())
456}
457
458impl NewtonDashboardCommand {
459    /// Execute the newton dashboard command
460    pub async fn execute(self: Box<Self>, mut config: NewtonAvsConfig<NewtonCliConfig>) -> eyre::Result<()> {
461        match self.subcommand {
462            NewtonDashboardSubcommand::Login(cmd) => {
463                info!("Starting login flow for Newton Dashboard...");
464
465                // Generate a session ID
466                let session_id = uuid::Uuid::new_v4().to_string();
467
468                // Construct the dashboard URL with session ID
469                let dashboard_login_url = format!(
470                    "{}/cli-login?session_id={}",
471                    cmd.dashboard_url.trim_end_matches('/'),
472                    session_id
473                );
474
475                info!("Opening browser to: {}", dashboard_login_url);
476                info!("Please log in via the browser using your email and OTP...");
477
478                // Open browser
479                if let Err(e) = open::that(&dashboard_login_url) {
480                    tracing::warn!("Failed to open browser automatically: {}", e);
481                    info!("Please manually open this URL in your browser:");
482                    info!("{}", dashboard_login_url);
483                }
484
485                // Poll for token
486                let token =
487                    poll_for_token(&cmd.api_base_url, &session_id, cmd.poll_interval, cmd.max_poll_time).await?;
488
489                info!("Successfully received bearer token!");
490
491                // Update config
492                config.service.set_dashboard_bearer_token(Some(token));
493                config
494                    .service
495                    .set_dashboard_api_base_url(Some(cmd.api_base_url.clone()));
496
497                // Save config to default location
498                let config_path = {
499                    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
500                    std::path::PathBuf::from(home).join(".newton").join("newton-cli.toml")
501                };
502
503                save_config(&config.service, &config_path)?;
504
505                info!("Bearer token saved to config file: {:?}", config_path);
506                info!("You can now use the 'upload-policy-and-secret' command without providing a bearer token.");
507
508                Ok(())
509            }
510            NewtonDashboardSubcommand::UploadPolicyAndSecret(cmd) => {
511                info!("Uploading policy and secret to Newton Dashboard...");
512
513                // Get bearer token: from command line arg, or from config, or fail
514                let bearer_token = cmd
515                    .bearer_token
516                    .or_else(|| config.service.dashboard_bearer_token.clone())
517                    .ok_or_else(|| {
518                        eyre::eyre!(
519                        "Bearer token not found. Please provide --bearer-token or run 'newton-dashboard login' first."
520                    )
521                    })?;
522
523                // Get API base URL: from command line arg, or from config, or use default
524                let api_base_url = cmd
525                    .api_base_url
526                    .or_else(|| config.service.dashboard_api_base_url.clone())
527                    .unwrap_or_else(|| "http://localhost:8000".to_string());
528
529                // Encrypt the secret value using hardcoded public key
530                info!("Encrypting secret value");
531                let encrypted_value = encrypt_data(cmd.secret_value.as_bytes(), KMS_PUBLIC_KEY_PEM)
532                    .with_context(|| "Failed to encrypt secret data")?;
533
534                info!("Secret encrypted successfully");
535
536                // Create policy call
537                info!("Creating policy call: {}", cmd.policy_name);
538                let policy_id = create_policy_call(
539                    &api_base_url,
540                    &cmd.policy_name,
541                    &cmd.policy_address,
542                    &bearer_token,
543                    !cmd.skip_tls_verify,
544                )
545                .await
546                .with_context(|| "Failed to create policy call")?;
547
548                info!("Policy call created with ID: {}", policy_id);
549
550                // Create policy data
551                info!("Creating policy data: {}", cmd.policy_name);
552                let policy_data_id = create_policy_data(
553                    &api_base_url,
554                    &cmd.policy_name,
555                    &cmd.policy_data_address,
556                    &bearer_token,
557                    !cmd.skip_tls_verify,
558                )
559                .await
560                .with_context(|| "Failed to create policy data")?;
561
562                info!("Policy data created with ID: {}", policy_data_id);
563
564                // Upload secret
565                info!("Uploading secret with key: {}", cmd.secret_key);
566                upload_policy_data_secret(
567                    &api_base_url,
568                    &policy_data_id,
569                    &cmd.secret_key,
570                    &encrypted_value,
571                    &bearer_token,
572                    !cmd.skip_tls_verify,
573                )
574                .await
575                .with_context(|| "Failed to upload secret")?;
576
577                info!("Secret uploaded successfully!");
578                info!("Policy ID: {}", policy_id);
579                info!("Policy data ID: {}", policy_data_id);
580                info!("Secret Key: {}", cmd.secret_key);
581
582                Ok(())
583            }
584        }
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    // Test RSA public key (2048-bit) - this is a test key, not for production use
593    const TEST_PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
594MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy8Dbv8prpJ/0kKhlGeJY
595ozo2t60EG8L0561g13R29oVDmMytRDtpYRZxlYQbzhhJTV1bYgHyUwYj90fd6y0m
596UHHJ9ZVKyj6HCdbO0nbt9Z4Khd9m5niJ2STgGUF0Q4Kq0lz4TgFYq9czvHBNc2Hq
597dxFyPtkMDVDN7+auviBN+iHXj3pVKcQYBSyEbUGq+74Ceb5pdNCjBiWQrt40etnx
598yU4aiUxE8SMBcgZnutK5UTjOSawafuqfBc9wfYTxEXk8qZ9s2qHI0wMDQN/POIoc
599l9X5BWFX3ONjT9hCsBCdG7hm2XxKpnUbEUVyHNbVyHwIdJTCHNbRWwq7wym2MxYv
600VwIDAQAB
601-----END PUBLIC KEY-----"#;
602
603    #[test]
604    fn test_encrypt_data() {
605        let data = b"test data";
606        let result = encrypt_data(data, TEST_PUBLIC_KEY_PEM);
607        assert!(result.is_ok());
608        let encrypted = result.unwrap();
609        // Base64 encoded ciphertext should be non-empty
610        assert!(!encrypted.is_empty());
611        // Base64 should be valid
612        assert!(general_purpose::STANDARD.decode(&encrypted).is_ok());
613    }
614
615    #[test]
616    fn test_encrypt_data_empty() {
617        let data = b"";
618        let result = encrypt_data(data, TEST_PUBLIC_KEY_PEM);
619        assert!(result.is_ok());
620    }
621
622    #[test]
623    fn test_encrypt_data_large() {
624        // RSA-2048 with OAEP-SHA256 can encrypt up to ~190 bytes
625        // Use 150 bytes to be safe
626        let data = "x".repeat(150).into_bytes();
627        let result = encrypt_data(&data, TEST_PUBLIC_KEY_PEM);
628        assert!(result.is_ok());
629    }
630
631    #[test]
632    fn test_encrypt_data_invalid_key() {
633        let data = b"test data";
634        let invalid_key = "invalid key";
635        let result = encrypt_data(data, invalid_key);
636        assert!(result.is_err());
637    }
638
639    #[test]
640    fn test_secret_request_serialization() {
641        let request = SecretRequest {
642            key: "test_key".to_string(),
643            value: "encrypted_value".to_string(),
644        };
645        let json = serde_json::to_string(&request).unwrap();
646        assert!(json.contains("test_key"));
647        assert!(json.contains("encrypted_value"));
648    }
649
650    #[test]
651    fn test_policy_response_deserialization() {
652        let json = r#"{"id": "policy-123"}"#;
653        let response: PolicyResponse = serde_json::from_str(json).unwrap();
654        assert_eq!(response.id, "policy-123");
655    }
656}