Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/cgka-engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ still agree after the world gets messy.

See [`tests/AGENTS.md`](tests/AGENTS.md) for the test file map.

## KeyPackage validation before membership changes

Create and Invite validate transported KeyPackages through OpenMLS and the Marmot identity/profile checks before
adding members. They also reject LeafNode capabilities that explicitly advertise RFC 9420 section 7.2 default
extension or proposal types. Unknown capability values remain accepted for protocol extensibility.

This membership check does not change local private-bundle retention or historical Welcome processing. Directory
metadata can still describe an older package; that does not make it eligible for a new membership operation.

## Promoting state-bearing app components

`upgrade_group_capabilities` only promotes state-bearing app components whose
Expand Down
26 changes: 26 additions & 0 deletions crates/cgka-engine/src/key_package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ impl<S: StorageProvider> Engine<S> {

let provider = EngineOpenMlsProvider::<S>::new(&self.crypto, self.storage.mls_storage());
let key_package = validate_key_package(kp_in, provider.crypto())?;
validate_invitee_capabilities(&key_package)?;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bundled change with the largest blast radius in this PR.

mdk itself advertised ExtensionType::RequiredCapabilities (0x0003) in every leaf until #1709 landed on 2026-09-06 (first shipped in v0.9.19). Those packages stay lifetime-valid for up to ~3 months (OpenMLS default 3 * 28 days + 1h, not overridden here), and republish_key_package deliberately reuses the current artifact rather than rotating, so they stay published. After this check, any peer whose published package predates that fix -- including everyone still on v0.9.18 or earlier -- cannot be created-with or invited at all. The same PR removes the cached-package fallback, so there is no second path around it.

RFC 9420 section 7.3 leaf-node validation does not require this check, and tolerating a stray 0x0003 advertisement is harmless: the group's required-capabilities computation is unaffected. So the strictness buys conformance tidiness at a real availability cost against our own recent releases.

Two separable asks: (1) land this as its own PR rather than inside a caching fix, since it needs its own compatibility decision; (2) state the rollout -- warn + telemetry for a release, or accept-and-ignore per RFC extensibility -- before failing closed on packages mdk generated four days ago.

// foundation/key-packages.md: reject a KeyPackage whose credential
// identity is not a valid Marmot account identity. This single gate
// covers both the create-group and invite invitee paths.
Expand All @@ -336,6 +337,31 @@ impl<S: StorageProvider> Engine<S> {
}
}

/// Enforce the RFC 9420 section 7.2 advertisement rule before using a
/// KeyPackage for a new membership operation. Keep this out of the shared
/// storage/maintenance validator: old private bundles may still be needed to
/// process Welcomes sent before the peer refreshed its public KeyPackage.
fn validate_invitee_capabilities(key_package: &MlsKeyPackage) -> Result<(), EngineError> {
use crate::capabilities::{DEFAULT_MLS_EXTENSION_TYPES, DEFAULT_MLS_PROPOSAL_TYPES};

let capabilities = key_package.leaf_node().capabilities();
if capabilities
.extensions()
.iter()
.any(|kind| DEFAULT_MLS_EXTENSION_TYPES.contains(&u16::from(*kind)))
|| capabilities
.proposals()
.iter()
.any(|kind| DEFAULT_MLS_PROPOSAL_TYPES.contains(&u16::from(*kind)))
{
return Err(EngineError::Backend(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EngineError::Backend is the wrong carrier for a deliberate policy refusal, and it is observably wrong downstream:

  • classify_engine_error maps Backend to SubjectFailureCategory::Resource (crates/cgka-conformance-simulator/src/subject.rs:2324), so a conformance refusal will be reported by the simulator as a resource failure.
  • engine_error_kind records it as "backend", so forensic audit data cannot distinguish it from a storage/backend fault.
  • Every sibling KeyPackage refusal -- InvalidCredentialIdentity, InvalidAccountIdentityProof, InvalidKeyPackageLifetime -- is classified ExpectedRefusal.

Separately, the message names no member. An app creating a ten-person group gets "default capabilities must not be advertised" with no way to tell the user which invitee must republish. A dedicated variant carrying the credential identity (or a per-member rejection at the app resolution boundary, where AppError already attributes failures by account) would fix both.

"key_package validate: default capabilities must not be advertised (RFC 9420 section 7.2)"
.into(),
));
}
Ok(())
}

fn ensure_key_package_profile(
key_package: &KeyPackage,
wire_profile: ProtocolProfile,
Expand Down
167 changes: 167 additions & 0 deletions crates/cgka-engine/tests/group_creation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2411,6 +2411,173 @@ async fn create_group_rejects_relabelled_legacy_key_package_profile() {
);
}

/// Build a correctly signed package so a rejection proves semantic validation,
/// rather than a broken signature caused by editing serialized bytes.
fn key_package_with_advertised_capabilities(
profile: ProtocolProfile,
extra_extensions: &[u16],
extra_proposals: &[u16],
) -> cgka_traits::engine::KeyPackage {
let identity_seed = b"capability-invitee";
let ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
let provider = openmls_rust_crypto::OpenMlsRustCrypto::default();
let signer = SignatureKeyPair::new(ciphersuite.signature_algorithm()).unwrap();
let identity = pad32(identity_seed);
let credential = CredentialWithKey {
credential: BasicCredential::new(identity.clone()).into(),
signature_key: signer.public().into(),
};
let mut dictionary = AppDataDictionary::new();
let mut components = default_group_components();
components.insert(APP_COMPONENTS_COMPONENT_ID);
let mut extensions = Vec::new();
let mut extension_types = vec![ExtensionType::AppDataDictionary];
if profile == ProtocolProfile::Current {
components.insert(ACCOUNT_IDENTITY_PROOF_COMPONENT_ID);
dictionary.insert(
ACCOUNT_IDENTITY_PROOF_COMPONENT_ID,
account_identity_proof_component(
&identity,
&signer.to_public_vec(),
ciphersuite,
ciphersuite.signature_algorithm(),
1_700_000_000,
proof_signer(identity_seed).as_ref(),
)
.unwrap(),
);
} else {
extensions.push(
account_identity_proof_extension(
&identity,
&signer.to_public_vec(),
ciphersuite,
ciphersuite.signature_algorithm(),
proof_signer(identity_seed).as_ref(),
)
.unwrap(),
);
extension_types.push(ExtensionType::from(ACCOUNT_IDENTITY_PROOF_EXTENSION_TYPE));
}
dictionary.insert(
APP_COMPONENTS_COMPONENT_ID,
encode_components_list(&components),
);
extensions.push(Extension::AppDataDictionary(
AppDataDictionaryExtension::new(dictionary),
));
extension_types.extend(extra_extensions.iter().copied().map(ExtensionType::from));
let mut proposals = vec![openmls::prelude::ProposalType::AppDataUpdate];
proposals.extend(
extra_proposals
.iter()
.copied()
.map(openmls::prelude::ProposalType::from),
);
let bundle = MlsKeyPackage::builder()
.leaf_node_capabilities(Capabilities::new(
None,
Some(&[ciphersuite]),
Some(&extension_types),
Some(&proposals),
None,
))
.leaf_node_extensions(Extensions::from_vec(extensions).unwrap())
.build(ciphersuite, &provider, &signer, credential)
.unwrap();
let message: MlsMessageOut = bundle.key_package().clone().into();
cgka_traits::engine::KeyPackage::new(message.tls_serialize_detached().unwrap())
.with_protocol_profile(profile)
}

#[tokio::test]
async fn create_and_invite_reject_explicit_default_key_package_capabilities() {
for profile in [ProtocolProfile::Current, ProtocolProfile::Legacy] {
let storage = SqliteAccountStorage::in_memory().unwrap();
let mut alice = build_profile_client_on_storage(b"alice", storage.clone(), profile);
// Test every forbidden id separately, including the historical 0x0003
// RequiredCapabilities advertisement. Directory metadata stays usable
// for discovery and private-bundle maintenance.
for (extensions, proposals) in (1..=5)
.map(|id| (vec![id], vec![]))
.chain((1..=7).map(|id| (vec![], vec![id])))
{
let kp = key_package_with_advertised_capabilities(profile, &extensions, &proposals);
key_package_metadata(&kp).expect("valid signatures, identity, and lifetime");
let error = alice
.create_group(CreateGroupRequest {
name: "invalid-capabilities".into(),
description: String::new(),
members: vec![kp],
required_features: vec![],
app_components: vec![],
initial_admins: vec![],
})
.await
.expect_err("forbidden advertisement must fail before group creation");
assert!(
matches!(error, EngineError::Backend(ref message)
if message.contains("default capabilities must not be advertised")),
"{error:?}"
);
assert!(storage.list_groups().unwrap().is_empty());
}
let (group_id, result) = alice
.create_group(CreateGroupRequest {
name: "existing".into(),
description: String::new(),
members: vec![],
required_features: vec![],
app_components: vec![],
initial_admins: vec![],
})
.await
.unwrap();
if let SendResult::GroupCreated { pending, .. } = result {
alice.confirm_published(pending).await.unwrap();
}
let epoch = alice.epoch(&group_id).unwrap();
let members = alice.members(&group_id).unwrap();
for (extensions, proposals) in (1..=5)
.map(|id| (vec![id], vec![]))
.chain((1..=7).map(|id| (vec![], vec![id])))
{
let kp = key_package_with_advertised_capabilities(profile, &extensions, &proposals);
let error = alice
.send(SendIntent::Invite {
group_id: group_id.clone(),
key_packages: vec![kp],
initial_admins: vec![],
})
.await
.expect_err("forbidden advertisement must fail before an Add commit");
assert!(
matches!(error, EngineError::Backend(ref message)
if message.contains("default capabilities must not be advertised")),
"{error:?}"
);
assert_eq!(alice.epoch(&group_id).unwrap(), epoch);
assert_eq!(alice.members(&group_id).unwrap(), members);
}
// RFC 9420 section 7.2 requires unknown capability values to be ignored.
// A valid retry also proves rejection left no pending commit behind.
let kp = key_package_with_advertised_capabilities(profile, &[0x0a0a], &[0x0a0a]);
let result = alice
.send(SendIntent::Invite {
group_id: group_id.clone(),
key_packages: vec![kp],
initial_admins: vec![],
})
.await
.expect("unknown capabilities remain usable");
let SendResult::GroupEvolution { pending, .. } = result else {
panic!("expected invite")
};
alice.confirm_published(pending).await.unwrap();
assert_eq!(alice.members(&group_id).unwrap().len(), 2);
}
}

#[tokio::test]
async fn fresh_key_packages_omit_default_mls_capabilities() {
for profile in [ProtocolProfile::Current, ProtocolProfile::Legacy] {
Expand Down
12 changes: 9 additions & 3 deletions crates/marmot-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ separate cache reads, membership, provider response, profile hydration, and netw
queries or identities.

Group creation and invites still take pubkeys at the action boundary. The app canonicalizes and deduplicates the
requested roster, reuses current cached KeyPackages, and resolves cold members in bounded multi-author relay batches
before building the MLS add. Hosts may prewarm that same bounded composition lookup without reserving packages or
durably admitting strangers; the final mutation revalidates every package. New Nostr-routed groups generate
requested roster and fetches current KeyPackages in bounded multi-author relay batches before building the MLS add.
Cached packages remain useful for discovery, but cannot authorize an invitation or substitute for a failed relay
lookup. Hosts may prewarm that same bounded composition lookup without reserving packages or durably admitting
strangers; the final action reuses discovery routes but fetches packages again before the mutation validates them. This
also applies to another account on the same installation: its local package record is not an invitation shortcut,
and its published package must be reachable on relays. Relay freshness is not proof that the recipient still owns
private material; it avoids authorizing from a stale local copy. Each prewarm call requests a fresh readiness signal,
so hosts should debounce roster changes. The process-local prewarm cache retains only bounded relay metadata.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc drift missed in this pass: docs/marmot-architecture/invitation-recovery.md:7 still says recovery resolves fresh KeyPackages "bypassing the initial cache/prewarm shortcut". After this PR there is no cache/prewarm package shortcut to bypass -- the only remaining difference for CommitFresh is that it also skips route reuse.

New Nostr-routed groups generate
`marmot.transport.nostr.routing.v1` at creation, store the component bytes in
signed MLS app data, and project the decoded `nostr_group_id` plus relay list into group subscriptions and publish
targets.
Expand Down
10 changes: 2 additions & 8 deletions crates/marmot-app/src/app_telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ pub(crate) enum AppPerformanceOperation {
GroupCreateQueueWait,
GroupCreateKeyPackageLookup,
GroupMemberKeyPackagePrewarm,
GroupCreateKeyPackageCacheReuse,
GroupCreateKeyPackageNetworkResolution,
GroupCreateImagePreprocess,
GroupCreateImageUpload,
Expand Down Expand Up @@ -318,6 +317,7 @@ pub struct AppPerformanceSnapshot {
#[serde(default)]
pub group_member_key_package_prewarm: AppPerformanceOperationSnapshot,
#[serde(default)]
/// Retired counter retained for export/API compatibility; no new samples.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the doc comment sits after #[serde(default)]. Convention (and the rest of this struct) puts /// above the attributes.

pub group_create_key_package_cache_reuse: AppPerformanceOperationSnapshot,
#[serde(default)]
pub group_create_key_package_network_resolution: AppPerformanceOperationSnapshot,
Expand Down Expand Up @@ -813,11 +813,6 @@ impl AppPerformanceTelemetry {
.group_member_key_package_prewarm
.record(duration, success);
}
AppPerformanceOperation::GroupCreateKeyPackageCacheReuse => {
inner
.group_create_key_package_cache_reuse
.record(duration, success);
}
AppPerformanceOperation::GroupCreateKeyPackageNetworkResolution => {
inner
.group_create_key_package_network_resolution
Expand Down Expand Up @@ -1502,7 +1497,6 @@ mod tests {
AppPerformanceOperation::GroupCreateQueueWait,
AppPerformanceOperation::GroupCreateKeyPackageLookup,
AppPerformanceOperation::GroupMemberKeyPackagePrewarm,
AppPerformanceOperation::GroupCreateKeyPackageCacheReuse,
AppPerformanceOperation::GroupCreateKeyPackageNetworkResolution,
AppPerformanceOperation::GroupCreateImagePreprocess,
AppPerformanceOperation::GroupCreateImageUpload,
Expand All @@ -1520,11 +1514,11 @@ mod tests {
}

let snapshot = telemetry.snapshot();
assert_eq!(snapshot.group_create_key_package_cache_reuse.attempts, 0);
for stage in [
snapshot.group_create_queue_wait,
snapshot.group_create_key_package_lookup,
snapshot.group_member_key_package_prewarm,
snapshot.group_create_key_package_cache_reuse,
snapshot.group_create_key_package_network_resolution,
snapshot.group_create_image_preprocess,
snapshot.group_create_image_upload,
Expand Down
24 changes: 11 additions & 13 deletions crates/marmot-app/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1183,9 +1183,9 @@ impl AppClient {
Ok(self.runtime.publish_fresh_key_package().await?)
}

/// Resolve and cache the current composition roster without reserving or
/// consuming any KeyPackage. Group creation revalidates the cached bytes
/// and the MLS mutation boundary retains its ordinary validation.
/// Fetch current relay KeyPackages for the composition roster without

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documented create-time telemetry contract is now false and one metric is dead.

create_group_with_initial_source_and_optional_telemetry (line 1531-1540) still selects between GroupCreateKeyPackageCacheReuse and GroupCreateKeyPackageNetworkResolution on resolved.stats.network_resolved_members == 0. Since network_resolved_members is now always unique_members, CacheReuse can only ever fire for a zero-member create. So app_group_create_key_package_cache_reuse_{duration_ms,attempts,successes,failures} go permanently flat for real creates, and any host dashboard built on them silently reads zero rather than breaking.

docs/marmot-architecture/telemetry.md documents the opposite:

  • line 243: "includes either cache-only reuse or create-time relay resolution below",
  • line 245: "Successful create-time lookup when every canonical member was satisfied by revalidated local/directory state. A prewarm should shift the later Create wait into this bucket.",
  • line 259: "Captures local cached lookups plus relay directory fetches used to obtain invitee KeyPackages."

All three now describe behavior that cannot occur. Please either retire the CacheReuse operation with its snapshot fields, or keep it and update those rows — but the docs should not keep telling the next person to look for a prewarm win in a bucket that is structurally empty. This is the one item I would want resolved before merge rather than followed up.

/// reserving or consuming them. Group creation fetches again before the
/// MLS mutation; cached packages only inform discovery.
pub async fn prewarm_group_member_key_packages(
&self,
member_refs: &[&str],
Expand Down Expand Up @@ -1529,16 +1529,14 @@ impl AppClient {
key_packages.is_ok(),
);
let resolved = key_packages?;
record_app_performance(
telemetry,
if resolved.stats.network_resolved_members == 0 {
AppPerformanceOperation::GroupCreateKeyPackageCacheReuse
} else {
AppPerformanceOperation::GroupCreateKeyPackageNetworkResolution
},
key_package_elapsed,
true,
);
if resolved.stats.unique_members > 0 {
record_app_performance(
telemetry,
AppPerformanceOperation::GroupCreateKeyPackageNetworkResolution,
key_package_elapsed,
true,
);
}
let members = resolved.key_packages;
self.refresh_routing()?;
let nostr_routing = self.app.new_nostr_routing()?;
Expand Down
Loading
Loading