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:
2026-03-08 17:50:15 +01:00
parent 077f48f19c
commit a05da9b751
20 changed files with 1433 additions and 657 deletions

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 {