Skip to main content

outlayer_cli/commands/
secrets.rs

1use anyhow::{Context, Result};
2use serde_json::{json, Value};
3
4use crate::api::{ApiClient, GetPubkeyRequest};
5use crate::config::{self, NetworkConfig, ProjectConfig};
6use crate::crypto;
7use crate::near::{ContractCaller, NearClient};
8
9// ── Accessor Resolution ──────────────────────────────────────────────
10
11struct ResolvedAccessor {
12    /// Internally tagged ({"type":"Project", ...}) — for coordinator API
13    coordinator: Value,
14    /// Externally tagged ({"Project": {...}}) — for contract
15    contract: Value,
16}
17
18fn resolve_accessor(
19    project: Option<String>,
20    repo: Option<String>,
21    branch: Option<String>,
22    wasm_hash: Option<String>,
23    project_config: Option<&ProjectConfig>,
24) -> Result<ResolvedAccessor> {
25    if let Some(hash) = wasm_hash {
26        return Ok(ResolvedAccessor {
27            coordinator: json!({"type": "WasmHash", "hash": hash}),
28            contract: json!({"WasmHash": {"hash": hash}}),
29        });
30    }
31
32    if let Some(repo) = repo {
33        return Ok(ResolvedAccessor {
34            coordinator: json!({"type": "Repo", "repo": repo, "branch": branch}),
35            contract: json!({"Repo": {"repo": repo, "branch": branch}}),
36        });
37    }
38
39    if let Some(project_id) = project {
40        return Ok(ResolvedAccessor {
41            coordinator: json!({"type": "Project", "project_id": project_id}),
42            contract: json!({"Project": {"project_id": project_id}}),
43        });
44    }
45
46    // Fallback to outlayer.toml
47    let config = project_config.context(
48        "No accessor specified. Use --project, --repo, or --wasm-hash \
49         (or run from a directory with outlayer.toml)",
50    )?;
51    let project_id = format!("{}/{}", config.project.owner, config.project.name);
52    Ok(ResolvedAccessor {
53        coordinator: json!({"type": "Project", "project_id": project_id}),
54        contract: json!({"Project": {"project_id": project_id}}),
55    })
56}
57
58// ── Access Control Parsing ───────────────────────────────────────────
59
60fn parse_access(access_str: &str) -> Result<Value> {
61    match access_str {
62        "allow-all" | "AllowAll" => Ok(json!("AllowAll")),
63        s if s.starts_with("whitelist:") => {
64            let accounts: Vec<&str> = s["whitelist:".len()..].split(',').collect();
65            if accounts.is_empty() || accounts.iter().any(|a| a.is_empty()) {
66                anyhow::bail!(
67                    "Whitelist requires at least one account. \
68                     Use: --access whitelist:alice.near,bob.near"
69                );
70            }
71            Ok(json!({ "Whitelist": accounts }))
72        }
73        other => anyhow::bail!(
74            "Unknown access type: '{other}'. Use: allow-all, whitelist:acc1,acc2"
75        ),
76    }
77}
78
79// ── Generate Spec Parsing ────────────────────────────────────────────
80
81struct GenerateSpec {
82    name: String,
83    generation_type: String,
84}
85
86fn parse_generate_specs(generate: Vec<String>) -> Result<Vec<GenerateSpec>> {
87    let mut specs = Vec::new();
88    for g in generate {
89        let (name, gen_type) = g.split_once(':').with_context(|| {
90            format!(
91                "Invalid --generate format: '{g}'. \
92                 Use PROTECTED_NAME:type (e.g. PROTECTED_KEY:hex32)"
93            )
94        })?;
95        if !name.starts_with("PROTECTED_") {
96            anyhow::bail!(
97                "Generated secret names must start with PROTECTED_. Got: '{name}'"
98            );
99        }
100        specs.push(GenerateSpec {
101            name: name.to_string(),
102            generation_type: gen_type.to_string(),
103        });
104    }
105    Ok(specs)
106}
107
108// ── Parse JSON secrets ───────────────────────────────────────────────
109
110fn parse_secrets_json(json_str: &str) -> Result<serde_json::Map<String, Value>> {
111    let val: Value =
112        serde_json::from_str(json_str).context("Invalid JSON. Use: '{\"KEY\":\"value\"}'")?;
113    let map = val
114        .as_object()
115        .context("Secrets must be a JSON object: '{\"KEY\":\"value\"}'")?
116        .clone();
117    if map.is_empty() {
118        anyhow::bail!("Empty secrets object");
119    }
120    Ok(map)
121}
122
123// ── Set ──────────────────────────────────────────────────────────────
124
125/// `outlayer secrets set '{"KEY":"val"}' [--generate PROTECTED_X:type] [--access ...]`
126#[allow(clippy::too_many_arguments)]
127pub async fn set(
128    network: &NetworkConfig,
129    project_config: Option<&ProjectConfig>,
130    secrets_json: Option<String>,
131    profile: &str,
132    project: Option<String>,
133    repo: Option<String>,
134    branch: Option<String>,
135    wasm_hash: Option<String>,
136    generate: Vec<String>,
137    access_str: &str,
138) -> Result<()> {
139    let creds = config::load_credentials(network)?;
140
141    let accessor = resolve_accessor(project, repo, branch, wasm_hash, project_config)?;
142    let access = parse_access(access_str)?;
143    let generate_specs = parse_generate_specs(generate)?;
144
145    let secrets_map = match &secrets_json {
146        Some(s) => Some(parse_secrets_json(s)?),
147        None => None,
148    };
149
150    if secrets_map.is_none() && generate_specs.is_empty() {
151        anyhow::bail!("Provide secrets JSON and/or --generate flags");
152    }
153
154    let api = ApiClient::new(network);
155
156    let encrypted_data = if generate_specs.is_empty() {
157        // Simple flow: encrypt manually, no TEE generation
158        let secrets_str = Value::Object(secrets_map.clone().unwrap()).to_string();
159
160        eprintln!("Encrypting secrets...");
161        let pubkey = api
162            .get_secrets_pubkey(&GetPubkeyRequest {
163                accessor: accessor.coordinator.clone(),
164                owner: creds.account_id.clone(),
165                profile: Some(profile.to_string()),
166                secrets_json: secrets_str.clone(),
167            })
168            .await
169            .context("Failed to get keystore pubkey")?;
170
171        crypto::encrypt_secrets(&pubkey, &secrets_str)?
172    } else {
173        // Generate flow: call add_generated_secret (TEE merges manual + generated)
174        let encrypted_base64 = if let Some(map) = &secrets_map {
175            let secrets_str = Value::Object(map.clone()).to_string();
176
177            eprintln!("Encrypting manual secrets...");
178            let pubkey = api
179                .get_secrets_pubkey(&GetPubkeyRequest {
180                    accessor: accessor.coordinator.clone(),
181                    owner: creds.account_id.clone(),
182                    profile: Some(profile.to_string()),
183                    secrets_json: secrets_str.clone(),
184                })
185                .await?;
186
187            Some(crypto::encrypt_secrets(&pubkey, &secrets_str)?)
188        } else {
189            None
190        };
191
192        eprintln!("Generating protected secrets in TEE...");
193        let new_secrets: Vec<Value> = generate_specs
194            .iter()
195            .map(|s| json!({"name": s.name, "generation_type": s.generation_type}))
196            .collect();
197
198        let response = api
199            .add_generated_secret(&json!({
200                "accessor": accessor.coordinator,
201                "owner": creds.account_id,
202                "profile": profile,
203                "encrypted_secrets_base64": encrypted_base64,
204                "new_secrets": new_secrets,
205            }))
206            .await
207            .context("Failed to generate protected secrets")?;
208
209        response.encrypted_data_base64
210    };
211
212    // Store on contract
213    let caller = ContractCaller::from_credentials(&creds, network)?;
214    let deposit = 100_000_000_000_000_000_000_000u128; // 0.1 NEAR
215    let gas = 50_000_000_000_000u64; // 50 TGas
216
217    caller
218        .call_contract(
219            "store_secrets",
220            json!({
221                "accessor": accessor.contract,
222                "profile": profile,
223                "encrypted_secrets_base64": encrypted_data,
224                "access": access,
225            }),
226            gas,
227            deposit,
228        )
229        .await
230        .context("Failed to store secrets")?;
231
232    // Summary
233    let mut parts = Vec::new();
234    if let Some(map) = &secrets_map {
235        let mut keys: Vec<&String> = map.keys().collect();
236        keys.sort();
237        parts.push(format!("keys: {}", keys.iter().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")));
238    }
239    if !generate_specs.is_empty() {
240        let names: Vec<&str> = generate_specs.iter().map(|s| s.name.as_str()).collect();
241        parts.push(format!("protected (TEE): {}", names.join(", ")));
242    }
243    eprintln!("Secrets stored (profile: {profile}, {})", parts.join("; "));
244
245    Ok(())
246}
247
248// ── Update ───────────────────────────────────────────────────────────
249
250/// `outlayer secrets update '{"KEY":"val"}' [--generate PROTECTED_X:type]`
251///
252/// Merges with existing secrets, preserving all PROTECTED_* variables.
253/// Uses NEP-413 signature for authentication.
254#[allow(clippy::too_many_arguments)]
255pub async fn update(
256    network: &NetworkConfig,
257    project_config: Option<&ProjectConfig>,
258    secrets_json: Option<String>,
259    profile: &str,
260    project: Option<String>,
261    repo: Option<String>,
262    branch: Option<String>,
263    wasm_hash: Option<String>,
264    generate: Vec<String>,
265) -> Result<()> {
266    let creds = config::load_credentials(network)?;
267
268    let accessor = resolve_accessor(project, repo, branch, wasm_hash, project_config)?;
269    let generate_specs = parse_generate_specs(generate)?;
270
271    let secrets_map = match &secrets_json {
272        Some(s) => Some(parse_secrets_json(s)?),
273        None => None,
274    };
275
276    if secrets_map.is_none() && generate_specs.is_empty() {
277        anyhow::bail!("Provide secrets JSON and/or --generate flags");
278    }
279
280    // Build sorted key lists for NEP-413 message
281    let mut sorted_keys: Vec<String> = secrets_map
282        .as_ref()
283        .map(|m| m.keys().cloned().collect())
284        .unwrap_or_default();
285    sorted_keys.sort();
286
287    let mut sorted_protected: Vec<String> = generate_specs
288        .iter()
289        .map(|s| s.name.clone())
290        .collect();
291    sorted_protected.sort();
292
293    // NEP-413 message
294    let message = format!(
295        "Update Outlayer secrets for {}:{}\nkeys:{}\nprotected:{}",
296        creds.account_id,
297        profile,
298        sorted_keys.join(","),
299        sorted_protected.join(","),
300    );
301
302    let recipient = &network.contract_id;
303
304    eprintln!("Signing update request...");
305
306    // Sign: local key or wallet API
307    // Both verifiers (keystore, coordinator) expect signature in base64 format.
308    let (signature, public_key, nonce_base64) = if creds.is_wallet_key() {
309        let wk = creds
310            .wallet_key
311            .as_ref()
312            .context("wallet_key missing from credentials")?;
313        let api = ApiClient::new(network);
314        let resp = api.sign_message(wk, &message, recipient, None).await?;
315        (resp.signature_base64, resp.public_key, resp.nonce)
316    } else {
317        let private_key = config::load_private_key(&network.network_id, &creds.account_id, &creds)?;
318        let (sig_near, pk, nonce) = crypto::sign_nep413(&private_key, &message, recipient)?;
319        // Convert ed25519:base58 → raw bytes → base64
320        let sig_b58 = sig_near.strip_prefix("ed25519:").unwrap_or(&sig_near);
321        let sig_bytes = bs58::decode(sig_b58).into_vec().context("Failed to decode signature base58")?;
322        let sig_base64 = base64::Engine::encode(
323            &base64::engine::general_purpose::STANDARD,
324            &sig_bytes,
325        );
326        (sig_base64, pk, nonce)
327    };
328
329    // Build secrets to send (plaintext — coordinator encrypts inside TEE)
330    let secrets_value = secrets_map
331        .as_ref()
332        .map(|m| Value::Object(m.clone()))
333        .unwrap_or(json!({}));
334
335    let generate_protected: Vec<Value> = generate_specs
336        .iter()
337        .map(|s| json!({"name": s.name, "generation_type": s.generation_type}))
338        .collect();
339
340    let api = ApiClient::new(network);
341
342    eprintln!("Updating secrets...");
343    let response = api
344        .update_user_secrets(&json!({
345            "accessor": accessor.coordinator,
346            "profile": profile,
347            "owner": creds.account_id,
348            "mode": "append",
349            "secrets": secrets_value,
350            "generate_protected": generate_protected,
351            "signed_message": message,
352            "signature": signature,
353            "public_key": public_key,
354            "nonce": nonce_base64,
355            "recipient": recipient,
356        }))
357        .await
358        .context("Failed to update secrets")?;
359
360    // Store merged result on contract
361    let caller = ContractCaller::from_credentials(&creds, network)?;
362    let deposit = 100_000_000_000_000_000_000_000u128; // 0.1 NEAR
363    let gas = 50_000_000_000_000u64;
364
365    caller
366        .call_contract(
367            "store_secrets",
368            json!({
369                "accessor": accessor.contract,
370                "profile": profile,
371                "encrypted_secrets_base64": response.encrypted_secrets_base64,
372                "access": "AllowAll",
373            }),
374            gas,
375            deposit,
376        )
377        .await
378        .context("Failed to store updated secrets")?;
379
380    // Summary
381    let mut parts = Vec::new();
382    if !sorted_keys.is_empty() {
383        parts.push(format!("updated: {}", sorted_keys.join(", ")));
384    }
385    if !sorted_protected.is_empty() {
386        parts.push(format!("protected (TEE): {}", sorted_protected.join(", ")));
387    }
388    eprintln!("Secrets updated (profile: {profile}, {})", parts.join("; "));
389
390    Ok(())
391}
392
393// ── List ─────────────────────────────────────────────────────────────
394
395/// `outlayer secrets list` — list stored secrets metadata
396pub async fn list(network: &NetworkConfig) -> Result<()> {
397    let creds = config::load_credentials(network)?;
398    let near = NearClient::new(network);
399
400    let secrets = near.list_user_secrets(&creds.account_id).await?;
401
402    // Filter out System (PaymentKey) entries
403    let user_secrets: Vec<_> = secrets
404        .iter()
405        .filter(|s| !s.accessor.to_string().contains("System"))
406        .collect();
407
408    if user_secrets.is_empty() {
409        eprintln!("No secrets stored.");
410        return Ok(());
411    }
412
413    println!(
414        "{:<15} {:<30} {:<15}",
415        "PROFILE", "ACCESSOR", "ACCESS"
416    );
417
418    for s in user_secrets {
419        let accessor_str = format_accessor(&s.accessor);
420        let access_str = format_access(&s.access);
421        println!("{:<15} {:<30} {:<15}", s.profile, accessor_str, access_str);
422    }
423
424    Ok(())
425}
426
427// ── Delete ───────────────────────────────────────────────────────────
428
429/// `outlayer secrets delete [--project|--repo|--wasm-hash]`
430#[allow(clippy::too_many_arguments)]
431pub async fn delete(
432    network: &NetworkConfig,
433    project_config: Option<&ProjectConfig>,
434    profile: &str,
435    project: Option<String>,
436    repo: Option<String>,
437    branch: Option<String>,
438    wasm_hash: Option<String>,
439) -> Result<()> {
440    let creds = config::load_credentials(network)?;
441
442    let accessor = resolve_accessor(project, repo, branch, wasm_hash, project_config)?;
443
444    let caller = ContractCaller::from_credentials(&creds, network)?;
445    let gas = 30_000_000_000_000u64; // 30 TGas
446
447    caller
448        .call_contract(
449            "delete_secrets",
450            json!({
451                "accessor": accessor.contract,
452                "profile": profile,
453            }),
454            gas,
455            0, // no deposit, storage refunded
456        )
457        .await
458        .context("Failed to delete secrets")?;
459
460    eprintln!("Secrets deleted (profile: {profile})");
461    Ok(())
462}
463
464// ── Helpers ──────────────────────────────────────────────────────────
465
466fn format_accessor(accessor: &Value) -> String {
467    if let Some(obj) = accessor.as_object() {
468        if let Some(project) = obj.get("Project") {
469            if let Some(id) = project.get("project_id").and_then(|v| v.as_str()) {
470                return format!("Project({id})");
471            }
472        }
473        if let Some(repo) = obj.get("Repo") {
474            if let Some(r) = repo.get("repo").and_then(|v| v.as_str()) {
475                let branch = repo
476                    .get("branch")
477                    .and_then(|v| v.as_str())
478                    .map(|b| format!("@{b}"))
479                    .unwrap_or_default();
480                return format!("Repo({r}{branch})");
481            }
482        }
483        if let Some(wasm) = obj.get("WasmHash") {
484            if let Some(h) = wasm.get("hash").and_then(|v| v.as_str()) {
485                let short = if h.len() > 8 { &h[..8] } else { h };
486                return format!("WasmHash({short}...)");
487            }
488        }
489    }
490    accessor.to_string()
491}
492
493fn format_access(access: &Value) -> String {
494    if access.is_string() && access.as_str() == Some("AllowAll") {
495        return "AllowAll".to_string();
496    }
497    if let Some(obj) = access.as_object() {
498        if let Some(wl) = obj.get("Whitelist") {
499            if let Some(arr) = wl.as_array() {
500                return format!("Whitelist({})", arr.len());
501            }
502        }
503    }
504    access.to_string()
505}