use anyhow::{Context, Result};
use mcpmesh_local_api::{OrgApproveResult, OrgCreateResult, OrgRevokeResult};
use mcpmesh_trust::keys::OrgRootKey;
use mcpmesh_trust::roster::sign::{mint_signed, sign, verify_device_binding};
use mcpmesh_trust::roster::{Roster, decode_b64u, decode_endpoint_id, encode_b64u, mutate};
use crate::control::DaemonState;
use crate::daemon::MeshState;
use crate::pairing;
use crate::roster::enroll::{JoinCode, OrgInviteCode};
use crate::util::{blocking, epoch_now_i64};
use super::roster_install::installed_roster_path;
pub(crate) const DEFAULT_EXPIRES_SECS: i64 = 90 * 86_400;
pub(crate) fn org_root_key_path(mesh: &MeshState) -> std::path::PathBuf {
mesh.config_path
.parent()
.map(|dir| dir.join("org-root.key"))
.unwrap_or_else(|| std::path::PathBuf::from("org-root.key"))
}
fn load_operator_roster(mesh: &MeshState) -> Result<(OrgRootKey, Roster)> {
let key_path = org_root_key_path(mesh);
anyhow::ensure!(
key_path.exists(),
"this node is not an org operator (no org root key); run org_create first"
);
let (root, _) = OrgRootKey::load_or_generate(&key_path)
.map_err(|e| anyhow::anyhow!("org root key error at {}: {e}", key_path.display()))?;
let roster_path = installed_roster_path(mesh);
let bytes = std::fs::read(&roster_path)
.with_context(|| format!("no installed roster at {}", roster_path.display()))?;
let roster: Roster = serde_json::from_slice(&bytes).context("parse installed roster")?;
Ok((root, roster))
}
async fn install_authored(
state: &DaemonState,
roster: &Roster,
org_root_pk: Option<String>,
) -> Result<mcpmesh_local_api::RosterInstallResult> {
let bytes = serde_json::to_vec(roster).context("serialize the authored roster")?;
let staged = super::roster_install::write_temp_roster(
&super::roster_install::roster_staging_dir(state.mesh_required()?),
&bytes,
)?;
let path = staged.path().to_string_lossy().into_owned();
let out = super::roster_install::install_roster(state, path, org_root_pk).await;
drop(staged);
out
}
pub(crate) async fn org_create(
state: &DaemonState,
name: String,
expires_secs: Option<i64>,
roster_url: Option<String>,
) -> Result<OrgCreateResult> {
let mesh = state.mesh_required()?;
anyhow::ensure!(!name.trim().is_empty(), "org_create: the org name is empty");
anyhow::ensure!(
!name.contains('/'),
"org_create: the org name must not contain '/'"
);
let expires_secs = expires_secs.unwrap_or(DEFAULT_EXPIRES_SECS);
anyhow::ensure!(
expires_secs > 0,
"org_create: expires_secs must be positive"
);
let key_path = org_root_key_path(mesh);
let (root, created) = blocking("org_create root key", {
let key_path = key_path.clone();
move || OrgRootKey::load_or_generate(&key_path)
})
.await?
.map_err(|e| anyhow::anyhow!("org root key error at {}: {e}", key_path.display()))?;
anyhow::ensure!(
created,
"this node already holds an org root key ({}); org_create is one-time per node",
key_path.display()
);
let now = epoch_now_i64();
let roster = mint_signed(
root.signing_key(),
mutate::empty_roster(&name, 1, now, now.saturating_add(expires_secs)),
);
let org_root_pk = encode_b64u(&root.public_bytes());
let installed = install_authored(state, &roster, Some(org_root_pk.clone())).await?;
if let Some(url) = &roster_url
&& let Err(e) = super::roster_install::set_roster_url(state, url.clone()).await
{
tracing::warn!(
%e,
"org created, but pinning the roster URL failed — set it with set_roster_url"
);
}
Ok(OrgCreateResult {
org_id: installed.org_id,
serial: installed.serial,
org_invite: OrgInviteCode {
org_id: name,
org_root_pk,
roster_url,
}
.encode(),
org_root_fingerprint: pairing::sas::fingerprint_words(&root.public_bytes()),
})
}
pub(crate) async fn org_approve(
state: &DaemonState,
join_code: String,
groups: Vec<String>,
user_id: Option<String>,
) -> Result<OrgApproveResult> {
let mesh = state.mesh_required()?;
let _authoring = mesh.org_author_lock.lock().await;
let (jc, _user_pk, _device_id, code_fp) = inspect_join_code(&join_code)?;
let (root, mut roster) = load_operator_roster(mesh)?;
let uid = user_id.unwrap_or_else(|| jc.requested_user_id.clone());
anyhow::ensure!(!uid.trim().is_empty(), "org_approve: the user_id is empty");
anyhow::ensure!(
!uid.contains('/'),
"org_approve: a user_id must not contain '/' (it would collide with the \
'<user_id>/<device>' revoke grammar); pass an explicit user_id to override the one the \
join code requested"
);
roster.serial += 1;
mutate::upsert_member(
&mut roster,
&uid,
&jc.display_name,
&jc.user_pk,
&groups,
&jc.device_endpoint_id,
&jc.device_label,
)
.map_err(|e| anyhow::anyhow!("roster mutation rejected: {e}"))?;
sign(root.signing_key(), &mut roster).map_err(|e| anyhow::anyhow!("sign roster: {e}"))?;
let installed = install_authored(state, &roster, None).await?; Ok(OrgApproveResult {
user_id: uid,
groups,
org_id: installed.org_id,
serial: installed.serial,
join_code_fingerprint: code_fp,
})
}
fn inspect_join_code(join_code: &str) -> Result<(JoinCode, [u8; 32], [u8; 32], String)> {
let jc = JoinCode::decode(join_code)?;
let user_pk = decode_endpoint_id(&jc.user_pk).context("join code has an invalid user_pk")?;
let device_id = decode_endpoint_id(&jc.device_endpoint_id)
.context("join code has an invalid device endpoint")?;
let sig = decode_b64u(&jc.binding_sig).context("join code has an invalid signature")?;
verify_device_binding(&user_pk, &device_id, &sig).map_err(|_| {
anyhow::anyhow!("join code device binding failed — the code is forged or corrupt")
})?;
let fingerprint = pairing::sas::join_code_fingerprint(&user_pk, &device_id);
Ok((jc, user_pk, device_id, fingerprint))
}
pub(crate) async fn org_join_code(
state: &DaemonState,
join_code: String,
) -> Result<mcpmesh_local_api::OrgJoinCodeResult> {
let _ = state.mesh_required()?;
let (jc, _user_pk, _device_id, fingerprint) = inspect_join_code(&join_code)?;
Ok(mcpmesh_local_api::OrgJoinCodeResult {
display_name: jc.display_name,
requested_user_id: jc.requested_user_id,
device_label: jc.device_label,
join_code_fingerprint: fingerprint,
})
}
pub(crate) async fn org_revoke(
state: &DaemonState,
target: String,
user_key: bool,
) -> Result<OrgRevokeResult> {
let mesh = state.mesh_required()?;
let _authoring = mesh.org_author_lock.lock().await;
anyhow::ensure!(!target.trim().is_empty(), "org_revoke: the target is empty");
anyhow::ensure!(
!(user_key && target.contains('/')),
"org_revoke: user_key rotates a PERSON's key, so the target must be a user_id, not \
'<user_id>/<device>'"
);
let (root, mut roster) = load_operator_roster(mesh)?;
roster.serial += 1;
let mode = if user_key {
mutate::remove_user(&mut roster, &target, false).map_err(|e| anyhow::anyhow!("{e}"))?;
"user-key-rotation"
} else if let Some((person, device)) = target.split_once('/') {
mutate::revoke_device(&mut roster, person, device).map_err(|e| anyhow::anyhow!("{e}"))?;
"device"
} else {
mutate::remove_user(&mut roster, &target, true).map_err(|e| anyhow::anyhow!("{e}"))?;
"person"
};
sign(root.signing_key(), &mut roster).map_err(|e| anyhow::anyhow!("sign roster: {e}"))?;
let installed = install_authored(state, &roster, None).await?;
Ok(OrgRevokeResult {
target,
mode: mode.to_string(),
org_id: installed.org_id,
serial: installed.serial,
severed: installed.severed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::testutil::hermetic_mesh;
use mcpmesh_trust::ed25519_dalek::SigningKey;
async fn operator_state(dir: &std::path::Path) -> DaemonState {
let config_path = dir.join("config.toml");
std::fs::write(&config_path, "").unwrap();
let mesh = hermetic_mesh(config_path).await;
DaemonState::with_mesh("test", mesh)
}
fn join_code_signed_by(
signer: &SigningKey,
claimed_user_pk: &[u8; 32],
device: &[u8; 32],
) -> String {
let sig = mcpmesh_trust::roster::sign::sign_device_binding(signer, device);
JoinCode {
display_name: "Alice".into(),
requested_user_id: "alice".into(),
user_pk: encode_b64u(claimed_user_pk),
device_endpoint_id: encode_b64u(device),
device_label: "laptop".into(),
binding_sig: encode_b64u(&sig),
}
.encode()
}
#[tokio::test(flavor = "multi_thread")]
async fn an_embedder_can_create_approve_and_revoke_over_the_control_seam() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
let created = org_create(&state, "acme".into(), Some(86_400), None)
.await
.expect("org_create mints the root and installs an empty roster");
assert_eq!(created.org_id, "acme");
assert_eq!(created.serial, 1, "a fresh org starts at serial 1");
assert!(
created.org_invite.starts_with("mcpmesh-org:"),
"the copyable invite must be the artifact a joiner pastes: {}",
created.org_invite
);
assert!(
!created.org_root_fingerprint.is_empty(),
"the fingerprint anchors every joiner's trust and must be shown to the operator"
);
let again = org_create(&state, "acme2".into(), None, None)
.await
.expect_err("a second org_create must be refused");
assert!(
format!("{again:#}").contains("one-time per node"),
"it must be refused BY THE ONE-TIME GUARD, naming the existing key. Asserting only \
`is_err()` here proved nothing: without the guard the second create still fails, on \
the roster serial check, for a reason that has nothing to do with the orphaned root \
— and the orphaning would already have happened. Got: {again:#}"
);
let alice_key = SigningKey::from_bytes(&[9u8; 32]);
let alice_pk = alice_key.verifying_key().to_bytes();
let device = [42u8; 32];
let code = join_code_signed_by(&alice_key, &alice_pk, &device);
let approved = org_approve(&state, code, vec![], None)
.await
.expect("a well-formed join code is approved");
assert_eq!(
approved.user_id, "alice",
"the requested user_id is accepted by default"
);
assert_eq!(approved.serial, 2, "an approval bumps the serial");
assert!(
!approved.join_code_fingerprint.is_empty(),
"the fingerprint is the ONLY thing binding this code to a person, and only a human can \
check it — so it must be returned, not merely computed"
);
let mesh = state.mesh_required().unwrap();
let members = crate::daemon::roster_members(mesh);
let alice = members
.users
.iter()
.find(|u| u.user_id == "alice")
.expect("the approved member appears in the membership read");
assert_eq!(
alice.display_name, "Alice",
"the join code's display name is carried through"
);
assert_eq!(alice.devices.len(), 1);
assert_eq!(alice.devices[0].label, "laptop");
let revoked = org_revoke(&state, "alice".into(), false)
.await
.expect("revoke installs");
assert_eq!(
revoked.mode, "person",
"a bare user_id is the departing-person grammar"
);
assert_eq!(revoked.serial, 3);
let after = crate::daemon::roster_members(mesh);
assert!(
after.users.is_empty(),
"the revoked person must be gone from the membership read: {:?}",
after.users
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_key_rotation_leaves_the_devices_usable_and_a_departure_does_not() {
let alice_key = SigningKey::from_bytes(&[9u8; 32]);
let alice_pk = alice_key.verifying_key().to_bytes();
let device = [42u8; 32];
let d1 = tempfile::tempdir().unwrap();
let s1 = operator_state(d1.path()).await;
org_create(&s1, "acme".into(), None, None).await.unwrap();
org_approve(
&s1,
join_code_signed_by(&alice_key, &alice_pk, &device),
vec![],
None,
)
.await
.unwrap();
let rot = org_revoke(&s1, "alice".into(), true).await.unwrap();
assert_eq!(rot.mode, "user-key-rotation");
let m1 = s1.mesh_required().unwrap();
assert!(
!m1.roster.view().unwrap().is_revoked(&device),
"a ROTATION must leave the device un-revoked — the same hardware re-enrolls under a \
fresh user key, and revoking it here locks the person out of their own machine"
);
let d2 = tempfile::tempdir().unwrap();
let s2 = operator_state(d2.path()).await;
org_create(&s2, "acme".into(), None, None).await.unwrap();
org_approve(
&s2,
join_code_signed_by(&alice_key, &alice_pk, &device),
vec![],
None,
)
.await
.unwrap();
let dep = org_revoke(&s2, "alice".into(), false).await.unwrap();
assert_eq!(dep.mode, "person");
let m2 = s2.mesh_required().unwrap();
assert!(
m2.roster.view().unwrap().is_revoked(&device),
"a DEPARTURE must revoke the device — otherwise the person's hardware stays admissible \
after they are removed"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_forged_binding_is_refused_before_any_operator_state_is_read() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
let mallory = SigningKey::from_bytes(&[7u8; 32]);
let alice_pk = SigningKey::from_bytes(&[9u8; 32])
.verifying_key()
.to_bytes();
let code = join_code_signed_by(&mallory, &alice_pk, &[42u8; 32]);
let err = org_approve(&state, code, vec![], None).await.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("device binding failed"),
"the forged binding must be the failure — not 'not an org operator', which would mean \
the check runs after the roster load: {msg}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_user_id_containing_a_slash_cannot_hijack_the_revoke_grammar() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
org_create(&state, "acme".into(), None, None).await.unwrap();
let alice = SigningKey::from_bytes(&[9u8; 32]);
let alice_pk = alice.verifying_key().to_bytes();
org_approve(
&state,
join_code_signed_by(&alice, &alice_pk, &[0xA1; 32]),
vec![],
None,
)
.await
.expect("the real alice is approved");
let mallory = SigningKey::from_bytes(&[7u8; 32]);
let mallory_pk = mallory.verifying_key().to_bytes();
let hostile = JoinCode {
display_name: "Mallory".into(),
requested_user_id: "alice/laptop".into(),
user_pk: encode_b64u(&mallory_pk),
device_endpoint_id: encode_b64u(&[0xB1; 32]),
device_label: "phone".into(),
binding_sig: encode_b64u(&mcpmesh_trust::roster::sign::sign_device_binding(
&mallory,
&[0xB1; 32],
)),
}
.encode();
let err = org_approve(&state, hostile.clone(), vec![], None)
.await
.expect_err("a user_id carrying '/' must be refused");
assert!(
format!("{err:#}").contains("must not contain '/'"),
"refused for the RIGHT reason — the grammar collision, not some incidental failure: \
{err:#}"
);
org_approve(&state, hostile, vec![], Some("mallory".into()))
.await
.expect("an explicit, non-colliding user_id is accepted");
let out = org_revoke(&state, "alice/laptop".into(), false)
.await
.unwrap();
assert_eq!(out.mode, "device");
let mesh = state.mesh_required().unwrap();
let members = crate::daemon::roster_members(mesh);
assert!(
members.users.iter().any(|u| u.user_id == "mallory"),
"revoking alice's laptop must not have touched the other member"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_rotation_refuses_a_device_shaped_target() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
org_create(&state, "acme".into(), None, None).await.unwrap();
let alice = SigningKey::from_bytes(&[9u8; 32]);
org_approve(
&state,
join_code_signed_by(&alice, &alice.verifying_key().to_bytes(), &[42u8; 32]),
vec![],
None,
)
.await
.unwrap();
let err = org_revoke(&state, "alice/laptop".into(), true)
.await
.expect_err("a rotation with a device target must be refused");
assert!(
format!("{err:#}").contains("must be a user_id"),
"the refusal must name the grammar mistake: {err:#}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_join_code_can_be_inspected_before_it_is_approved() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
org_create(&state, "acme".into(), None, None).await.unwrap();
let alice = SigningKey::from_bytes(&[9u8; 32]);
let code = join_code_signed_by(&alice, &alice.verifying_key().to_bytes(), &[42u8; 32]);
let seen = org_join_code(&state, code.clone())
.await
.expect("a well-formed code inspects");
assert_eq!(seen.display_name, "Alice");
assert_eq!(seen.requested_user_id, "alice");
assert_eq!(seen.device_label, "laptop");
assert!(!seen.join_code_fingerprint.is_empty());
let mesh = state.mesh_required().unwrap();
assert_eq!(
mesh.roster.view().unwrap().serial(),
1,
"inspection must not bump the serial"
);
assert!(crate::daemon::roster_members(mesh).users.is_empty());
let approved = org_approve(&state, code, vec![], None).await.unwrap();
assert_eq!(
approved.join_code_fingerprint, seen.join_code_fingerprint,
"the inspected and approved fingerprints must be identical — otherwise the operator's \
out-of-band check certifies a different code than the one that lands"
);
let mallory = SigningKey::from_bytes(&[7u8; 32]);
let forged = join_code_signed_by(&mallory, &alice.verifying_key().to_bytes(), &[43u8; 32]);
let err = org_join_code(&state, forged).await.unwrap_err();
assert!(
format!("{err:#}").contains("device binding failed"),
"{err:#}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn authoring_on_a_non_operator_node_refuses() {
let dir = tempfile::tempdir().unwrap();
let state = operator_state(dir.path()).await;
let err = org_revoke(&state, "alice".into(), false).await.unwrap_err();
assert!(
format!("{err:#}").contains("not an org operator"),
"revoke on a non-operator must say so: {err:#}"
);
let alice = SigningKey::from_bytes(&[9u8; 32]);
let code = join_code_signed_by(&alice, &alice.verifying_key().to_bytes(), &[42u8; 32]);
let err = org_approve(&state, code, vec![], None).await.unwrap_err();
assert!(
format!("{err:#}").contains("not an org operator"),
"approve on a non-operator must say so once the code itself is valid: {err:#}"
);
}
}