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
9struct ResolvedAccessor {
12 coordinator: Value,
14 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 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
58fn 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
79struct 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
108fn 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#[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 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 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 let caller = ContractCaller::from_credentials(&creds, network)?;
214 let deposit = 100_000_000_000_000_000_000_000u128; let gas = 50_000_000_000_000u64; 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 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#[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 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 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 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 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 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 let caller = ContractCaller::from_credentials(&creds, network)?;
362 let deposit = 100_000_000_000_000_000_000_000u128; 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 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
393pub 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 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#[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; caller
448 .call_contract(
449 "delete_secrets",
450 json!({
451 "accessor": accessor.contract,
452 "profile": profile,
453 }),
454 gas,
455 0, )
457 .await
458 .context("Failed to delete secrets")?;
459
460 eprintln!("Secrets deleted (profile: {profile})");
461 Ok(())
462}
463
464fn 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}