diff --git a/Cargo.lock b/Cargo.lock index 20c0b63..c99dc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2134,7 +2134,10 @@ dependencies = [ "clap", "jacquard", "jacquard-axum", + "k256", "miette", + "multibase", + "rand 0.8.6", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e8085c8..ec71d35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ clap = { version = "4.6", features = ["derive", "env"] } http = "1.4" jacquard = "0.11" jacquard-axum = { version = "0.11", features = ["service-auth"] } +k256 = { version = "0.13", features = ["ecdsa"] } +multibase = "0.9" +rand = "0.8" miette = { version = "7.6", features = ["fancy"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/knotty-knot/Cargo.toml b/crates/knotty-knot/Cargo.toml index 42b4365..b24397f 100644 --- a/crates/knotty-knot/Cargo.toml +++ b/crates/knotty-knot/Cargo.toml @@ -9,6 +9,9 @@ axum.workspace = true clap.workspace = true jacquard.workspace = true jacquard-axum.workspace = true +k256.workspace = true +multibase.workspace = true +rand.workspace = true miette.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/knotty-knot/src/keypair.rs b/crates/knotty-knot/src/keypair.rs new file mode 100644 index 0000000..7125116 --- /dev/null +++ b/crates/knotty-knot/src/keypair.rs @@ -0,0 +1,41 @@ +use std::path::Path; + +use k256::ecdsa::SigningKey; +use miette::{IntoDiagnostic, WrapErr}; + +pub struct Keypair { + signing_key: SigningKey, +} + +impl Keypair { + pub fn load(path: &Path) -> miette::Result { + let bytes = std::fs::read(path) + .into_diagnostic() + .wrap_err_with(|| format!("reading key file {}", path.display()))?; + let signing_key = SigningKey::from_bytes(bytes.as_slice().into()) + .into_diagnostic() + .wrap_err("parsing secp256k1 key from file")?; + tracing::info!(path = %path.display(), "loaded signing key"); + Ok(Self { signing_key }) + } + + pub fn generate(path: &Path) -> miette::Result { + let signing_key = SigningKey::random(&mut rand::thread_rng()); + std::fs::write(path, signing_key.to_bytes()) + .into_diagnostic() + .wrap_err_with(|| format!("writing key file {}", path.display()))?; + tracing::info!(path = %path.display(), "generated new signing key"); + Ok(Self { signing_key }) + } + + /// Encode the compressed secp256k1 public key as a multibase multikey string. + /// + /// Format: base58btc('z') prefix + varint(0xE7 secp256k1-pub) + compressed point bytes. + pub fn public_key_multibase(&self) -> String { + let compressed = self.signing_key.verifying_key().to_encoded_point(true); + // secp256k1-pub multicodec varint: 0xE7 → [0xe7, 0x01] + let mut payload = vec![0xe7u8, 0x01]; + payload.extend_from_slice(compressed.as_bytes()); + multibase::encode(multibase::Base::Base58Btc, &payload) + } +} diff --git a/crates/knotty-knot/src/main.rs b/crates/knotty-knot/src/main.rs index 79ee7a4..d1681dc 100644 --- a/crates/knotty-knot/src/main.rs +++ b/crates/knotty-knot/src/main.rs @@ -1,15 +1,32 @@ mod error; +mod keypair; mod router; mod state; mod xrpc; -use clap::Parser; +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; use miette::IntoDiagnostic; use state::KnotState; #[derive(Parser, Debug, Clone)] #[command(author, version, about = "Knotty - tangled git mirroring knot server")] -pub struct Config { +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Clone, Debug, Subcommand)] +enum Commands { + /// Run the knot server + Serve(ServeArgs), + /// Generate a secp256k1 signing keypair + Generate(GenerateArgs), +} + +#[derive(Clone, Debug, Args)] +pub struct ServeArgs { #[arg(long, env = "KNOT_LISTEN_ADDR", default_value = "0.0.0.0:5555")] pub listen_addr: String, @@ -24,30 +41,60 @@ pub struct Config { /// Path where bare git repos are stored #[arg(long, env = "KNOT_REPO_PATH", default_value = "/home/git")] pub repo_path: String, + + /// Path to the secp256k1 signing key file (loaded on startup, must exist) + #[arg(long, env = "KNOT_KEY_PATH")] + pub key_path: PathBuf, +} + +#[derive(Clone, Debug, Args)] +pub struct GenerateArgs { + /// Path to write the generated secp256k1 signing key + #[arg(long, env = "KNOT_KEY_PATH")] + pub key_path: PathBuf, + + /// Overwrite key path if found + #[arg(long)] + pub overwrite: bool, } #[tokio::main] async fn main() -> miette::Result<()> { tracing_subscriber::fmt::init(); - let config = Config::parse(); + let config = Cli::parse(); + + match config.command { + Commands::Generate(args) => { + let path = args.key_path; + if path.exists() && !args.overwrite { + miette::bail!("{path:?} file already exists"); + } + keypair::Keypair::generate(&path)?; + Ok(()) + } + Commands::Serve(args) => { + let keypair = keypair::Keypair::load(&args.key_path)?; - let listen_addr = config.listen_addr.clone(); - let state = KnotState::new(config); + let listen_addr = args.listen_addr.clone(); + let state = KnotState::new(args); - tracing::info!( - hostname = %state.config.hostname, - owner = %state.config.owner, - "starting knotty-knot", - ); + tracing::info!( + hostname = %state.config.hostname, + owner = %state.config.owner, + did = %state.service_did, + "starting knotty-knot", + ); - let listener = tokio::net::TcpListener::bind(&listen_addr) - .await - .into_diagnostic()?; + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .into_diagnostic()?; - tracing::info!("listening on {listen_addr}"); - axum::serve(listener, router::router(state)) - .await - .into_diagnostic()?; + tracing::info!("listening on {listen_addr}"); + axum::serve(listener, router::router(state)) + .await + .into_diagnostic()?; - Ok(()) + Ok(()) + } + } } diff --git a/crates/knotty-knot/src/state.rs b/crates/knotty-knot/src/state.rs index e8d0190..ba1cc5f 100644 --- a/crates/knotty-knot/src/state.rs +++ b/crates/knotty-knot/src/state.rs @@ -2,17 +2,17 @@ use jacquard::identity::{slingshot_resolver_default, JacquardResolver}; use jacquard::types::string::Did; use jacquard_axum::service_auth::ServiceAuth; -use crate::Config; +use crate::ServeArgs; #[derive(Clone)] pub struct KnotState { - pub config: Config, + pub config: ServeArgs, pub resolver: JacquardResolver, - service_did: Did<'static>, + pub service_did: Did<'static>, } impl KnotState { - pub fn new(config: Config) -> Self { + pub fn new(config: ServeArgs) -> Self { let resolver = slingshot_resolver_default(); let did_str: &'static str = format!("did:web:{}", config.hostname).leak(); let service_did = Did::new_static(did_str).expect("invalid hostname for did:web");