use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use uuid::Uuid;
fn now_iso() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, m, d, hh, mm, ss) = ymdhms_from_unix(now as i64);
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
fn ymdhms_from_unix(t: i64) -> (i32, u32, u32, u32, u32, u32) {
let secs = t.rem_euclid(86_400) as u32;
let days = t.div_euclid(86_400);
let hh = secs / 3600;
let mm = (secs % 3600) / 60;
let ss = secs % 60;
let z = days + 719_468;
let era = if z >= 0 {
z / 146_097
} else {
(z - 146_096) / 146_097
};
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i32 + (era as i32) * 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 y = if m <= 2 { y + 1 } else { y };
(y, m, d, hh, mm, ss)
}
#[cfg(unix)]
fn chmod_600(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(path, perms)
}
#[cfg(not(unix))]
fn chmod_600(_path: &Path) -> io::Result<()> {
Ok(())
}
pub fn config_dir() -> io::Result<PathBuf> {
let base = match std::env::var("IICP_HOME") {
Ok(v) if !v.is_empty() => PathBuf::from(shellexpand_home(&v)),
_ => {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".iicp")
}
};
fs::create_dir_all(&base)?;
fs::create_dir_all(base.join("nodes"))?;
Ok(base)
}
fn shellexpand_home(s: &str) -> String {
if let Some(rest) = s.strip_prefix('~') {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}{rest}");
}
}
s.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorIdentity {
pub operator_id: String,
pub created_at: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub contact: String,
#[serde(default)]
pub operator_secret: String,
#[serde(default)]
pub operator_integrity_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_secret_enc: Option<crate::operator_crypto::EncryptedSecret>,
}
impl OperatorIdentity {
pub fn generate(display_name: &str, contact: &str) -> Self {
use base64::{engine::general_purpose::STANDARD, Engine};
use ed25519_dalek::SigningKey;
let sk = SigningKey::generate(&mut rand::rngs::OsRng);
let operator_id = STANDARD.encode(sk.verifying_key().to_bytes());
let operator_secret = STANDARD.encode(sk.to_bytes());
let created_at = now_iso();
let operator_integrity_hash = Self::compute_integrity_hash(&operator_id, &created_at);
Self {
operator_id,
created_at,
display_name: display_name.to_string(),
contact: contact.to_string(),
operator_secret,
operator_integrity_hash,
operator_secret_enc: None,
}
}
pub fn compute_integrity_hash(operator_id: &str, created_at: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(
format!("{operator_id}:{created_at}").as_bytes(),
))
}
pub fn is_key_backed(&self) -> bool {
(!self.operator_secret.is_empty() || self.operator_secret_enc.is_some())
&& !self.operator_id.starts_with("op-")
}
pub fn is_encrypted(&self) -> bool {
self.operator_secret_enc.is_some() && self.operator_secret.is_empty()
}
fn seed_b64(&self, passphrase: Option<&str>) -> Result<String, String> {
if !self.operator_secret.is_empty() {
return Ok(self.operator_secret.clone());
}
if let Some(enc) = &self.operator_secret_enc {
let pw = passphrase
.map(str::to_string)
.or_else(crate::operator_crypto::passphrase_from_env)
.ok_or_else(|| {
"operator secret is encrypted — set $IICP_OPERATOR_PASSPHRASE (or pass a \
passphrase) to unlock it (#460)"
.to_string()
})?;
return crate::operator_crypto::decrypt_seed(&pw, enc, &self.operator_id);
}
Err(
"legacy operator identity has no key (operator_id is a UUID, not a public key) — regenerate (#464)".into(),
)
}
pub fn signing_key(&self) -> Result<ed25519_dalek::SigningKey, String> {
self.signing_key_with(None)
}
pub fn signing_key_with(
&self,
passphrase: Option<&str>,
) -> Result<ed25519_dalek::SigningKey, String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let seed_b64 = self.seed_b64(passphrase)?;
let bytes = STANDARD
.decode(&seed_b64)
.map_err(|e| format!("bad operator_secret base64: {e}"))?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| "operator_secret is not 32 bytes".to_string())?;
Ok(ed25519_dalek::SigningKey::from_bytes(&arr))
}
pub fn encrypt_at_rest(&self, passphrase: &str) -> Result<Self, String> {
let enc = crate::operator_crypto::encrypt_seed(
passphrase,
&self.seed_b64(None)?,
&self.operator_id,
)?;
Ok(Self {
operator_secret: String::new(),
operator_secret_enc: Some(enc),
..self.clone()
})
}
pub fn decrypt_at_rest(&self, passphrase: &str) -> Result<Self, String> {
let seed = self.seed_b64(Some(passphrase))?;
Ok(Self {
operator_secret: seed,
operator_secret_enc: None,
..self.clone()
})
}
}
pub const NO_IDENTITY_NOTICE: &str = "NOTICE: no operator identity - this node is registering anonymously.\n You will NOT accrue founder or recognition standing.\n Run `iicp-node init` (takes 30 seconds), then restart, to start\n your founder clock. Docs: https://iicp.network/docs/operator-identity";
pub fn no_identity_notice(op: Option<&OperatorIdentity>) -> Option<&'static str> {
match op {
Some(op) if op.is_key_backed() => None,
_ => Some(NO_IDENTITY_NOTICE),
}
}
pub fn operator_path() -> io::Result<PathBuf> {
Ok(config_dir()?.join("operator.json"))
}
pub fn load_operator() -> io::Result<Option<OperatorIdentity>> {
let p = operator_path()?;
if !p.exists() {
return Ok(None);
}
let txt = fs::read_to_string(&p)?;
let op: OperatorIdentity =
serde_json::from_str(&txt).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(op))
}
pub fn save_operator(op: &OperatorIdentity) -> io::Result<PathBuf> {
let p = operator_path()?;
let json = serde_json::to_string_pretty(op)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&p, format!("{json}\n"))?;
let _ = chmod_600(&p);
Ok(p)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NodeIdentity {
pub node_id: String,
pub operator_id: String,
pub name: String,
pub backend_url: String,
#[serde(default = "default_backend_type")]
pub backend_type: String,
pub model: String,
#[serde(default = "default_intent")]
pub intent: String,
#[serde(default = "default_region")]
pub region: String,
#[serde(default = "default_directory_url")]
pub directory_url: String,
#[serde(default = "default_max_concurrent")]
pub max_concurrent: u32,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
#[serde(default)]
pub public_endpoint: String,
#[serde(default)]
pub auto_detect_nat: bool,
#[serde(default)]
pub external_ip_probe_url: String,
#[serde(default)]
pub node_token: Option<String>,
#[serde(default)]
pub node_hmac_key: Option<String>,
pub created_at: String,
}
fn default_backend_type() -> String {
"openai_compat".to_string()
}
fn default_intent() -> String {
"urn:iicp:intent:llm:chat:v1".to_string()
}
fn default_region() -> String {
"unknown".to_string()
}
fn default_directory_url() -> String {
"https://iicp.network/api".to_string()
}
fn default_max_concurrent() -> u32 {
4
}
fn default_port() -> u16 {
8020
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn validate_name(name: &str) -> io::Result<()> {
let bytes = name.as_bytes();
if bytes.is_empty() || bytes.len() > 63 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"node name length must be 1..=63",
));
}
let first_ok = bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit();
if !first_ok {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"node name must start with [a-z0-9]",
));
}
for &b in &bytes[1..] {
let ok =
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'_' || b == b'-';
if !ok {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"node name must match [a-z0-9][a-z0-9._-]{0,62}",
));
}
}
Ok(())
}
pub fn node_path(name: &str) -> io::Result<PathBuf> {
validate_name(name)?;
Ok(config_dir()?.join("nodes").join(format!("{name}.json")))
}
pub fn load_node(name: &str) -> io::Result<Option<NodeIdentity>> {
let p = node_path(name)?;
if !p.exists() {
return Ok(None);
}
let txt = fs::read_to_string(&p)?;
let node: NodeIdentity =
serde_json::from_str(&txt).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(node))
}
pub fn save_node(node: &NodeIdentity) -> io::Result<PathBuf> {
validate_name(&node.name)?;
let p = node_path(&node.name)?;
let json = serde_json::to_string_pretty(node)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&p, format!("{json}\n"))?;
let _ = chmod_600(&p);
Ok(p)
}
pub fn list_nodes() -> io::Result<Vec<NodeIdentity>> {
let dir = config_dir()?.join("nodes");
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<PathBuf> = fs::read_dir(&dir)?
.filter_map(|r| r.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();
entries.sort();
let mut out = Vec::new();
for p in entries {
if let Ok(txt) = fs::read_to_string(&p) {
if let Ok(node) = serde_json::from_str::<NodeIdentity>(&txt) {
out.push(node);
}
}
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
pub fn generate_node(
operator_id: &str,
name: &str,
backend_url: &str,
model: &str,
intent: &str,
region: &str,
directory_url: &str,
port: u16,
host: &str,
public_endpoint: &str,
auto_detect_nat: bool,
external_ip_probe_url: &str,
) -> io::Result<NodeIdentity> {
validate_name(name)?;
Ok(NodeIdentity {
node_id: Uuid::new_v4().to_string(),
operator_id: operator_id.to_string(),
name: name.to_string(),
backend_url: backend_url.to_string(),
backend_type: default_backend_type(),
model: model.to_string(),
intent: intent.to_string(),
region: region.to_string(),
directory_url: directory_url.to_string(),
max_concurrent: 4,
port,
host: host.to_string(),
public_endpoint: public_endpoint.to_string(),
auto_detect_nat,
external_ip_probe_url: external_ip_probe_url.to_string(),
node_token: None, node_hmac_key: None, created_at: now_iso(),
})
}
#[cfg(test)]
mod operator_identity_tests {
use super::OperatorIdentity;
use crate::delegation::{issue_delegation, operator_pub_b64, verify_delegation};
use base64::{engine::general_purpose::STANDARD, Engine};
#[test]
fn operator_id_is_the_base64_ed25519_pubkey() {
let op = OperatorIdentity::generate("Rebel One", "me@example.com");
assert!(!op.operator_id.starts_with("op-"));
assert_eq!(STANDARD.decode(&op.operator_id).unwrap().len(), 32);
assert_eq!(STANDARD.decode(&op.operator_secret).unwrap().len(), 32);
assert!(op.is_key_backed());
}
#[test]
fn signing_key_public_matches_operator_id() {
let op = OperatorIdentity::generate("", "");
let sk = op.signing_key().unwrap();
assert_eq!(operator_pub_b64(&sk), op.operator_id);
}
#[test]
fn delegation_uses_the_identity_key_and_verifies() {
let op = OperatorIdentity::generate("", "");
let token = issue_delegation(&op.signing_key().unwrap(), "node-123", 3600);
assert_eq!(token.operator_pub, op.operator_id);
assert!(verify_delegation(&token, "node-123", 0).is_ok());
assert!(verify_delegation(&token, "other-node", 0).is_err());
}
#[test]
fn integrity_hash_binds_operator_id_and_created_at() {
let op = OperatorIdentity::generate("", "");
assert_eq!(
op.operator_integrity_hash,
OperatorIdentity::compute_integrity_hash(&op.operator_id, &op.created_at)
);
assert_ne!(
OperatorIdentity::compute_integrity_hash(&op.operator_id, "1999-01-01T00:00:00Z"),
op.operator_integrity_hash
);
}
#[test]
fn legacy_uuid_identity_is_not_key_backed() {
let legacy = OperatorIdentity {
operator_id: "op-deadbeef".into(),
created_at: "2026-01-01T00:00:00Z".into(),
display_name: String::new(),
contact: String::new(),
operator_secret: String::new(),
operator_integrity_hash: String::new(),
operator_secret_enc: None,
};
assert!(!legacy.is_key_backed());
assert!(legacy.signing_key().is_err());
}
#[test]
fn operator_encrypt_sign_decrypt_cycle() {
let op = OperatorIdentity::generate("Padme", "");
assert!(!op.is_encrypted());
let pub_before = op.signing_key().unwrap().verifying_key().to_bytes();
let enc = op.encrypt_at_rest("s3cret").unwrap();
assert!(enc.is_encrypted());
assert!(enc.operator_secret.is_empty()); assert!(enc.is_key_backed());
let sk = enc.signing_key_with(Some("s3cret")).unwrap();
assert_eq!(sk.verifying_key().to_bytes(), pub_before);
assert!(enc.signing_key_with(Some("WRONG")).is_err());
let back = enc.decrypt_at_rest("s3cret").unwrap();
assert!(!back.is_encrypted());
assert_eq!(back.operator_secret, op.operator_secret);
}
}
#[cfg(test)]
mod node_identity_tests {
use super::NodeIdentity;
#[test]
fn node_identity_region_default_is_unknown() {
let json = r#"{"node_id":"n1","operator_id":"o1","name":"test","backend_url":"http://x","model":"llama3","created_at":"2026-01-01T00:00:00Z"}"#;
let identity: NodeIdentity = serde_json::from_str(json).unwrap();
assert_eq!(
identity.region, "unknown",
"missing region field must default to 'unknown', not 'eu-central' (#484)"
);
}
}
#[cfg(test)]
mod no_identity_notice_tests {
use super::{no_identity_notice, OperatorIdentity, NO_IDENTITY_NOTICE};
#[test]
fn fires_when_no_operator() {
let n = no_identity_notice(None).expect("notice must fire without an identity");
assert_eq!(n, NO_IDENTITY_NOTICE);
assert!(n.contains("iicp-node init"));
assert!(n.to_lowercase().contains("founder"));
}
#[test]
fn fires_for_legacy_keyless_identity() {
let legacy = OperatorIdentity {
operator_id: "op-12345678-1234-1234-1234-123456789abc".into(),
created_at: "2026-01-01T00:00:00Z".into(),
display_name: "Legacy".into(),
contact: String::new(),
operator_secret: String::new(),
operator_integrity_hash: String::new(),
operator_secret_enc: None,
};
assert!(!legacy.is_key_backed());
assert!(no_identity_notice(Some(&legacy)).is_some());
}
#[test]
fn silent_for_key_backed_identity() {
let op = OperatorIdentity::generate("Keyed", "");
assert!(op.is_key_backed());
assert!(no_identity_notice(Some(&op)).is_none());
}
}