mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-05-01 00:31:09 +00:00
398 lines
12 KiB
Rust
398 lines
12 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::SystemTime;
|
|
|
|
use anyhow::{ensure, Context, Result};
|
|
use blah_types::{
|
|
bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, PubKey, RichText,
|
|
RoomAttrs, ServerPermission, Signed, UserActKeyDesc, UserIdentityDesc, UserProfile,
|
|
};
|
|
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
|
|
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;
|
|
|
|
/// NB. Sync with docs of [`User::url`].
|
|
const KEY_URL_SUBPATH: &str = "/.well-known/blah/key";
|
|
|
|
/// Control or manage Blah Chat Server.
|
|
#[derive(Debug, clap::Parser)]
|
|
#[clap(about, version = option_env!("CFG_RELEASE").unwrap_or(env!("CARGO_PKG_VERSION")))]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Debug, clap::Subcommand)]
|
|
enum Command {
|
|
/// Identity management.
|
|
Identity {
|
|
#[command(subcommand)]
|
|
command: IdCommand,
|
|
},
|
|
/// Database manipulation.
|
|
Database {
|
|
/// The path to the database.
|
|
#[arg(long = "db")]
|
|
database: PathBuf,
|
|
|
|
#[command(subcommand)]
|
|
command: DbCommand,
|
|
},
|
|
/// Access the API endpoint.
|
|
Api {
|
|
/// The URL to the API endpoint.
|
|
#[arg(long)]
|
|
url: Url,
|
|
|
|
#[command(subcommand)]
|
|
command: ApiCommand,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, clap::Subcommand)]
|
|
enum IdCommand {
|
|
/// Generate a new identity keypair.
|
|
Generate {
|
|
/// The identity description JSON file to write.
|
|
#[arg(long, short = 'f')]
|
|
desc_file: PathBuf,
|
|
|
|
/// 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
|
|
/// `<id_url>/.well-known/blah/identity.json`.
|
|
#[arg(long)]
|
|
id_url: Url,
|
|
},
|
|
/// Add an action subkey to an existing identity description.
|
|
AddActKey {
|
|
/// The identity description JSON file to modify.
|
|
#[arg(long, short = 'f')]
|
|
desc_file: PathBuf,
|
|
|
|
/// 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<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, clap::Subcommand)]
|
|
enum DbCommand {
|
|
/// Create and initialize database.
|
|
Init,
|
|
/// Set user property, possibly adding new users.
|
|
SetUser {
|
|
#[command(flatten)]
|
|
user: Box<User>,
|
|
|
|
#[arg(long, value_parser = flag_parser::<ServerPermission>)]
|
|
permission: ServerPermission,
|
|
},
|
|
}
|
|
|
|
fn flag_parser<T: bitflags::Flags>(s: &str) -> clap::error::Result<T> {
|
|
bitflags::parser::from_str_strict(s)
|
|
.map_err(|_| clap::Error::new(clap::error::ErrorKind::InvalidValue))
|
|
}
|
|
|
|
#[derive(Debug, clap::Subcommand)]
|
|
enum ApiCommand {
|
|
/// Create a room with the given user as the only owner.
|
|
CreateRoom {
|
|
#[arg(long, short = 'f')]
|
|
private_key_file: PathBuf,
|
|
|
|
#[arg(long)]
|
|
title: String,
|
|
|
|
#[arg(long, value_parser = flag_parser::<RoomAttrs>)]
|
|
attrs: Option<RoomAttrs>,
|
|
},
|
|
PostChat {
|
|
#[arg(long, short = 'f')]
|
|
private_key_file: PathBuf,
|
|
|
|
#[arg(long)]
|
|
room: i64,
|
|
|
|
#[arg(long)]
|
|
text: String,
|
|
},
|
|
}
|
|
|
|
// This should be an enum but clap does not support it on `Args` yet.
|
|
// See: https://github.com/clap-rs/clap/issues/2621
|
|
#[derive(Debug, clap::Args)]
|
|
#[clap(group = clap::ArgGroup::new("user").required(true).multiple(false))]
|
|
struct User {
|
|
/// Hex-encoded public key.
|
|
#[arg(long, group = "user", value_parser = userkey_parser)]
|
|
key: Option<VerifyingKey>,
|
|
|
|
/// Path to a user public key.
|
|
#[arg(long, short = 'f', group = "user")]
|
|
public_key_file: Option<PathBuf>,
|
|
|
|
/// User's URL where `/.well-known/blah/key` is hosted.
|
|
#[arg(long, group = "user")]
|
|
url: Option<Url>,
|
|
}
|
|
|
|
fn userkey_parser(s: &str) -> clap::error::Result<VerifyingKey> {
|
|
(|| {
|
|
let mut buf = [0u8; PUBLIC_KEY_LENGTH];
|
|
hex::decode_to_slice(s, &mut buf).ok()?;
|
|
VerifyingKey::from_bytes(&buf).ok()
|
|
})()
|
|
.ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidValue))
|
|
}
|
|
|
|
impl User {
|
|
async fn fetch_key(&self) -> Result<PubKey> {
|
|
let rawkey = if let Some(key) = &self.key {
|
|
return Ok(PubKey(key.to_bytes()));
|
|
} else if let Some(path) = &self.public_key_file {
|
|
fs::read_to_string(path).context("failed to read key file")?
|
|
} else if let Some(url) = &self.url {
|
|
let url = url.join(KEY_URL_SUBPATH)?;
|
|
reqwest::get(url).await?.error_for_status()?.text().await?
|
|
} else {
|
|
unreachable!()
|
|
};
|
|
let key = VerifyingKey::from_public_key_pem(&rawkey)
|
|
.context("invalid key")?
|
|
.to_bytes();
|
|
Ok(PubKey(key))
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = <Cli as clap::Parser>::parse();
|
|
|
|
match cli.command {
|
|
Command::Identity { command } => main_id(command)?,
|
|
Command::Database { database, command } => {
|
|
use rusqlite::OpenFlags;
|
|
|
|
let mut flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX;
|
|
flags.set(
|
|
OpenFlags::SQLITE_OPEN_CREATE,
|
|
matches!(command, DbCommand::Init),
|
|
);
|
|
let conn =
|
|
Connection::open_with_flags(database, flags).context("failed to open database")?;
|
|
main_db(conn, command)?;
|
|
}
|
|
Command::Api { url, command } => build_rt()?.block_on(main_api(url, command))?,
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn build_rt() -> Result<Runtime> {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("failed to initialize tokio runtime")
|
|
}
|
|
|
|
fn main_id(cmd: IdCommand) -> Result<()> {
|
|
match cmd {
|
|
IdCommand::Generate {
|
|
desc_file,
|
|
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 {
|
|
desc_file,
|
|
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::<UserIdentityDesc>(&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 => {}
|
|
DbCommand::SetUser { user, permission } => {
|
|
let userkey = build_rt()?.block_on(user.fetch_key())?;
|
|
|
|
conn.execute(
|
|
r"
|
|
INSERT
|
|
INTO `user` (`userkey`, `permission`)
|
|
VALUES (:userkey, :permission)
|
|
ON CONFLICT (`userkey`) DO UPDATE SET
|
|
`permission` = :permission
|
|
",
|
|
named_params! {
|
|
":userkey": userkey,
|
|
":permission": permission,
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn load_signing_key(path: &Path) -> Result<SigningKey> {
|
|
let pem = fs::read_to_string(path).context("failed to read private key file")?;
|
|
SigningKey::from_pkcs8_pem(&pem).context("failed to parse private key")
|
|
}
|
|
|
|
async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
|
|
let client = reqwest::Client::new();
|
|
match command {
|
|
ApiCommand::CreateRoom {
|
|
private_key_file,
|
|
title,
|
|
attrs,
|
|
} => {
|
|
let key = load_signing_key(&private_key_file)?;
|
|
let payload = CreateRoomPayload::Group(CreateGroup {
|
|
attrs: attrs.unwrap_or_default(),
|
|
title,
|
|
});
|
|
// FIXME: Same key.
|
|
let payload = Signed::sign(
|
|
&PubKey(key.to_bytes()),
|
|
&key,
|
|
get_timestamp(),
|
|
&mut OsRng,
|
|
payload,
|
|
)?;
|
|
|
|
let ret = client
|
|
.post(api_url.join("/room/create")?)
|
|
.json(&payload)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.text()
|
|
.await?;
|
|
println!("{ret}");
|
|
}
|
|
ApiCommand::PostChat {
|
|
private_key_file,
|
|
room,
|
|
text,
|
|
} => {
|
|
let key = load_signing_key(&private_key_file)?;
|
|
let payload = ChatPayload {
|
|
room: Id(room),
|
|
rich_text: RichText::from(text),
|
|
};
|
|
// FIXME: Same key.
|
|
let payload = Signed::sign(
|
|
&PubKey(key.to_bytes()),
|
|
&key,
|
|
get_timestamp(),
|
|
&mut OsRng,
|
|
payload,
|
|
)?;
|
|
|
|
let ret = client
|
|
.post(api_url.join(&format!("/room/{room}/msg"))?)
|
|
.json(&payload)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.text()
|
|
.await?;
|
|
println!("{ret}");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|