From 859b84b9af54377d012fff8a17c511585d799913 Mon Sep 17 00:00:00 2001 From: RustMunkey Date: Fri, 27 Mar 2026 22:11:59 -0600 Subject: [PATCH] feat(cli): move secrets to OS keychain, update mach token metadata to github --- Cargo.lock | 11 ++ assets/card-logo.svg | 3 + assets/mach-token-metadata.json | 4 +- packages/cli/Cargo.toml | 1 + packages/cli/src/commands/node/join.rs | 2 +- packages/cli/src/config.rs | 138 ++++++++++++++++++++----- scripts/update-token-uri.ts | 94 +++++++++++++++++ 7 files changed, 222 insertions(+), 31 deletions(-) create mode 100644 assets/card-logo.svg create mode 100644 scripts/update-token-uri.ts diff --git a/Cargo.lock b/Cargo.lock index c9d5f3d..5ebfb74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2985,6 +2985,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -3200,6 +3210,7 @@ dependencies = [ "hex", "indicatif", "inquire", + "keyring", "rand 0.8.5", "ratatui", "reqwest 0.12.28", diff --git a/assets/card-logo.svg b/assets/card-logo.svg new file mode 100644 index 0000000..0f4f871 --- /dev/null +++ b/assets/card-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/mach-token-metadata.json b/assets/mach-token-metadata.json index 642428f..4a3e408 100644 --- a/assets/mach-token-metadata.json +++ b/assets/mach-token-metadata.json @@ -2,13 +2,13 @@ "name": "Maschina", "symbol": "MACH", "description": "Maschina governance and utility token. Stake to participate in network governance, earn rewards, and access premium compute capacity on the agentic network.", - "image": "https://arweave.net/At45mjn2d5h2VqcYVGrc9xNf4YAtSuMcaN5qChaH2VsU", + "image": "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token.png", "external_url": "https://maschina.dev", "attributes": [], "properties": { "files": [ { - "uri": "https://arweave.net/At45mjn2d5h2VqcYVGrc9xNf4YAtSuMcaN5qChaH2VsU", + "uri": "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token.png", "type": "image/png" } ], diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index ce8dfea..8d112c7 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -28,6 +28,7 @@ dirs = "5" chrono = { version = "0.4", features = ["serde"] } anyhow = "1" thiserror = "1" +keyring = "3" arboard = { version = "3", default-features = false } # Node binary deps diff --git a/packages/cli/src/commands/node/join.rs b/packages/cli/src/commands/node/join.rs index 4d1c024..2feba1c 100644 --- a/packages/cli/src/commands/node/join.rs +++ b/packages/cli/src/commands/node/join.rs @@ -186,7 +186,7 @@ pub async fn run(profile: &str, out: &Output) -> Result<()> { // 7. Save to config let node_cfg = NodeConfig { node_id: node_id.clone(), - signing_key: privkey_b64, + signing_key: Some(privkey_b64), runtime_url, nats_url, nats_ca_cert, diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index e42e60d..ea4a42d 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -1,18 +1,21 @@ use anyhow::{Context, Result}; +use keyring::Entry; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { pub api_url: String, + /// Stored in OS keychain — field is None in the TOML file on disk. + #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, pub email: Option, - /// Database connection URL (sqlite path or postgres/neon URL) + /// Stored in OS keychain — field is None in the TOML file on disk. #[serde(skip_serializing_if = "Option::is_none")] pub db_url: Option, - /// Configured AI model providers + /// Configured AI model providers (api_key fields stored in keychain) #[serde(skip_serializing_if = "Vec::is_empty", default)] pub model_providers: Vec, @@ -20,7 +23,7 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] pub node: Option, - /// Cached tier from last successful auth (access/m1/m5/m10/teams/enterprise/beta) + /// Cached tier from last successful auth #[serde(skip_serializing_if = "Option::is_none")] pub tier: Option, @@ -36,30 +39,55 @@ pub struct Config { /// Stored when a machine joins the Maschina compute network. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeConfig { - /// UUID assigned by the API on registration pub node_id: String, - /// Base64-encoded Ed25519 signing key (32 bytes) — used to sign execution receipts - pub signing_key: String, - /// URL of the local Python runtime this node will forward tasks to + /// Base64-encoded Ed25519 signing key — stored in OS keychain, not on disk. + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_key: Option, pub runtime_url: String, - /// NATS server URL for receiving tasks pub nats_url: String, - /// Optional path to a CA certificate for TLS-enabled NATS connections #[serde(skip_serializing_if = "Option::is_none")] pub nats_ca_cert: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelProvider { - /// e.g. "anthropic", "openai", "ollama", "openrouter", "gemini", "mistral" pub name: String, + /// Stored in OS keychain — field is None in the TOML file on disk. #[serde(skip_serializing_if = "Option::is_none")] pub api_key: Option, - /// For Ollama or custom OpenAI-compatible endpoints #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, } +// ─── Keychain helpers ───────────────────────────────────────────────────────── + +const KEYRING_SERVICE: &str = "maschina"; + +fn kr_key(profile: &str, field: &str) -> String { + format!("{profile}/{field}") +} + +fn kr_get(profile: &str, field: &str) -> Option { + Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) + .ok() + .and_then(|e| e.get_password().ok()) + .filter(|s| !s.is_empty()) +} + +fn kr_set(profile: &str, field: &str, value: &str) { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) { + let _ = entry.set_password(value); + } +} + +fn kr_del(profile: &str, field: &str) { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, &kr_key(profile, field)) { + let _ = entry.delete_credential(); + } +} + +// ─── Config impl ────────────────────────────────────────────────────────────── + impl Config { pub fn default_api_url() -> String { "https://api.maschina.ai".to_string() @@ -73,7 +101,7 @@ impl Config { } } -/// ~/.config/maschina/.toml +/// `~/.config/maschina/.toml` pub fn config_path(profile: &str) -> Result { let base = dirs::config_dir() .or_else(dirs::home_dir) @@ -86,36 +114,90 @@ pub fn config_path(profile: &str) -> Result { Ok(base.join("maschina").join(filename)) } +/// Load config from disk and inject secrets from OS keychain. +/// Falls back to any plaintext value already in the TOML (handles migration +/// from older versions that stored secrets in the file). pub fn load(profile: &str) -> Result { let path = config_path(profile)?; - if !path.exists() { - return Ok(Config { + let mut cfg = if path.exists() { + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let mut c: Config = toml::from_str(&contents).context("failed to parse config.toml")?; + c.profile = profile.to_string(); + c + } else { + Config { api_url: Config::default_api_url(), - api_key: None, - email: None, - db_url: None, - model_providers: vec![], - node: None, - tier: None, - tui_theme: None, profile: profile.to_string(), - }); + ..Default::default() + } + }; + + // Inject secrets from keychain; fall back to TOML value so old plaintext + // configs continue to work until the next `save()` migrates them. + cfg.api_key = kr_get(profile, "api_key").or(cfg.api_key); + cfg.db_url = kr_get(profile, "db_url").or(cfg.db_url); + + for provider in &mut cfg.model_providers { + let field = format!("provider/{}", provider.name); + provider.api_key = kr_get(profile, &field).or(provider.api_key.take()); + } + + if let Some(ref mut node) = cfg.node { + node.signing_key = kr_get(profile, "node_signing_key").or(node.signing_key.take()); } - let contents = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - let mut cfg: Config = toml::from_str(&contents).context("failed to parse config.toml")?; - cfg.profile = profile.to_string(); + Ok(cfg) } -pub fn save(config: &Config, profile: &str) -> Result<()> { +/// Persist secrets to OS keychain, then write TOML with those fields stripped. +pub fn save(cfg: &Config, profile: &str) -> Result<()> { + // ── Write secrets to keychain ───────────────────────────────────────────── + match &cfg.api_key { + Some(v) if !v.is_empty() => kr_set(profile, "api_key", v), + None => kr_del(profile, "api_key"), + _ => {} + } + match &cfg.db_url { + Some(v) if !v.is_empty() => kr_set(profile, "db_url", v), + None => kr_del(profile, "db_url"), + _ => {} + } + for provider in &cfg.model_providers { + let field = format!("provider/{}", provider.name); + match &provider.api_key { + Some(v) if !v.is_empty() => kr_set(profile, &field, v), + None => kr_del(profile, &field), + _ => {} + } + } + if let Some(ref node) = cfg.node { + match &node.signing_key { + Some(v) if !v.is_empty() => kr_set(profile, "node_signing_key", v), + None => kr_del(profile, "node_signing_key"), + _ => {} + } + } + + // ── Write TOML with secrets stripped ────────────────────────────────────── + let mut on_disk = cfg.clone(); + on_disk.api_key = None; + on_disk.db_url = None; + for p in &mut on_disk.model_providers { + p.api_key = None; + } + if let Some(ref mut node) = on_disk.node { + node.signing_key = None; + } + let path = config_path(profile)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let contents = toml::to_string_pretty(config).context("failed to serialize config")?; + let contents = toml::to_string_pretty(&on_disk).context("failed to serialize config")?; std::fs::write(&path, &contents) .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) } diff --git a/scripts/update-token-uri.ts b/scripts/update-token-uri.ts new file mode 100644 index 0000000..4c9d8ac --- /dev/null +++ b/scripts/update-token-uri.ts @@ -0,0 +1,94 @@ +/** + * update-token-uri.ts + * + * Updates the on-chain metadata URI for the MACH Token-2022 mint. + * Run this after the new metadata file is live on main (GitHub raw URL accessible). + * + * Run: + * SOLANA_CLUSTER=devnet npx tsx scripts/update-token-uri.ts + * + * Prerequisites: + * - MACH_MINT_ADDRESS in env (from setup-mach-token.ts output) + * - Keypair at ~/.config/solana/id.json (or ANCHOR_WALLET) — must be update authority + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { TOKEN_2022_PROGRAM_ID, createUpdateFieldInstruction } from "@solana/spl-token"; +import { + Connection, + Keypair, + PublicKey, + Transaction, + sendAndConfirmTransaction, +} from "@solana/web3.js"; + +const CLUSTER = process.env.SOLANA_CLUSTER ?? "devnet"; + +const NEW_METADATA_URI = + "https://raw.githubusercontent.com/RustMunkey/maschina/main/assets/mach-token-metadata.json"; + +function getRpcUrl(): string { + if (CLUSTER === "localnet") return "http://127.0.0.1:8899"; + const apiKey = process.env.HELIUS_API_KEY; + if (apiKey) { + return CLUSTER === "mainnet-beta" + ? `https://mainnet.helius-rpc.com/?api-key=${apiKey}` + : `https://devnet.helius-rpc.com/?api-key=${apiKey}`; + } + return CLUSTER === "mainnet-beta" + ? "https://api.mainnet-beta.solana.com" + : "https://api.devnet.solana.com"; +} + +function loadKeypair(): Keypair { + const walletPath = + process.env.ANCHOR_WALLET ?? path.join(os.homedir(), ".config", "solana", "id.json"); + const bytes = JSON.parse(fs.readFileSync(walletPath, "utf-8")) as number[]; + return Keypair.fromSecretKey(Uint8Array.from(bytes)); +} + +async function main() { + const mintAddress = process.env.MACH_MINT_ADDRESS; + if (!mintAddress) { + console.error("MACH_MINT_ADDRESS is required."); + process.exit(1); + } + + const connection = new Connection(getRpcUrl(), "confirmed"); + const authority = loadKeypair(); + const mint = new PublicKey(mintAddress); + + console.log(`Cluster: ${CLUSTER}`); + console.log(`Mint: ${mintAddress}`); + console.log(`Authority: ${authority.publicKey.toBase58()}`); + console.log(`New URI: ${NEW_METADATA_URI}\n`); + + // Token-2022 TokenMetadata extension — metadata lives at the mint address itself. + // updateField sets a single field by name; "uri" is the metadata URI. + const ix = createUpdateFieldInstruction({ + programId: TOKEN_2022_PROGRAM_ID, + metadata: mint, + updateAuthority: authority.publicKey, + field: "uri", + value: NEW_METADATA_URI, + }); + + const tx = new Transaction().add(ix); + tx.feePayer = authority.publicKey; + + console.log("Sending updateField transaction..."); + const sig = await sendAndConfirmTransaction(connection, tx, [authority], { + commitment: "confirmed", + }); + + console.log("\nDone."); + console.log(` Signature: ${sig}`); + console.log(` New URI: ${NEW_METADATA_URI}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});