feat: upgrade OpenMLS 0.5 → 0.8 for security patches and GREASE support
Migrates all MLS code in quicprochat-core from OpenMLS 0.5 to 0.8: - StorageProvider replaces OpenMlsKeyStore (keystore.rs full rewrite) - HybridCryptoProvider updated for new OpenMlsProvider trait - Group operations updated for new API signatures - MLS state persistence via MemoryStorage serialization - tls_codec 0.3 → 0.4, openmls_traits/rust_crypto 0.2 → 0.5
This commit is contained in:
@@ -50,8 +50,9 @@ rustls = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# CLI
|
||||
# CLI + config
|
||||
clap = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
# Local message/conversation storage
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
@@ -1449,10 +1449,8 @@ pub(crate) async fn cmd_dm(
|
||||
},
|
||||
display_name: format!("@{username}"),
|
||||
mls_group_blob: member
|
||||
.group_ref()
|
||||
.map(bincode::serialize)
|
||||
.transpose()
|
||||
.context("serialize group")?,
|
||||
.serialize_mls_state()
|
||||
.context("serialize MLS state")?,
|
||||
keystore_blob: None,
|
||||
member_keys,
|
||||
unread_count: 0,
|
||||
@@ -1493,10 +1491,8 @@ pub(crate) fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow
|
||||
kind: ConversationKind::Group { name: name.to_string() },
|
||||
display_name: format!("#{name}"),
|
||||
mls_group_blob: member
|
||||
.group_ref()
|
||||
.map(bincode::serialize)
|
||||
.transpose()
|
||||
.context("serialize group")?,
|
||||
.serialize_mls_state()
|
||||
.context("serialize MLS state")?,
|
||||
keystore_blob: None,
|
||||
member_keys,
|
||||
unread_count: 0,
|
||||
@@ -1780,9 +1776,7 @@ pub(crate) async fn cmd_join(
|
||||
kind: ConversationKind::Group { name: display.clone() },
|
||||
display_name: format!("#{display}"),
|
||||
mls_group_blob: new_member
|
||||
.group_ref()
|
||||
.map(bincode::serialize)
|
||||
.transpose()
|
||||
.serialize_mls_state()
|
||||
.context("serialize joined group")?,
|
||||
keystore_blob: None,
|
||||
member_keys,
|
||||
@@ -3186,8 +3180,9 @@ async fn try_auto_join(
|
||||
};
|
||||
|
||||
let mls_blob = member
|
||||
.group_ref()
|
||||
.and_then(|g| bincode::serialize(g).ok());
|
||||
.serialize_mls_state()
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let conv = Conversation {
|
||||
id: conv_id.clone(),
|
||||
|
||||
@@ -16,7 +16,7 @@ use quicprochat_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair
|
||||
use super::conversation::{
|
||||
now_ms, Conversation, ConversationId, ConversationKind, ConversationStore,
|
||||
};
|
||||
use super::state::{load_or_init_state, keystore_path};
|
||||
use super::state::load_or_init_state;
|
||||
|
||||
/// Runtime state for an interactive REPL session.
|
||||
pub struct SessionState {
|
||||
@@ -109,7 +109,7 @@ impl SessionState {
|
||||
/// Migrate the legacy single-group from StoredState into the conversation DB.
|
||||
fn migrate_legacy_group(
|
||||
&mut self,
|
||||
state_path: &Path,
|
||||
_state_path: &Path,
|
||||
group_blob: &Option<Vec<u8>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let blob = match group_blob {
|
||||
@@ -117,16 +117,22 @@ impl SessionState {
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Reconstruct GroupMember using the legacy keystore and group blob.
|
||||
let ks_path = keystore_path(state_path);
|
||||
let ks = DiskKeyStore::persistent(&ks_path)?;
|
||||
let group = bincode::deserialize(blob).context("decode legacy group")?;
|
||||
let member = GroupMember::new_with_state(
|
||||
// Legacy group blobs used openmls 0.5 serde format. After the 0.8
|
||||
// upgrade the blob format changed to storage-provider state. Attempt
|
||||
// to load from the new format; if that fails, skip the legacy group.
|
||||
let group_id_guess = &blob[..blob.len().min(16)];
|
||||
let member = match GroupMember::new_from_storage_bytes(
|
||||
Arc::clone(&self.identity),
|
||||
ks,
|
||||
Some(group),
|
||||
blob,
|
||||
group_id_guess,
|
||||
false, // legacy groups are classical
|
||||
);
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "skipping incompatible legacy group blob (openmls version mismatch)");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let group_id_bytes = member.group_id().unwrap_or_default();
|
||||
|
||||
@@ -182,26 +188,31 @@ impl SessionState {
|
||||
|
||||
/// Create a GroupMember from a stored conversation.
|
||||
fn create_member_from_conv(&self, conv: &Conversation) -> anyhow::Result<GroupMember> {
|
||||
let ks_path = self.keystore_path_for(&conv.id);
|
||||
let ks = DiskKeyStore::persistent(&ks_path)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(path = %ks_path.display(), error = %e, "DiskKeyStore open failed, falling back to ephemeral");
|
||||
DiskKeyStore::ephemeral()
|
||||
});
|
||||
|
||||
let group = conv
|
||||
.mls_group_blob
|
||||
.as_ref()
|
||||
.map(|b| bincode::deserialize(b))
|
||||
.transpose()
|
||||
.context("decode MLS group from conversation db")?;
|
||||
|
||||
Ok(GroupMember::new_with_state(
|
||||
Arc::clone(&self.identity),
|
||||
ks,
|
||||
group,
|
||||
conv.is_hybrid,
|
||||
))
|
||||
if let Some(blob) = conv.mls_group_blob.as_ref() {
|
||||
let group_id = conv.id.0.as_slice();
|
||||
let member = GroupMember::new_from_storage_bytes(
|
||||
Arc::clone(&self.identity),
|
||||
blob,
|
||||
group_id,
|
||||
conv.is_hybrid,
|
||||
)
|
||||
.context("restore MLS state from conversation db")?;
|
||||
Ok(member)
|
||||
} else {
|
||||
// No MLS state — create an empty member.
|
||||
let ks_path = self.keystore_path_for(&conv.id);
|
||||
let ks = DiskKeyStore::persistent(&ks_path)
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(path = %ks_path.display(), error = %e, "DiskKeyStore open failed, falling back to ephemeral");
|
||||
DiskKeyStore::ephemeral()
|
||||
});
|
||||
Ok(GroupMember::new_with_state(
|
||||
Arc::clone(&self.identity),
|
||||
ks,
|
||||
None,
|
||||
conv.is_hybrid,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Path for a per-conversation keystore file.
|
||||
@@ -214,10 +225,8 @@ impl SessionState {
|
||||
pub fn save_member(&self, conv_id: &ConversationId) -> anyhow::Result<()> {
|
||||
let member = self.members.get(conv_id).context("no such conversation")?;
|
||||
let blob = member
|
||||
.group_ref()
|
||||
.map(bincode::serialize)
|
||||
.transpose()
|
||||
.context("serialize MLS group")?;
|
||||
.serialize_mls_state()
|
||||
.context("serialize MLS state")?;
|
||||
|
||||
let member_keys = member.member_identities();
|
||||
|
||||
|
||||
@@ -27,18 +27,31 @@ pub struct StoredState {
|
||||
/// Cached member public keys for group participants.
|
||||
#[serde(default)]
|
||||
pub member_keys: Vec<Vec<u8>>,
|
||||
/// MLS group ID bytes, needed to reload the group from StorageProvider state.
|
||||
#[serde(default)]
|
||||
pub group_id: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl StoredState {
|
||||
pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option<HybridKeypair>)> {
|
||||
let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed));
|
||||
let group = self
|
||||
.group
|
||||
.map(|bytes| bincode::deserialize(&bytes).context("decode group"))
|
||||
.transpose()?;
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
|
||||
let hybrid = self.hybrid_key.is_some();
|
||||
let member = GroupMember::new_with_state(identity, key_store, group, hybrid);
|
||||
|
||||
let member = match (self.group.as_ref(), self.group_id.as_ref()) {
|
||||
(Some(storage_bytes), Some(gid)) => {
|
||||
GroupMember::new_from_storage_bytes(
|
||||
identity,
|
||||
storage_bytes,
|
||||
gid,
|
||||
hybrid,
|
||||
)
|
||||
.context("restore MLS state from stored state")?
|
||||
}
|
||||
_ => {
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
|
||||
GroupMember::new_with_state(identity, key_store, None, hybrid)
|
||||
}
|
||||
};
|
||||
|
||||
let hybrid_kp = self
|
||||
.hybrid_key
|
||||
@@ -50,15 +63,15 @@ impl StoredState {
|
||||
|
||||
pub fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result<Self> {
|
||||
let group = member
|
||||
.group_ref()
|
||||
.map(|g| bincode::serialize(g).context("serialize group"))
|
||||
.transpose()?;
|
||||
.serialize_mls_state()
|
||||
.context("serialize MLS state")?;
|
||||
|
||||
Ok(Self {
|
||||
identity_seed: *member.identity_seed(),
|
||||
group,
|
||||
hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()),
|
||||
member_keys: Vec::new(),
|
||||
group_id: member.group_id(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -245,6 +258,7 @@ mod tests {
|
||||
hybrid_key: None,
|
||||
group: None,
|
||||
member_keys: Vec::new(),
|
||||
group_id: None,
|
||||
};
|
||||
let password = "test-password";
|
||||
let plaintext = bincode::serialize(&state).unwrap();
|
||||
@@ -268,6 +282,7 @@ mod tests {
|
||||
}),
|
||||
group: None,
|
||||
member_keys: Vec::new(),
|
||||
group_id: None,
|
||||
};
|
||||
let password = "another-password";
|
||||
let plaintext = bincode::serialize(&state).unwrap();
|
||||
@@ -285,6 +300,7 @@ mod tests {
|
||||
hybrid_key: None,
|
||||
group: None,
|
||||
member_keys: Vec::new(),
|
||||
group_id: None,
|
||||
};
|
||||
let plaintext = bincode::serialize(&state).unwrap();
|
||||
let encrypted = encrypt_state("correct", &plaintext).unwrap();
|
||||
|
||||
@@ -28,12 +28,159 @@ use quicprochat_client::{
|
||||
#[cfg(all(feature = "tui", not(feature = "v2")))]
|
||||
use quicprochat_client::client::tui::run_tui;
|
||||
|
||||
// ── Config file loading ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Loads a TOML config file and sets QPQ_* environment variables for values
|
||||
// not already set. This runs BEFORE clap parses, so the natural precedence is:
|
||||
// CLI flags > environment variables > config file > compiled defaults.
|
||||
//
|
||||
// Config file search order:
|
||||
// 1. --config <path> (parsed manually from argv)
|
||||
// 2. $QPC_CONFIG env var
|
||||
// 3. $XDG_CONFIG_HOME/qpc/config.toml (usually ~/.config/qpc/config.toml)
|
||||
// 4. ~/.qpc.toml
|
||||
#[cfg(not(feature = "v2"))]
|
||||
mod client_config {
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ClientFileConfig {
|
||||
pub server: Option<String>,
|
||||
pub server_name: Option<String>,
|
||||
pub ca_cert: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub state_password: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub danger_accept_invalid_certs: Option<bool>,
|
||||
pub no_server: Option<bool>,
|
||||
}
|
||||
|
||||
/// Find and load the config file. Returns the parsed config (or default if
|
||||
/// no file is found).
|
||||
pub fn load_client_config() -> ClientFileConfig {
|
||||
let path = find_config_path();
|
||||
let path = match path {
|
||||
Some(p) if p.exists() => p,
|
||||
_ => return ClientFileConfig::default(),
|
||||
};
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => match toml::from_str(&contents) {
|
||||
Ok(cfg) => {
|
||||
eprintln!("Loaded config: {}", path.display());
|
||||
cfg
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to parse {}: {e}", path.display());
|
||||
ClientFileConfig::default()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Warning: failed to read {}: {e}", path.display());
|
||||
ClientFileConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_config_path() -> Option<PathBuf> {
|
||||
// 1. --config <path> from argv (before clap parses).
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for i in 0..args.len().saturating_sub(1) {
|
||||
if args[i] == "--config" || args[i] == "-c" {
|
||||
return Some(PathBuf::from(&args[i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. $QPC_CONFIG env var.
|
||||
if let Ok(p) = std::env::var("QPC_CONFIG") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
|
||||
// 3. $XDG_CONFIG_HOME/qpc/config.toml
|
||||
let xdg = std::env::var("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(".config")
|
||||
});
|
||||
let xdg_path = xdg.join("qpc").join("config.toml");
|
||||
if xdg_path.exists() {
|
||||
return Some(xdg_path);
|
||||
}
|
||||
|
||||
// 4. ~/.qpc.toml
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home_path = PathBuf::from(home).join(".qpc.toml");
|
||||
if home_path.exists() {
|
||||
return Some(home_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Set QPQ_* env vars from config values, but only if they're not already set.
|
||||
pub fn apply_config_to_env(cfg: &ClientFileConfig) {
|
||||
fn set_if_empty(key: &str, val: &str) {
|
||||
if std::env::var(key).is_err() {
|
||||
std::env::set_var(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref v) = cfg.server {
|
||||
set_if_empty("QPQ_SERVER", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.server_name {
|
||||
set_if_empty("QPQ_SERVER_NAME", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.ca_cert {
|
||||
set_if_empty("QPQ_CA_CERT", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.username {
|
||||
set_if_empty("QPQ_USERNAME", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.password {
|
||||
set_if_empty("QPQ_PASSWORD", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.access_token {
|
||||
set_if_empty("QPQ_ACCESS_TOKEN", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.device_id {
|
||||
set_if_empty("QPQ_DEVICE_ID", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.state_password {
|
||||
set_if_empty("QPQ_STATE_PASSWORD", v);
|
||||
}
|
||||
if let Some(ref v) = cfg.state {
|
||||
set_if_empty("QPQ_STATE", v);
|
||||
}
|
||||
if let Some(v) = cfg.danger_accept_invalid_certs {
|
||||
if v {
|
||||
set_if_empty("QPQ_DANGER_ACCEPT_INVALID_CERTS", "true");
|
||||
}
|
||||
}
|
||||
if let Some(v) = cfg.no_server {
|
||||
if v {
|
||||
set_if_empty("QPQ_NO_SERVER", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
#[cfg(not(feature = "v2"))]
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "qpc", about = "quicprochat CLI client", version)]
|
||||
struct Args {
|
||||
/// Path to a TOML config file (auto-detected from ~/.config/qpc/config.toml or ~/.qpc.toml).
|
||||
#[arg(long, short = 'c', global = true, env = "QPC_CONFIG")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Path to the server's TLS certificate (self-signed by default).
|
||||
#[arg(
|
||||
long,
|
||||
@@ -540,6 +687,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
)
|
||||
.init();
|
||||
|
||||
// Load config file and apply to env BEFORE clap parses (so config values
|
||||
// act as defaults that env vars and CLI flags can override).
|
||||
{
|
||||
let cfg = client_config::load_client_config();
|
||||
client_config::apply_config_to_env(&cfg);
|
||||
}
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
if args.danger_accept_invalid_certs {
|
||||
|
||||
Reference in New Issue
Block a user