diff --git a/Cargo.lock b/Cargo.lock index 24d4180..4066036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,9 +288,11 @@ dependencies = [ "clap", "ed25519-dalek", "hex", + "humantime", "rand", "reqwest", "rusqlite", + "serde_json", "tokio", ] diff --git a/blah-types/src/lib.rs b/blah-types/src/lib.rs index 76d68b4..b33bf65 100644 --- a/blah-types/src/lib.rs +++ b/blah-types/src/lib.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::str::FromStr; use std::time::SystemTime; use bitflags_serde_shim::impl_serde_for_bitflags; @@ -92,6 +93,14 @@ pub struct UserKey { #[serde(transparent)] pub struct PubKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]); +impl FromStr for PubKey { + type Err = hex::FromHexError; + + fn from_str(s: &str) -> Result { + hex::FromHex::from_hex(s).map(Self) + } +} + impl fmt::Display for PubKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut buf = [0u8; PUBLIC_KEY_LENGTH * 2]; diff --git a/blahctl/Cargo.toml b/blahctl/Cargo.toml index 89e08a6..09090ad 100644 --- a/blahctl/Cargo.toml +++ b/blahctl/Cargo.toml @@ -8,9 +8,11 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } ed25519-dalek = { version = "2", features = ["pkcs8", "pem", "rand_core"] } hex = "0.4" +humantime = "2" rand = "0.8" reqwest = { version = "0.12", features = ["json"] } rusqlite = "0.32" +serde_json = "1" tokio = { version = "1", features = ["rt", "macros"] } blah-types = { path = "../blah-types", features = ["rusqlite"] } diff --git a/blahctl/src/main.rs b/blahctl/src/main.rs index 18deabd..585e85f 100644 --- a/blahctl/src/main.rs +++ b/blahctl/src/main.rs @@ -1,16 +1,18 @@ -use std::io::Write; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fs, io}; +use std::time::SystemTime; -use anyhow::{Context, Result}; +use anyhow::{ensure, Context, Result}; use blah_types::{ bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, PubKey, RichText, - RoomAttrs, ServerPermission, Signed, + RoomAttrs, ServerPermission, Signed, UserActKeyDesc, UserIdentityDesc, UserProfile, }; use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; -use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey}; +use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey}; use ed25519_dalek::{SigningKey, VerifyingKey, PUBLIC_KEY_LENGTH}; +use humantime::Duration; use rand::rngs::OsRng; +use rand::thread_rng; use reqwest::Url; use rusqlite::{named_params, Connection}; use tokio::runtime::Runtime; @@ -28,11 +30,14 @@ struct Cli { #[derive(Debug, clap::Subcommand)] enum Command { - /// Generate a keypair. - GenerateKey { - /// The output path to store secret key. - #[arg(long, short)] - output: PathBuf, + /// Identity management. + Identity { + /// The identity description JSON file to write or modify. + #[arg(long, short = 'f')] + desc_file: PathBuf, + + #[command(subcommand)] + command: IdCommand, }, /// Database manipulation. Database { @@ -54,6 +59,43 @@ enum Command { }, } +#[derive(Debug, clap::Subcommand)] +enum IdCommand { + /// Generate a new identity keypair. + Generate { + /// The output path to save the generated signing (private) key. + /// Keep it secret and safe! + #[arg(long)] + id_key_file: PathBuf, + + /// The URL where the identity description is hosted on. + /// + /// It must be a domain with top-level path `/`. It should have HTTPS schema. + /// The identity description file should be available at + /// `/.well-known/blah/identity.json`. + #[arg(long)] + id_url: Url, + }, + /// Add an action subkey to an existing identity description. + AddActKey { + /// The identity signing (private) key to sign with. + #[arg(long)] + id_key_file: PathBuf, + + /// The verifying (public) key of the action subkey to add. + #[arg(long)] + act_key: PubKey, + + /// The valid duration for the new subkey, starting from now. + #[arg(long)] + expire: Duration, + + /// Comment for the new subkey. + #[arg(long)] + comment: Option, + }, +} + #[derive(Debug, clap::Subcommand)] enum DbCommand { /// Create and initialize database. @@ -148,12 +190,7 @@ fn main() -> Result<()> { let cli = ::parse(); match cli.command { - Command::GenerateKey { output } => { - let privkey = SigningKey::generate(&mut OsRng); - let pubkey_doc = privkey.verifying_key().to_public_key_pem(LineEnding::LF)?; - privkey.write_pkcs8_pem_file(&output, LineEnding::LF)?; - io::stdout().write_all(pubkey_doc.as_bytes())?; - } + Command::Identity { desc_file, command } => main_id(desc_file, command)?, Command::Database { database, command } => { use rusqlite::OpenFlags; @@ -179,6 +216,86 @@ fn build_rt() -> Result { .context("failed to initialize tokio runtime") } +fn main_id(desc_file: PathBuf, cmd: IdCommand) -> Result<()> { + match cmd { + IdCommand::Generate { + id_key_file, + id_url, + } => { + let rng = &mut thread_rng(); + let id_key_priv = SigningKey::generate(rng); + let id_key = PubKey(id_key_priv.verifying_key().to_bytes()); + + let act_key_desc = UserActKeyDesc { + act_key: id_key.clone(), + expire_time: i64::MAX as _, + comment: "id_key".into(), + }; + let act_key_desc = + Signed::sign(&id_key, &id_key_priv, get_timestamp(), rng, act_key_desc)?; + let profile = UserProfile { + preferred_chat_server_urls: Vec::new(), + id_urls: vec![id_url], + }; + let profile = Signed::sign(&id_key, &id_key_priv, get_timestamp(), rng, profile)?; + let id_desc = UserIdentityDesc { + id_key, + act_keys: vec![act_key_desc], + profile, + }; + let id_desc_str = serde_json::to_string_pretty(&id_desc).unwrap(); + + id_key_priv + .write_pkcs8_pem_file(&id_key_file, LineEnding::LF) + .context("failed to save private key")?; + fs::write(desc_file, &id_desc_str).context("failed to save identity description")?; + } + IdCommand::AddActKey { + id_key_file, + act_key, + expire, + comment, + } => { + let id_desc = fs::read_to_string(&desc_file).context("failed to open desc_file")?; + let mut id_desc = serde_json::from_str::(&id_desc) + .context("failed to parse desc_file")?; + let id_key_priv = load_signing_key(&id_key_file)?; + let id_key = PubKey(id_key_priv.verifying_key().to_bytes()); + ensure!(id_key == id_desc.id_key, "id_key mismatch with key file"); + let exists = id_desc + .act_keys + .iter() + .any(|kdesc| kdesc.signee.payload.act_key == act_key); + ensure!(!exists, "duplicated act_key"); + + let expire_time: i64 = SystemTime::now() + .checked_add(*expire) + .and_then(|time| { + time.duration_since(SystemTime::UNIX_EPOCH) + .ok()? + .as_secs() + .try_into() + .ok() + }) + .context("invalid expire time")?; + + let rng = &mut thread_rng(); + let act_key_desc = UserActKeyDesc { + act_key, + expire_time: expire_time as _, + comment: comment.unwrap_or_default(), + }; + let act_key_desc = + Signed::sign(&id_key, &id_key_priv, get_timestamp(), rng, act_key_desc)?; + id_desc.act_keys.push(act_key_desc); + + let id_desc_str = serde_json::to_string_pretty(&id_desc).unwrap(); + fs::write(desc_file, &id_desc_str).context("failed to save identity description")?; + } + } + Ok(()) +} + fn main_db(conn: Connection, command: DbCommand) -> Result<()> { match command { DbCommand::Init => {}