use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_sdk::identity::EntityId;
use serde::{Deserialize, Serialize};
use crate::commands::org::{refuse_replacing_foreign_seed, SeedArtifact};
use crate::error::{generic, invalid_args, sdk, CliError};
use crate::prelude::{emit_value, OutputFormat};
use crate::secret::{zeroize_string, ScrubbedBytes, ScrubbedString};
#[derive(Subcommand, Debug)]
pub enum IdentityCommand {
Generate(GenerateArgs),
Show(ShowArgs),
Fingerprint(FingerprintArgs),
Revoke(RevokeArgs),
}
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct ShowArgs {
pub path: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct FingerprintArgs {
pub path: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct RevokeArgs {
pub issuer: String,
#[arg(long, default_value_t = 1)]
pub generation: u32,
#[arg(long = "revocation-store", value_name = "PATH")]
pub revocation_store: Option<PathBuf>,
}
pub async fn run(cmd: IdentityCommand, output: Option<OutputFormat>) -> Result<(), CliError> {
match cmd {
IdentityCommand::Generate(args) => run_generate(args, output).await,
IdentityCommand::Show(args) => run_show(args, output).await,
IdentityCommand::Fingerprint(args) => run_fingerprint(args, output).await,
IdentityCommand::Revoke(args) => run_revoke(args, output).await,
}
}
async fn run_generate(args: GenerateArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
use net_sdk::deck::OperatorIdentity;
let identity = OperatorIdentity::generate();
let operator_id = identity.operator_id();
let seed = ScrubbedBytes::new(identity.keypair().secret_bytes().to_vec());
let public_key = *identity.keypair().entity_id().as_bytes();
let path = match args.out {
Some(explicit) => explicit,
None => default_identity_path(operator_id).ok_or_else(|| {
invalid_args(
"cannot resolve the platform config directory, and refusing to fall back to \
the working directory — this file holds the operator's private seed. Pass \
an explicit --out."
.to_string(),
)
})?,
};
if !args.force {
match tokio::fs::try_exists(&path).await {
Ok(true) => {
return Err(invalid_args(format!(
"identity file already exists at {}; pass --force to overwrite",
path.display()
)));
}
Ok(false) => {}
Err(e) => {
return Err(generic(format!("failed to stat {}: {e}", path.display())));
}
}
} else {
refuse_replacing_foreign_seed(&path, SeedArtifact::Identity).await?;
}
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
generic(format!(
"failed to create parent directory {}: {e}",
parent.display()
))
})?;
}
let file = IdentityFile {
operator_id_hex: format!("0x{operator_id:016x}"),
seed_hex: hex::encode(seed.as_slice()),
public_key_hex: hex::encode(public_key),
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 identity TOML: {e}")))?,
);
let pid = std::process::id();
let tmp = path.with_extension(format!("tmp.{pid}"));
write_identity_atomically(&tmp, &path, toml_text.as_bytes()).await?;
enforce_strict_permissions(&path).await?;
let summary = IdentitySummary {
path: path.display().to_string(),
operator_id_hex: file.operator_id_hex.clone(),
public_key_hex: file.public_key_hex.clone(),
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_show(args: ShowArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let file = read_identity_file(&args.path, args.insecure_permissions).await?;
let summary = IdentitySummary {
path: args.path.display().to_string(),
operator_id_hex: file.operator_id_hex.clone(),
public_key_hex: file.public_key_hex.clone(),
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_fingerprint(
args: FingerprintArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
use sha2::{Digest, Sha256};
let file = read_identity_file(&args.path, args.insecure_permissions).await?;
let public_key = hex::decode(&file.public_key_hex)
.map_err(|e| sdk(format!("public_key_hex is not valid hex: {e}")))?;
let digest = Sha256::digest(&public_key);
let short: Vec<String> = digest.iter().take(8).map(|b| format!("{b:02X}")).collect();
let fingerprint = short.join(":");
let info = FingerprintOutput {
operator_id_hex: file.operator_id_hex.clone(),
fingerprint,
};
emit_value(OutputFormat::resolve_oneshot(output), &info)
.map_err(|e| generic(format!("write fingerprint: {e}")))?;
Ok(())
}
async fn run_revoke(args: RevokeArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let issuer = parse_entity_hex(&args.issuer)?;
let path = args
.revocation_store
.or_else(net_sdk::revocation::default_revocation_store_path)
.ok_or_else(|| {
invalid_args(
"no revocation-store path could be resolved; pass --revocation-store <PATH>",
)
})?;
let floor = net_sdk::revocation::RevocationStore::revoke_below(&path, &issuer, args.generation)
.map_err(|e| sdk(format!("revoke failed: {e}")))?;
let info = RevokeOutput {
issuer_hex: hex::encode(issuer.as_bytes()),
generation: args.generation,
floor,
store: path.display().to_string(),
};
emit_value(OutputFormat::resolve_oneshot(output), &info)
.map_err(|e| generic(format!("write revoke: {e}")))?;
Ok(())
}
pub(crate) fn parse_entity_hex(raw: &str) -> Result<EntityId, CliError> {
let trimmed = raw
.strip_prefix("0x")
.or_else(|| raw.strip_prefix("0X"))
.unwrap_or(raw);
let bytes =
hex::decode(trimmed).map_err(|e| invalid_args(format!("issuer: invalid hex: {e}")))?;
let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
invalid_args(format!(
"issuer must be 32 bytes (64 hex chars), got {}",
bytes.len()
))
})?;
Ok(EntityId::from_bytes(arr))
}
#[derive(Serialize, Deserialize)]
pub(crate) struct IdentityFile {
pub(crate) operator_id_hex: String,
pub(crate) seed_hex: String,
pub(crate) public_key_hex: String,
pub(crate) created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl Drop for IdentityFile {
fn drop(&mut self) {
zeroize_string(&mut self.seed_hex);
}
}
#[derive(Debug, Serialize)]
struct IdentitySummary {
path: String,
operator_id_hex: String,
public_key_hex: String,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct FingerprintOutput {
operator_id_hex: String,
fingerprint: String,
}
#[derive(Debug, Serialize)]
struct RevokeOutput {
issuer_hex: String,
generation: u32,
floor: u32,
store: String,
}
pub(crate) async fn read_identity_file(
path: &Path,
insecure_permissions: bool,
) -> Result<IdentityFile, CliError> {
if !insecure_permissions {
check_strict_permissions(path).await?;
}
let text = tokio::fs::read_to_string(path).await.map_err(|e| {
generic(format!(
"failed to read identity file {}: {e}",
path.display()
))
})?;
let parsed: IdentityFile = toml::from_str(&text).map_err(|e| {
invalid_args(format!(
"identity file {} failed to parse: {e}",
path.display()
))
})?;
Ok(parsed)
}
fn write_sync_or_remove(mut f: std::fs::File, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
let written = std::io::Write::write_all(&mut f, bytes).and_then(|()| {
f.sync_all()
});
let Err(e) = written else {
return Ok(());
};
drop(f);
match std::fs::remove_file(tmp) {
Ok(()) => {}
Err(rm) if rm.kind() == std::io::ErrorKind::NotFound => {}
Err(rm) => eprintln!(
"warning: failed to remove partially-written seed temp {}: {rm}; REMOVE IT MANUALLY — it may contain key material.",
tmp.display()
),
}
Err(e)
}
pub(crate) async fn write_identity_atomically(
tmp: &Path,
final_path: &Path,
bytes: &[u8],
) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let bytes_owned = 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(0o600);
}
let f = opts.open(&tmp_owned)?;
write_sync_or_remove(f, &tmp_owned, bytes_owned.as_slice())
})
.await
.map_err(|e| generic(format!("seed-write task panicked: {e}")))?
.map_err(|e| {
generic(format!(
"failed to write identity tmp {}: {e}",
tmp.display()
))
})?;
if let Err(e) = tokio::fs::rename(tmp, final_path).await {
match tokio::fs::remove_file(tmp).await {
Ok(()) => {}
Err(rm) if rm.kind() == std::io::ErrorKind::NotFound => {}
Err(rm) => eprintln!(
"warning: failed to remove seed-bearing temp file {}: {rm}; REMOVE IT MANUALLY — it contains key material.",
tmp.display()
),
}
return Err(generic(format!(
"rename identity tmp {} -> {}: {e}",
tmp.display(),
final_path.display()
)));
}
Ok(())
}
#[cfg(unix)]
pub(crate) async fn enforce_strict_permissions(path: &Path) -> Result<(), CliError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(path, perms).await.map_err(|e| {
generic(format!(
"failed to set 0600 permissions on {}: {e}",
path.display()
))
})
}
#[cfg(not(unix))]
pub(crate) async fn enforce_strict_permissions(_path: &Path) -> Result<(), CliError> {
Ok(())
}
#[cfg(unix)]
pub(crate) async fn check_strict_permissions(path: &Path) -> Result<(), CliError> {
use std::os::unix::fs::PermissionsExt;
let meta = tokio::fs::metadata(path).await.map_err(|e| {
generic(format!(
"failed to stat identity file {}: {e}",
path.display()
))
})?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(invalid_args(format!(
"identity file {} has permissive mode {:#o}; tighten to 0600 \
or pass --insecure-permissions to override (kind: \
permissive_mode)",
path.display(),
mode
)));
}
Ok(())
}
#[cfg(not(unix))]
pub(crate) async fn check_strict_permissions(path: &Path) -> Result<(), CliError> {
eprintln!(
"warning: identity-file permission gate is a no-op on Windows; \
NTFS ACLs on {} are not validated. Pass --insecure-permissions \
to silence, or manage the DACL out-of-band.",
path.display()
);
Ok(())
}
fn default_identity_path(operator_id: u64) -> Option<PathBuf> {
Some(
dirs::config_dir()?
.join("net-mesh")
.join("identities")
.join(format!("operator-0x{operator_id:016x}.toml")),
)
}
pub(crate) fn now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format_iso8601_utc(now)
}
fn format_iso8601_utc(secs_since_epoch: u64) -> String {
const SECONDS_PER_DAY: u64 = 86_400;
let days = (secs_since_epoch / SECONDS_PER_DAY) as i64;
let remainder = secs_since_epoch % SECONDS_PER_DAY;
let hour = (remainder / 3600) as u32;
let minute = ((remainder % 3600) / 60) as u32;
let second = (remainder % 60) as u32;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, m, d, hour, minute, second
)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn a_failed_rename_removes_the_seed_bearing_temp() {
let dir = tempfile::tempdir().expect("tempdir");
let tmp = dir.path().join("identity.tmp");
let occupied = dir.path().join("occupied");
std::fs::create_dir(&occupied).expect("mkdir");
let err = write_identity_atomically(&tmp, &occupied, b"seed-material")
.await
.expect_err("renaming onto a directory must fail");
assert!(
format!("{err}").contains("rename identity tmp"),
"the error must name the failed step; got: {err}",
);
assert!(
!tmp.exists(),
"the seed-bearing temp {} must not be left on disk",
tmp.display(),
);
}
#[test]
fn a_failed_write_removes_the_partial_seed_temp() {
let dir = tempfile::tempdir().expect("tempdir");
let tmp = dir.path().join("identity.tmp");
std::fs::write(&tmp, b"partial-seed-material").expect("seed the temp");
let read_only = std::fs::File::open(&tmp).expect("open read-only");
let err = write_sync_or_remove(read_only, &tmp, b"the-real-seed")
.expect_err("writing through a read-only handle must fail");
#[cfg(unix)]
assert_eq!(
err.raw_os_error(),
Some(9), "precondition: the failure is the write itself, not something else",
);
#[cfg(windows)]
assert_eq!(
err.kind(),
std::io::ErrorKind::PermissionDenied,
"precondition: the failure is the write itself, not something else",
);
assert!(
!tmp.exists(),
"a partially-written seed temp must be removed on write failure",
);
}
#[test]
fn a_successful_write_keeps_the_temp_for_the_rename() {
let dir = tempfile::tempdir().expect("tempdir");
let tmp = dir.path().join("identity.tmp");
let f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.expect("create");
write_sync_or_remove(f, &tmp, b"seed-material").expect("healthy write succeeds");
assert_eq!(
std::fs::read(&tmp).expect("read back"),
b"seed-material",
"the payload is intact and the temp survives for the rename",
);
}
#[tokio::test]
async fn a_successful_write_renames_and_leaves_no_temp() {
let dir = tempfile::tempdir().expect("tempdir");
let tmp = dir.path().join("identity.tmp");
let final_path = dir.path().join("identity.toml");
write_identity_atomically(&tmp, &final_path, b"seed-material")
.await
.expect("write succeeds");
assert_eq!(
std::fs::read(&final_path).expect("read final"),
b"seed-material",
"the payload landed intact",
);
assert!(!tmp.exists(), "no temp is left behind on success");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&final_path).unwrap().permissions().mode();
assert_eq!(
mode & 0o077,
0,
"seed file must be owner-only, got {mode:o}"
);
}
}
#[test]
fn iso8601_formats_unix_epoch() {
assert_eq!(format_iso8601_utc(0), "1970-01-01T00:00:00Z");
}
#[test]
fn iso8601_formats_known_timestamp() {
assert_eq!(format_iso8601_utc(1763382896), "2025-11-17T12:34:56Z");
}
#[test]
fn parse_entity_hex_accepts_64_hex_and_rejects_bad() {
let id = net_sdk::Identity::generate();
let hexed = hex::encode(id.entity_id().as_bytes());
assert_eq!(
parse_entity_hex(&hexed).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert_eq!(
parse_entity_hex(&format!("0x{hexed}")).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert_eq!(
parse_entity_hex(&format!("0X{hexed}")).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert!(parse_entity_hex("deadbeef").is_err()); assert!(parse_entity_hex(&"zz".repeat(32)).is_err()); }
#[test]
fn revoke_writes_the_floor_to_the_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rev.json");
let issuer = net_sdk::Identity::generate();
let floor =
net_sdk::revocation::RevocationStore::revoke_below(&path, issuer.entity_id(), 1)
.unwrap();
assert_eq!(floor, 1);
assert_eq!(
net_sdk::revocation::RevocationStore::load(&path)
.unwrap()
.floor(issuer.entity_id()),
1
);
}
}