use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_sdk::org::{
CapabilityAuthorityId, DispatcherScope, GrantRights, GrantTargetScope, OrgCapabilityGrant,
OrgDispatcherGrant, OrgId, OrgKeypair, OrgMembershipCert, OrgRevocationBundle,
ORG_CERT_TTL_SECS_RECOMMENDED,
};
use serde::{Deserialize, Serialize};
use crate::commands::identity::{
enforce_strict_permissions, now_iso8601, parse_entity_hex, read_secret_key_file,
};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
use crate::secret::{zeroize_slice, zeroize_string, ScrubbedBytes, ScrubbedString};
pub(crate) const ORG_FILE_VERSION: u32 = 1;
const GRANT_TTL_SECS_DEFAULT: u64 = 7 * 24 * 60 * 60;
#[derive(Subcommand, Debug)]
pub enum OrgCommand {
Keygen(KeygenArgs),
IssueCert(IssueCertArgs),
IssueFloors(IssueFloorsArgs),
GrantDispatcher(GrantDispatcherArgs),
GrantCapability(GrantCapabilityArgs),
AudienceKeygen(OrgAudienceKeygenArgs),
Approve(OrgApproveArgs),
Invite(OrgInviteArgs),
Join(OrgJoinArgs),
Remove(OrgRemoveArgs),
Leave(OrgLeaveArgs),
Members(OrgMembersArgs),
}
#[derive(Args, Debug)]
pub struct OrgMembersArgs {
pub org: String,
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
#[arg(long = "verifier", value_name = "NODE", requires = "org_key")]
pub verifiers: Vec<String>,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: Option<PathBuf>,
#[arg(long, value_name = "DURATION", default_value = "10s", value_parser = crate::humantime::parse_duration)]
pub wait: std::time::Duration,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct OrgLeaveArgs {
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
#[arg(long, value_name = "DURATION", default_value = "15s", value_parser = crate::humantime::parse_duration)]
pub wait: std::time::Duration,
}
#[derive(Args, Debug)]
pub struct OrgRemoveArgs {
pub member: String,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long, value_name = "N")]
pub minimum_generation: u32,
#[arg(long = "verifier", value_name = "NODE", required = true)]
pub verifiers: Vec<String>,
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
#[arg(long, value_name = "DURATION", default_value = "10s", value_parser = crate::humantime::parse_duration)]
pub wait: std::time::Duration,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct OrgAudienceKeygenArgs {
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long, value_name = "PATH")]
pub out: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct OrgApproveArgs {
pub offer_id: String,
#[arg(long, value_name = "ENTITY")]
pub subject: String,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long, default_value_t = 0)]
pub generation: u32,
#[arg(long, value_name = "PATH")]
pub audience: Option<PathBuf>,
#[arg(long = "ttl-secs", default_value_t = ORG_CERT_TTL_SECS_RECOMMENDED)]
pub ttl_secs: u64,
#[arg(long)]
pub insecure_permissions: bool,
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct OrgInviteArgs {
pub org: String,
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
#[arg(long, value_name = "DURATION", value_parser = crate::humantime::parse_duration)]
pub ttl: Option<std::time::Duration>,
#[arg(long = "for", value_name = "ENTITY")]
pub for_subject: Option<String>,
#[arg(long, value_name = "PATH")]
pub out: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct OrgJoinArgs {
pub token: String,
#[arg(long, value_name = "DIR")]
pub state_dir: Option<PathBuf>,
#[arg(long)]
pub yes: bool,
}
#[derive(Args, Debug)]
pub struct KeygenArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long = "accept-windows-dacl")]
pub accept_windows_dacl: bool,
}
#[derive(Args, Debug)]
pub struct IssueCertArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long)]
pub member: String,
#[arg(long, default_value_t = 0)]
pub generation: u32,
#[arg(long = "ttl-secs", default_value_t = ORG_CERT_TTL_SECS_RECOMMENDED)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct IssueFloorsArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long = "floor", value_name = "MEMBER=GEN", required = true)]
pub floors: Vec<String>,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct GrantDispatcherArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long)]
pub dispatcher: String,
#[arg(long)]
pub capability: Option<String>,
#[arg(long = "any-capability")]
pub any_capability: bool,
#[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct GrantCapabilityArgs {
#[arg(long)]
pub inspect_target: bool,
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long = "grantee-org")]
pub grantee_org: String,
#[arg(long)]
pub capability: String,
#[arg(long)]
pub invoke: bool,
#[arg(long)]
pub discover: bool,
#[arg(long = "target-node")]
pub target_node: Option<String>,
#[arg(long = "target-any-owned-by")]
pub target_any_owned_by: Option<String>,
#[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long = "audience-out", value_name = "PATH")]
pub audience_out: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
#[arg(long = "accept-windows-dacl")]
pub accept_windows_dacl: bool,
}
pub async fn run(
cmd: OrgCommand,
output: Option<OutputFormat>,
config_path: Option<&Path>,
profile_name: &str,
) -> Result<(), CliError> {
let selection = match &cmd {
OrgCommand::Keygen(args) if args.inspect_target => {
let profile = crate::context::resolve_profile(config_path, profile_name).await?;
let mut view = crate::target::TargetInspection::local(&profile, "offline");
view.unavailable_identity(
"execution generates a new org root key; its public identity is not yet available",
);
view.provenance("identity", "runtime");
if let Some(path) = &args.out {
view.destination = Some(path.clone());
view.provenance("destination", "flag");
} else {
view.destination_pattern = Some(
default_org_key_dir()
.ok_or_else(|| {
invalid_args("cannot resolve the platform config directory; pass --out")
})?
.join("org-<generated-org-id-prefix>.toml"),
);
view.provenance("destination", "runtime");
}
return view.emit(output);
}
OrgCommand::IssueCert(a) if a.inspect_target => {
Some((&a.org_key, a.insecure_permissions, &a.out, None))
}
OrgCommand::IssueFloors(a) if a.inspect_target => {
Some((&a.org_key, a.insecure_permissions, &a.out, None))
}
OrgCommand::GrantDispatcher(a) if a.inspect_target => {
refuse_force(a.force)?;
Some((&a.org_key, a.insecure_permissions, &a.out, None))
}
OrgCommand::GrantCapability(a) if a.inspect_target => {
refuse_force(a.force)?;
validate_audience_output(a.discover, a.audience_out.as_deref())?;
Some((
&a.org_key,
a.insecure_permissions,
&a.out,
a.audience_out.as_ref(),
))
}
_ => None,
};
if let Some((key, insecure, destination, audience_destination)) = selection {
let signer = load_org_key(key, insecure).await?;
let profile = crate::context::resolve_profile(config_path, profile_name).await?;
let mut target = crate::target::TargetInspection::local(&profile, "offline");
target.configured_identity(signer.org_id().as_bytes());
target.source = Some(key.clone());
target.destination = Some(destination.clone());
for field in ["identity", "source", "destination"] {
target.provenance(field, "flag");
}
if audience_destination.is_some() {
target.provenance("audience_destination", "flag");
}
return emit_value(
OutputFormat::resolve_oneshot(output),
&OrgInspection {
target,
audience_destination,
},
)
.map_err(|e| generic(format!("write inspection: {e}")));
}
match cmd {
OrgCommand::Keygen(args) => run_keygen(args, output).await,
OrgCommand::IssueCert(args) => run_issue_cert(args, output).await,
OrgCommand::IssueFloors(args) => run_issue_floors(args, output).await,
OrgCommand::GrantDispatcher(args) => run_grant_dispatcher(args, output).await,
OrgCommand::GrantCapability(args) => run_grant_capability(args, output).await,
OrgCommand::AudienceKeygen(args) => run_audience_keygen(args, output).await,
OrgCommand::Approve(args) => run_approve(args, output, profile_name).await,
OrgCommand::Invite(args) => {
super::enrollment::run_invite(
super::enrollment::InviteCommand::Create(super::enrollment::CreateArgs {
state_dir: args.state_dir,
ttl: args.ttl,
require_approval: true,
for_subject: args.for_subject,
out: args.out,
addr: None,
subnet: None,
subnet_rights: None,
org: Some(args.org),
channel: None,
channel_rights: None,
standalone: true,
}),
output,
profile_name,
)
.await
}
OrgCommand::Join(args) => run_org_join(args, output, profile_name).await,
OrgCommand::Remove(args) => run_remove(args, output, profile_name).await,
OrgCommand::Members(args) => {
let org = super::enrollment::parse_org_id(&args.org).map_err(invalid_args)?;
let remote = match (&args.org_key, args.verifiers.is_empty()) {
(Some(key), false) => {
let keypair = load_org_key(key, args.insecure_permissions).await?;
if keypair.org_id() != org {
return Err(invalid_args("--org-key is the root of a different org"));
}
let root = net::adapter::net::identity::EntityKeypair::from_bytes(
*keypair.secret_bytes(),
);
drop(keypair);
Some(super::lifecycle::RemoteMembers {
root,
authority: None,
verifiers: args.verifiers.clone(),
wait: args.wait,
})
}
_ => None,
};
super::lifecycle::run_members(
"org",
hex::encode(org.0),
args.state_dir,
remote,
output,
profile_name,
)
.await
}
OrgCommand::Leave(args) => {
super::lifecycle::run_org_leave(args.state_dir, args.wait, output, profile_name).await
}
}
}
async fn run_remove(
args: OrgRemoveArgs,
output: Option<OutputFormat>,
profile_name: &str,
) -> Result<(), CliError> {
use net_sdk::org::floors::{OrgFloorAttestation, OrgFloorOutcome, OrgFloorRequest};
let member = parse_entity_hex(&args.member)?;
if args.minimum_generation == 0 {
return Err(invalid_args(
"--minimum-generation 0 revokes nothing (a floor n revokes certificates below n)",
));
}
let node_dir = super::lifecycle::state_dir(args.state_dir.clone(), profile_name)?
.join(super::lifecycle::NODE_SUBDIR);
let fmt = OutputFormat::resolve_oneshot(output);
if args.dry_run {
return emit_value(
fmt,
&serde_json::json!({
"dry_run": true,
"member": hex::encode(member.as_bytes()),
"minimum_generation": args.minimum_generation,
"verifiers": args.verifiers,
"effect": "none: nothing was signed or sent",
}),
)
.map_err(|e| generic(format!("write result: {e}")));
}
let mut targets = Vec::new();
for v in &args.verifiers {
if v == "self" {
let (_, status) =
super::lifecycle::control_call(&node_dir, serde_json::json!({ "op": "status" }))
.await
.map_err(|e| {
crate::error::connection_failure(format!("operator node: {e:?}"))
})?;
let entity =
parse_entity_hex(status["node"]["entity_id"].as_str().unwrap_or_default())?;
targets.push((entity, None));
} else {
let contact = super::subnet::parse_verifier(v)?;
targets.push((contact.entity.clone(), Some(contact)));
}
}
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let mut floors = BTreeMap::new();
floors.insert(member.clone(), args.minimum_generation);
let bundle = OrgRevocationBundle::try_issue(&keypair, &floors)
.map_err(|e| invalid_args(format!("floor: {e}")))?;
let org = hex::encode(keypair.org_id().as_bytes());
drop(keypair);
let mut rows = Vec::with_capacity(targets.len());
let mut applied = 0usize;
for (verifier, contact) in &targets {
let request = OrgFloorRequest::new(&bundle, verifier.clone()).map_err(generic)?;
let mut call = serde_json::json!({
"op": "org_floor_forward",
"request": hex::encode(request.to_bytes()),
"wait_ms": args.wait.as_millis() as u64,
});
if let Some(c) = contact {
call["addr"] = serde_json::json!(c.addr.to_string());
call["noise_pubkey"] = serde_json::json!(hex::encode(c.noise_pubkey));
}
let entity = hex::encode(verifier.as_bytes());
let row = match super::lifecycle::control_call_within(
&node_dir,
call,
args.wait + std::time::Duration::from_secs(5),
)
.await
{
Err(e) => serde_json::json!({
"verifier": entity, "state": "no_answer", "detail": format!("operator node: {e:?}"),
}),
Ok((_, reply)) => match reply["attestation"].as_str() {
Some(hex_a) => match hex::decode(hex_a)
.map_err(|e| e.to_string())
.and_then(|b| OrgFloorAttestation::from_bytes(&b))
.and_then(|a| a.verify_for(&request).map(|()| a))
{
Ok(a) => {
let floor = a.floor_of(&member);
let done = a.outcome == OrgFloorOutcome::Applied
&& floor.is_some_and(|f| f >= args.minimum_generation);
if done {
applied += 1;
}
serde_json::json!({
"verifier": entity,
"state": if done { "applied" } else { a.outcome.as_str() },
"floor": floor,
})
}
Err(e) => serde_json::json!({
"verifier": entity, "state": "bad_attestation", "detail": e,
}),
},
None => serde_json::json!({
"verifier": entity,
"state": if reply["refused"].is_string() { "refused" } else { "no_answer" },
"detail": reply["refused"]
.as_str()
.or_else(|| reply["no_answer"].as_str())
.or_else(|| reply["error"].as_str()),
}),
},
};
rows.push(row);
}
emit_value(
fmt,
&serde_json::json!({
"org": org,
"member": hex::encode(member.as_bytes()),
"minimum_generation": args.minimum_generation,
"verifiers": rows,
"applied": applied,
"pending": targets.len() - applied,
"complete": applied == targets.len(),
"scope": "membership only: the member's transport (the mesh PSK) and any independently granted access are unaffected; nodes not named here were not asked",
}),
)
.map_err(|e| generic(format!("write result: {e}")))
}
async fn run_approve(
args: OrgApproveArgs,
output: Option<OutputFormat>,
profile_name: &str,
) -> Result<(), CliError> {
let expected = parse_entity_hex(&args.subject)?;
let pending = super::enrollment::node_request(
args.state_dir.clone(),
profile_name,
serde_json::json!({ "op": "invite_org_pending", "offer_id": args.offer_id }),
)
.await?;
let claimed = parse_entity_hex(pending["subject"].as_str().unwrap_or_default())?;
if claimed != expected {
return Err(invalid_args(format!(
"the pending claim is from {}, not the --subject you named",
pending["subject"].as_str().unwrap_or_default()
)));
}
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let offered = pending["org"].as_str().unwrap_or_default();
if hex::encode(keypair.org_id().as_bytes()) != offered {
return Err(invalid_args(format!(
"--org-key is the root of org {}, but the invite offers org {offered}",
hex::encode(keypair.org_id().as_bytes())
)));
}
let cert = OrgMembershipCert::try_issue(&keypair, claimed, args.generation, args.ttl_secs)
.map_err(|e| invalid_args(format!("membership certificate: {e}")))?;
let audience = match &args.audience {
Some(path) => {
let audience = load_org_audience(path, args.insecure_permissions).await?;
if audience.owner_org != keypair.org_id() {
return Err(invalid_args(format!(
"{} is the audience of another org",
path.display()
)));
}
Some(ScrubbedString::new(hex::encode(audience.encode_config())))
}
None => None,
};
drop(keypair);
let reply = super::enrollment::node_request(
args.state_dir,
profile_name,
serde_json::json!({
"op": "invite_org_approve",
"offer_id": args.offer_id,
"subject": hex::encode(expected.as_bytes()),
"cert": hex::encode(cert.to_bytes()),
"audience": audience.as_ref().map(|a| a.as_str()),
}),
)
.await?;
emit_value(OutputFormat::resolve_oneshot(output), &reply)
.map_err(|e| generic(format!("write result: {e}")))
}
async fn run_audience_keygen(
args: OrgAudienceKeygenArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
if args.out.exists() {
return Err(invalid_args(format!(
"{} already exists",
args.out.display()
)));
}
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let audience = net::adapter::net::behavior::org_authority::OwnerAudienceCredential::generate(
keypair.org_id(),
);
drop(keypair);
let encoded = ScrubbedBytes::new(audience.encode_config().to_vec());
let tmp = args.out.with_extension("tmp-netmesh-audience");
crate::commands::identity::write_identity_atomically(&tmp, &args.out, encoded.as_slice())
.await?;
emit_value(
OutputFormat::resolve_oneshot(output),
&serde_json::json!({
"org": hex::encode(audience.owner_org.as_bytes()),
"audience_handle": hex::encode(audience.audience_handle),
"file": args.out.display().to_string(),
}),
)
.map_err(|e| generic(format!("write result: {e}")))
}
pub(crate) async fn load_org_audience(
path: &Path,
insecure_permissions: bool,
) -> Result<net::adapter::net::behavior::org_authority::OwnerAudienceCredential, CliError> {
use net::adapter::net::behavior::org_authority::OwnerAudienceCredential;
use std::io::Read as _;
let owned = path.to_path_buf();
let read = tokio::task::spawn_blocking(move || {
let mut file =
net::adapter::net::secret_file::open_secret_file(&owned, insecure_permissions)
.map_err(|e| e.to_string())?;
let mut bytes = Vec::with_capacity(OwnerAudienceCredential::ENCODED_SIZE + 1);
(&mut file)
.take(OwnerAudienceCredential::ENCODED_SIZE as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
Ok::<_, String>(ScrubbedBytes::new(bytes))
})
.await
.map_err(|e| generic(format!("org audience {}: {e}", path.display())))?
.map_err(|e| {
invalid_args(format!(
"org audience {}: {e}; or pass --insecure-permissions to override",
path.display()
))
})?;
if read.as_slice().len() != OwnerAudienceCredential::ENCODED_SIZE {
return Err(invalid_args(format!(
"org audience {} is not an org audience file",
path.display()
)));
}
OwnerAudienceCredential::decode_config(read.as_slice())
.map_err(|e| invalid_args(format!("org audience {}: {e}", path.display())))
}
const ORG_JOIN_CONTROL_WAIT: std::time::Duration = std::time::Duration::from_secs(28);
async fn run_org_join(
args: OrgJoinArgs,
output: Option<OutputFormat>,
profile_name: &str,
) -> Result<(), CliError> {
use net_sdk::enrollment::standalone::is_standalone_org;
let from_stdin = args.token == "-";
if from_stdin && !args.yes {
return Err(invalid_args(
"reading the link from stdin needs --yes (stdin cannot also answer the prompt)",
));
}
let token = if from_stdin {
use tokio::io::AsyncReadExt as _;
let mut buf = String::new();
tokio::io::stdin()
.take(4096)
.read_to_string(&mut buf)
.await
.map_err(|e| generic(format!("read link from stdin: {e}")))?;
ScrubbedString::new(buf.trim().to_string())
} else {
ScrubbedString::new(args.token)
};
let invite = net_sdk::enrollment::invite::MembershipInvite::decode(token.as_str())
.map_err(|e| invalid_args(format!("not a valid link: {e}")))?;
let offer = match invite.org() {
Some(offer) if is_standalone_org(&invite) => *offer,
_ => {
return Err(invalid_args(
"not a standalone org link (a mesh invite is redeemed with `net-mesh join`)",
))
}
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now >= invite.policy().expires_at() {
return Err(invalid_args("this link has expired; ask for a new one"));
}
eprintln!(
"Joining org {} under issuer {}\n membership only (no dispatcher or capability right); \
the operator approves it with the org root",
hex::encode(offer.org.as_bytes()),
invite.issuer_fingerprint(),
);
let tty = {
use std::io::IsTerminal as _;
std::io::stdin().is_terminal() && !from_stdin
};
let yes = args.yes;
tokio::task::spawn_blocking(move || {
super::ice::check_confirm_gate(tty, yes, || {
use std::io::{BufRead as _, Write as _};
let mut err = std::io::stderr();
write!(
err,
"Confirm the org and issuer are the ones you expect. Type YES to join: "
)
.and_then(|()| err.flush())
.map_err(|e| generic(format!("prompt: {e}")))?;
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| generic(format!("prompt: {e}")))?;
Ok(line.trim() == "YES")
})
})
.await
.map_err(|e| generic(format!("confirmation task failed: {e}")))??;
let node_dir = super::lifecycle::state_dir(args.state_dir, profile_name)?
.join(super::lifecycle::NODE_SUBDIR);
let (_, reply) = super::lifecycle::control_call_within(
&node_dir,
serde_json::json!({ "op": "org_join", "token": token.as_str() }),
ORG_JOIN_CONTROL_WAIT,
)
.await
.map_err(|e| {
crate::error::connection_failure(format!(
"this device's node is not reachable ({e:?}); `org join` runs through a running \
`net-mesh up`"
))
})?;
if let Some(e) = reply["error"].as_str() {
return Err(generic(e.to_string()));
}
emit_value(OutputFormat::resolve_oneshot(output), &reply)
.map_err(|e| generic(format!("write result: {e}")))
}
#[derive(Serialize)]
struct OrgInspection<'a> {
#[serde(flatten)]
target: crate::target::TargetInspection,
#[serde(skip_serializing_if = "Option::is_none")]
audience_destination: Option<&'a PathBuf>,
}
fn validate_audience_output(discover: bool, output: Option<&Path>) -> Result<(), CliError> {
match (discover, output) {
(true, None) => Err(invalid_args(
"--discover requires --audience-out <PATH> (where to write the minted audience secret)",
)),
(false, Some(_)) => Err(invalid_args(
"--audience-out is only valid with --discover (no secret is minted otherwise)",
)),
_ => Ok(()),
}
}
async fn run_keygen(args: KeygenArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let keypair = OrgKeypair::generate();
let org_id_hex = hex::encode(keypair.org_id().as_bytes());
let path = match args.out {
Some(explicit) => explicit,
None => default_org_key_path(&org_id_hex).ok_or_else(|| {
invalid_args(
"cannot resolve the platform config directory, and refusing to fall back to \
the working directory — this file holds the ORG ROOT SEED. Pass an explicit \
--out."
.to_string(),
)
})?,
};
refuse_existing(&path, args.force).await?;
if args.force {
refuse_replacing_foreign_seed(&path, SeedArtifact::OrgKey).await?;
}
let file = OrgKeyFile {
org_id_hex: org_id_hex.clone(),
seed_hex: hex::encode(keypair.secret_bytes()),
created_at: now_iso8601(),
note: args.note.clone(),
};
let toml_text = ScrubbedString::new(
toml::to_string_pretty(&file)
.map_err(|e| generic(format!("failed to serialize org key TOML: {e}")))?,
);
let tmp = stage_beside(&path, toml_text.as_bytes(), true).await?;
if args.force {
publish_staged_replace(&tmp, &path).await?;
} else {
publish_staged(&tmp, &path).await?;
}
enforce_strict_permissions(&path).await?;
warn_secret_permissions(&path, args.accept_windows_dacl);
let summary = OrgKeySummary {
path: path.display().to_string(),
org_id_hex,
created_at: file.created_at.clone(),
note: file.note.clone(),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_cert(args: IssueCertArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let member = parse_entity_hex(&args.member)?;
let cert =
OrgMembershipCert::try_issue(&keypair, member.clone(), args.generation, args.ttl_secs)
.map_err(|e| invalid_args(format!("issue-cert: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
refuse_existing(&args.out, args.force).await?;
let json = serialize_json(&OrgCertFile {
version: ORG_FILE_VERSION,
cert: cert.clone(),
})?;
publish_json_artifact(&args.out, &json, args.force).await?;
let summary = IssueCertOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(cert.org_id.as_bytes()),
member_hex: hex::encode(member.as_bytes()),
generation: cert.generation,
not_before: cert.not_before,
not_after: cert.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_floors(
args: IssueFloorsArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let mut floors = BTreeMap::new();
for raw in &args.floors {
let (member_raw, gen_raw) = raw.split_once('=').ok_or_else(|| {
invalid_args(format!("--floor '{raw}' must be <member-hex>=<generation>"))
})?;
let member = parse_entity_hex(member_raw)?;
let generation: u32 = gen_raw
.parse()
.map_err(|e| invalid_args(format!("--floor '{raw}': generation must be a u32: {e}")))?;
if floors.insert(member, generation).is_some() {
return Err(invalid_args(format!(
"--floor lists member {member_raw} more than once"
)));
}
}
let bundle = OrgRevocationBundle::try_issue(&keypair, &floors)
.map_err(|e| invalid_args(format!("issue-floors: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
refuse_existing(&args.out, args.force).await?;
let json = serialize_json(&OrgFloorsFile {
version: ORG_FILE_VERSION,
bundle: bundle.clone(),
})?;
publish_json_artifact(&args.out, &json, args.force).await?;
let summary = IssueFloorsOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(bundle.org_id.as_bytes()),
floors: bundle.floors().len(),
issued_at: bundle.issued_at,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_grant_dispatcher(
args: GrantDispatcherArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
refuse_force(args.force)?;
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let dispatcher = parse_entity_hex(&args.dispatcher)?;
let (scope, capability_label) = match (&args.capability, args.any_capability) {
(Some(tag), false) => (
DispatcherScope::Exact(CapabilityAuthorityId::for_tag(tag)),
tag.clone(),
),
(None, true) => (DispatcherScope::Any, "any".to_string()),
(Some(_), true) => {
return Err(invalid_args(
"--capability and --any-capability are mutually exclusive",
))
}
(None, false) => {
return Err(invalid_args(
"one of --capability <tag> or --any-capability is required",
))
}
};
let grant = OrgDispatcherGrant::try_issue(&keypair, dispatcher.clone(), scope, args.ttl_secs)
.map_err(|e| invalid_args(format!("grant-dispatcher: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
let json = serialize_json(&OrgDispatcherGrantFile {
version: ORG_FILE_VERSION,
grant: grant.clone(),
})?;
let tmp = stage_beside(&args.out, &json, false).await?;
publish_staged(&tmp, &args.out).await?;
let summary = GrantDispatcherOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(grant.org_id.as_bytes()),
dispatcher_hex: hex::encode(dispatcher.as_bytes()),
capability: capability_label,
not_before: grant.not_before,
not_after: grant.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_grant_capability(
args: GrantCapabilityArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
refuse_force(args.force)?;
let issuer = load_org_key(&args.org_key, args.insecure_permissions).await?;
let grantee_org = parse_org_hex(&args.grantee_org)?;
let capability = CapabilityAuthorityId::for_tag(&args.capability);
let rights = match (args.invoke, args.discover) {
(false, false) => {
return Err(invalid_args(
"at least one of --invoke or --discover is required",
))
}
(true, false) => GrantRights::INVOKE,
(false, true) => GrantRights::DISCOVER,
(true, true) => GrantRights::INVOKE.union(GrantRights::DISCOVER),
};
let mut rights_labels = Vec::new();
if args.invoke {
rights_labels.push("invoke");
}
if args.discover {
rights_labels.push("discover");
}
validate_audience_output(args.discover, args.audience_out.as_deref())?;
let mut alias_paths: Vec<(&str, &Path)> = vec![
("--org-key", args.org_key.as_path()),
("--out", args.out.as_path()),
];
if let Some(audience_out) = &args.audience_out {
alias_paths.push(("--audience-out", audience_out.as_path()));
}
refuse_aliased_paths(&alias_paths)?;
let (target_scope, target_label) = match (&args.target_node, &args.target_any_owned_by) {
(Some(entity_hex), None) => {
let entity = parse_entity_hex(entity_hex)?;
let label = format!("node:{}", hex::encode(entity.as_bytes()));
(GrantTargetScope::ExactNode(entity), label)
}
(None, Some(org_hex)) => {
let org = parse_org_hex(org_hex)?;
let label = format!("any-owned-by:{}", hex::encode(org.as_bytes()));
(GrantTargetScope::AnyNodeOwnedBy(org), label)
}
(Some(_), Some(_)) => {
return Err(invalid_args(
"--target-node and --target-any-owned-by are mutually exclusive",
))
}
(None, None) => {
return Err(invalid_args(
"one of --target-node <entity> or --target-any-owned-by <org> is required",
))
}
};
let (grant, secret) = OrgCapabilityGrant::try_issue(
&issuer,
grantee_org,
capability,
rights,
target_scope,
args.ttl_secs,
)
.map_err(|e| invalid_args(format!("grant-capability: {e}")))?;
let grant_json = serialize_json(&OrgCapabilityGrantFile {
version: ORG_FILE_VERSION,
grant: grant.clone(),
})?;
let audience_out_label = match secret {
Some(secret) => {
let audience_out = args
.audience_out
.as_ref()
.expect("--discover requires --audience-out (validated above)");
let mut raw = secret.encode_config();
let secret_bytes = ScrubbedBytes::new(raw.to_vec());
zeroize_slice(&mut raw);
let grant_tmp = stage_beside(&args.out, &grant_json, false).await?;
let secret_tmp = match stage_beside(audience_out, secret_bytes.as_slice(), true).await {
Ok(t) => t,
Err(e) => {
remove_file_or_warn(&grant_tmp, "staging temp").await;
return Err(e);
}
};
drop(secret_bytes);
if let Err(e) = publish_staged(&grant_tmp, &args.out).await {
remove_file_or_warn(
&secret_tmp,
"staging temp (holds a copy of the audience secret)",
)
.await;
return Err(e);
}
if let Err(e) = publish_staged(&secret_tmp, audience_out).await {
remove_file_or_warn(
&args.out,
"grant (rollback: its audience secret failed to publish)",
)
.await;
return Err(e);
}
warn_secret_permissions(audience_out, args.accept_windows_dacl);
Some(audience_out.display().to_string())
}
None => {
let tmp = stage_beside(&args.out, &grant_json, false).await?;
publish_staged(&tmp, &args.out).await?;
None
}
};
let summary = GrantCapabilityOutput {
path: args.out.display().to_string(),
audience_out: audience_out_label,
grant_id_hex: hex::encode(grant.grant_id),
issuer_org_hex: hex::encode(grant.issuer_org.as_bytes()),
grantee_org_hex: hex::encode(grant.grantee_org.as_bytes()),
capability: args.capability.clone(),
rights: rights_labels.join(","),
target: target_label,
not_before: grant.not_before,
not_after: grant.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
#[derive(Serialize, Deserialize)]
struct OrgKeyFile {
org_id_hex: String,
seed_hex: String,
created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl Drop for OrgKeyFile {
fn drop(&mut self) {
zeroize_string(&mut self.seed_hex);
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCertFile {
pub(crate) version: u32,
pub(crate) cert: OrgMembershipCert,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgFloorsFile {
pub(crate) version: u32,
pub(crate) bundle: OrgRevocationBundle,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgDispatcherGrantFile {
pub(crate) version: u32,
pub(crate) grant: OrgDispatcherGrant,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCapabilityGrantFile {
pub(crate) version: u32,
pub(crate) grant: OrgCapabilityGrant,
}
#[derive(Debug, Serialize)]
struct OrgKeySummary {
path: String,
org_id_hex: String,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct IssueCertOutput {
path: String,
org_id_hex: String,
member_hex: String,
generation: u32,
not_before: u64,
not_after: u64,
}
#[derive(Debug, Serialize)]
struct IssueFloorsOutput {
path: String,
org_id_hex: String,
floors: usize,
issued_at: u64,
}
#[derive(Debug, Serialize)]
struct GrantDispatcherOutput {
path: String,
org_id_hex: String,
dispatcher_hex: String,
capability: String,
not_before: u64,
not_after: u64,
}
#[derive(Debug, Serialize)]
struct GrantCapabilityOutput {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
audience_out: Option<String>,
grant_id_hex: String,
issuer_org_hex: String,
grantee_org_hex: String,
capability: String,
rights: String,
target: String,
not_before: u64,
not_after: u64,
}
fn refuse_force(force: bool) -> Result<(), CliError> {
if force {
return Err(invalid_args(
"--force is refused for grant commands: publication is no-clobber (a forced replace \
is not crash-atomic and, on a case-insensitive filesystem, an aliased output could \
destroy the org key). Write to a fresh path, or remove the old artifact explicitly.",
));
}
Ok(())
}
async fn load_org_key(path: &Path, insecure_permissions: bool) -> Result<OrgKeypair, CliError> {
let mut text = read_secret_key_file(path, "org key file", insecure_permissions).await?;
let outcome = load_org_key_from_text(&text, path);
zeroize_string(&mut text);
outcome
}
fn load_org_key_from_text(text: &str, path: &Path) -> Result<OrgKeypair, CliError> {
let parsed: OrgKeyFile = toml::from_str(text).map_err(|_| {
invalid_args(format!(
"org key file {} is not valid TOML (kind: parse_error)",
path.display()
))
})?;
let seed_bytes = ScrubbedBytes::new(hex::decode(parsed.seed_hex.as_bytes()).map_err(|_| {
invalid_args(format!(
"org key file {} seed_hex is not valid hex (kind: bad_seed_encoding)",
path.display()
))
})?);
if seed_bytes.as_slice().len() != 32 {
return Err(invalid_args(format!(
"org key file {} seed must be 32 bytes (64 hex chars), got {} (kind: bad_seed_length)",
path.display(),
seed_bytes.as_slice().len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(seed_bytes.as_slice());
let keypair = OrgKeypair::from_bytes(seed);
zeroize_slice(&mut seed);
let derived = hex::encode(keypair.org_id().as_bytes());
if !parsed.org_id_hex.eq_ignore_ascii_case(&derived) {
return Err(invalid_args(format!(
"org key file {}: org_id_hex does not match the key derived from seed_hex",
path.display()
)));
}
Ok(keypair)
}
fn parse_org_hex(s: &str) -> Result<OrgId, CliError> {
let s = s.strip_prefix("0x").unwrap_or(s);
let bytes =
hex::decode(s).map_err(|e| invalid_args(format!("org id is not valid hex: {e}")))?;
let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
invalid_args(format!(
"org id must be 32 bytes (64 hex chars), got {}",
bytes.len()
))
})?;
Ok(OrgId::from_bytes(arr))
}
fn normalize_for_alias(p: &Path) -> PathBuf {
std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.components()
.collect()
}
pub(crate) fn refuse_aliased_paths(paths: &[(&str, &Path)]) -> Result<(), CliError> {
for i in 0..paths.len() {
for j in (i + 1)..paths.len() {
if normalize_for_alias(paths[i].1) == normalize_for_alias(paths[j].1) {
return Err(invalid_args(format!(
"{} and {} resolve to the same path; refusing to alias them",
paths[i].0, paths[j].0
)));
}
}
}
Ok(())
}
fn serialize_json<T: Serialize>(value: &T) -> Result<Vec<u8>, CliError> {
serde_json::to_vec_pretty(value).map_err(|e| generic(format!("failed to serialize: {e}")))
}
fn stage_nonce() -> String {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid}.{nanos}")
}
pub(crate) async fn stage_beside(
final_path: &Path,
bytes: &[u8],
secret: bool,
) -> Result<PathBuf, CliError> {
if let Some(parent) = final_path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
generic(format!(
"failed to create parent directory {}: {e}",
parent.display()
))
})?;
}
}
let file_name = final_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact");
let tmp = final_path.with_file_name(format!(".{file_name}.stage.{}", stage_nonce()));
let tmp_owned = tmp.clone();
let payload = ScrubbedBytes::new(bytes.to_vec());
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(if secret { 0o600 } else { 0o644 });
}
#[cfg(not(unix))]
{
let _ = secret;
}
let mut f = opts.open(&tmp_owned)?;
let written = (|| -> std::io::Result<()> {
std::io::Write::write_all(&mut f, payload.as_slice())?;
f.sync_all()
})();
if let Err(e) = written {
drop(f);
let _ = std::fs::remove_file(&tmp_owned);
return Err(e);
}
Ok(())
})
.await
.map_err(|e| generic(format!("stage-write task panicked: {e}")))?
.map_err(|e| generic(format!("failed to stage {}: {e}", tmp.display())))?;
Ok(tmp)
}
pub(crate) async fn publish_staged(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let final_owned = final_path.to_path_buf();
let link = tokio::task::spawn_blocking(move || std::fs::hard_link(&tmp_owned, &final_owned))
.await
.map_err(|e| generic(format!("publish task panicked: {e}")))?;
if let Err(e) = link {
remove_file_or_warn(tmp, "staging temp").await;
return Err(if e.kind() == std::io::ErrorKind::AlreadyExists {
invalid_args(format!(
"file already exists at {}; publication is no-clobber — write to a fresh path \
or remove the old artifact explicitly",
final_path.display()
))
} else {
generic(format!("failed to publish {}: {e}", final_path.display()))
});
}
remove_file_or_warn(tmp, "staging temp").await;
sync_parent_dir(final_path).await;
Ok(())
}
async fn remove_file_or_warn(path: &Path, what: &str) {
match tokio::fs::remove_file(path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => eprintln!(
"warning: failed to remove {what} {}: {e}; remove it manually.",
path.display()
),
}
}
#[cfg(not(unix))]
pub(crate) fn warn_secret_permissions(path: &Path, accepted: bool) {
if !accepted {
eprintln!(
"warning: the 0600 audience-secret mode is not enforced on Windows; {} inherits its \
parent directory's NTFS DACL. Ensure the parent is owner-only (or pass \
--accept-windows-dacl to silence).",
path.display()
);
}
}
#[cfg(unix)]
pub(crate) fn warn_secret_permissions(_path: &Path, _accepted: bool) {}
async fn sync_parent_dir(path: &Path) {
#[cfg(unix)]
{
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub(crate) async fn refuse_existing(path: &Path, force: bool) -> Result<(), CliError> {
if force {
return Ok(());
}
match tokio::fs::try_exists(path).await {
Ok(true) => Err(invalid_args(format!(
"file already exists at {}; pass --force to overwrite",
path.display()
))),
Ok(false) => Ok(()),
Err(e) => Err(generic(format!("failed to stat {}: {e}", path.display()))),
}
}
async fn publish_json_artifact(
final_path: &Path,
json: &[u8],
force: bool,
) -> Result<(), CliError> {
if force {
refuse_replacing_foreign_seed(final_path, SeedArtifact::None).await?;
}
let tmp = stage_beside(final_path, json, false).await?;
if force {
publish_staged_replace(&tmp, final_path).await
} else {
publish_staged(&tmp, final_path).await
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SeedArtifact {
None,
OrgKey,
Identity,
SubnetKey,
}
impl SeedArtifact {
fn describe(self) -> &'static str {
match self {
SeedArtifact::None => "no seed material",
SeedArtifact::OrgKey => "org root key material",
SeedArtifact::Identity => "operator identity key material",
SeedArtifact::SubnetKey => "subnet authority key material",
}
}
}
pub(crate) async fn classify_seed_artifact(path: &Path) -> SeedArtifact {
let Ok(mut text) = tokio::fs::read_to_string(path).await else {
return SeedArtifact::None;
};
let parsed = toml::from_str::<toml::Value>(&text).ok();
let has_seed = parsed
.as_ref()
.and_then(|v| v.get("seed_hex"))
.is_some_and(|v| v.is_str());
let has_org_id = parsed
.as_ref()
.and_then(|v| v.get("org_id_hex"))
.is_some_and(|v| v.is_str());
let is_subnet_key = parsed
.as_ref()
.and_then(|v| v.get("kind"))
.and_then(|v| v.as_str())
.is_some_and(|k| k == "subnet-authority-key");
zeroize_string(&mut text);
match (has_seed, has_org_id, is_subnet_key) {
(true, true, _) => SeedArtifact::OrgKey,
(true, false, true) => SeedArtifact::SubnetKey,
(true, false, false) => SeedArtifact::Identity,
_ => SeedArtifact::None,
}
}
pub(crate) async fn refuse_replacing_foreign_seed(
path: &Path,
publishing: SeedArtifact,
) -> Result<(), CliError> {
let found = classify_seed_artifact(path).await;
if found == SeedArtifact::None || found == publishing {
return Ok(());
}
Err(invalid_args(format!(
"refusing to overwrite {}: it contains {}. --out must not name that file, however the \
path is spelled (case variant, symlink, hard link, or relative path). If you really \
mean to discard it, remove it explicitly first.",
path.display(),
found.describe(),
)))
}
pub(crate) async fn publish_staged_replace(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let final_owned = final_path.to_path_buf();
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp_owned, &final_owned))
.await
.map_err(|e| generic(format!("publish task panicked: {e}")))?;
if let Err(e) = renamed {
remove_file_or_warn(tmp, "staging temp").await;
return Err(generic(format!(
"failed to publish {}: {e}",
final_path.display()
)));
}
sync_parent_dir(final_path).await;
Ok(())
}
fn default_org_key_path(org_id_hex: &str) -> Option<PathBuf> {
let short = &org_id_hex[..org_id_hex.len().min(16)];
Some(default_org_key_dir()?.join(format!("org-{short}.toml")))
}
fn default_org_key_dir() -> Option<PathBuf> {
Some(dirs::config_dir()?.join("net-mesh").join("orgs"))
}