use crate::config_hub::{current_hub_name, short12};
use crate::identity::parse_card;
use crate::projection::request_status;
use crate::schema::{self, Message};
use crate::{
alias, config, crosshub, gitcmd, groups, keyring, knownhubs, machineconfig, roster, store,
tiers, verify,
};
use crate::{
check_version, format_line, hint, is_reserved_name, now, ssh_keygen_path, valid_slug,
warn_safety, warn_trust,
};
use anyhow::{anyhow, Result};
pub(crate) fn read_pubkey(key: &std::path::Path) -> Result<String> {
let pubpath = if key.extension().and_then(|e| e.to_str()) == Some("pub") {
key.to_path_buf()
} else {
let mut s = key.as_os_str().to_os_string();
s.push(".pub");
std::path::PathBuf::from(s)
};
Ok(std::fs::read_to_string(&pubpath)
.map_err(|e| anyhow!("cannot read public key {}: {e}", pubpath.display()))?
.trim()
.to_string())
}
pub(crate) fn configure_signing(root: &std::path::Path, key: &std::path::Path) -> Result<String> {
if !key.exists() {
return Err(anyhow!("signing key {} does not exist", key.display()));
}
let pubkey = read_pubkey(key)?;
let keygen = ssh_keygen_path();
let key_s = key.to_string_lossy();
for (k, v) in [
("gpg.format", "ssh"),
("gpg.ssh.program", keygen.as_str()),
("user.signingkey", key_s.as_ref()),
("commit.gpgsign", "true"),
("rebase.gpgSign", "true"),
] {
gitcmd::check(root, &["config", k, v])?;
}
Ok(pubkey)
}
fn write_atomic(path: &std::path::Path, contents: &str) -> Result<()> {
use std::io::Write;
let dir = path
.parent()
.ok_or_else(|| anyhow!("no parent dir for {}", path.display()))?;
std::fs::create_dir_all(dir)?;
let fname = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("state");
let tmp = dir.join(format!(".{fname}.tmp.{}", std::process::id()));
let mut f = std::fs::File::create(&tmp)?;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path)?;
Ok(())
}
pub(crate) fn published_pubkey(map: &serde_yaml::Mapping) -> Result<Option<String>> {
roster::classify_pubkey(map).map_err(|kind| {
anyhow!(
"role card's `pubkey` is present but is a {kind} where a key string was expected — \
refusing to treat that as 'no key published' (a role-id's identity IS its key; this \
shape can't be verified — possible tampering). Inspect the roles/*.md card."
)
})
}
fn role_ever_published_a_key(root: &std::path::Path, role: &str) -> Result<bool> {
if !valid_slug(role) {
return Ok(false);
}
let path = format!("roles/{role}.md");
let log = gitcmd::output(root, &["log", "--format=%H", "--", &path])?;
if !log.status.success() {
return Ok(false);
}
let shas = String::from_utf8_lossy(&log.stdout);
for sha in shas.lines().map(str::trim).filter(|s| !s.is_empty()) {
let blob = gitcmd::output(root, &["show", &format!("{sha}:{path}")])?;
if !blob.status.success() {
continue; }
let txt = String::from_utf8_lossy(&blob.stdout);
match parse_card(&txt).and_then(|(m, _)| published_pubkey(&m)) {
Ok(Some(_)) => return Ok(true),
Err(_) => return Ok(true),
Ok(None) => {}
}
}
Ok(false)
}
fn card_pubkey(card_text: &str) -> Result<Option<String>> {
let (map, _body) = parse_card(card_text)?;
published_pubkey(&map)
}
pub(crate) fn pubkey_material_eq(a: &str, b: &str) -> bool {
let material = |s: &str| {
let mut it = s.split_whitespace();
match (it.next(), it.next()) {
(Some(x), Some(y)) => format!("{x} {y}"),
_ => s.trim().to_string(),
}
};
material(a) == material(b)
}
fn ensure_card_pubkey(root: &std::path::Path, role: &str, pubkey: &str) -> Result<bool> {
let path = root.join("roles").join(format!("{role}.md"));
let (mut map, body) = parse_card(&std::fs::read_to_string(&path)?)?;
if let Some(existing) = published_pubkey(&map)? {
return if pubkey_material_eq(&existing, pubkey) {
Ok(false)
} else {
Err(anyhow!(
"role '{role}' already publishes a DIFFERENT signing key — the identity IS the key, so a role-id cannot be re-keyed. For a new agent use your OWN role-id; to drive THIS identity, join with its existing key."
))
};
}
if role_ever_published_a_key(root, role)? {
return Err(anyhow!(
"role '{role}' has published a signing key before, but its card now shows none — refusing \
to re-key it. Its card may have been tampered (its `pubkey` nulled/removed); recover the \
card from git history rather than re-keying. The identity IS the key."
));
}
map.insert("pubkey".into(), pubkey.into());
let yaml = serde_yaml::to_string(&map)?;
let content = if body.trim().is_empty() {
format!("---\n{yaml}---\n")
} else {
format!("---\n{yaml}---\n\n{}\n", body.trim())
};
std::fs::write(&path, content)?;
Ok(true)
}
pub(crate) fn is_nested_path(dir: &std::path::Path) -> bool {
let abs = if dir.is_absolute() {
dir.to_path_buf()
} else {
std::env::current_dir()
.map(|c| c.join(dir))
.unwrap_or_else(|_| dir.to_path_buf())
};
let mut p = abs.parent();
while let Some(a) = p {
if a.join(".git").exists() {
return true;
}
p = a.parent();
}
false
}
pub(crate) fn safe_clone_dir(dir: Option<String>, basename: &str) -> String {
if let Some(d) = dir {
return d;
}
if is_nested_path(std::path::Path::new(basename)) {
if let Ok(home) = config::home() {
let target = home.join(basename);
eprintln!(
"confer: inside a git repo — cloning to {} so it isn't nested in your working tree.",
target.display()
);
return target.to_string_lossy().into_owned();
}
}
basename.to_string()
}
pub(crate) fn warn_if_nested(hub: &std::path::Path) {
let hub_abs = hub.canonicalize().unwrap_or_else(|_| hub.to_path_buf());
let mut p = hub_abs.parent();
while let Some(dir) = p {
if dir.join(".git").exists() {
eprintln!(
"confer: ⚠ this hub clone is nested inside another git repo ({}). \
Keep the hub as a SIBLING (e.g. ~/git/<hub>), not inside a work repo — \
the outer repo sees it as an untracked dir and it's easy to commit by \
accident. Move it and `confer reconnect --dir <new-path>` when convenient.",
dir.display()
);
return;
}
p = dir.parent();
}
}
pub(crate) fn cmd_join(
role: String,
host: Option<String>,
display: Option<String>,
desc: Option<String>,
signing_key: Option<String>,
force: bool,
) -> Result<()> {
let root = config::repo_root()?;
if !valid_slug(&role) {
return Err(anyhow!(
"invalid role '{role}': must match [a-z0-9][a-z0-9-]* (≤64 chars)"
));
}
if is_reserved_name(&role) {
return Err(anyhow!(
"role '{role}' is reserved (the broadcast target); choose another role id"
));
}
if let Some(d) = &display {
if alias::homoglyph_risk(d) {
return Err(anyhow!(
"display name '{d}' mixes Latin with Cyrillic/Greek look-alike characters \
(homoglyph impersonation risk); use a plain-ASCII display name"
));
}
}
if let Err(e) = gitcmd::integrate(&root) {
eprintln!("confer: could not sync hub ({e}); resuming from local state");
}
check_version(&root);
if let Some(kp) = &signing_key {
let my_pub = read_pubkey(std::path::Path::new(kp))?;
let card_path = root.join("roles").join(format!("{role}.md"));
if let Ok(txt) = std::fs::read_to_string(&card_path) {
if let Some(existing) = card_pubkey(&txt)? {
if !pubkey_material_eq(&existing, &my_pub) {
return Err(anyhow!(
"role '{role}' already publishes a DIFFERENT signing key — the identity IS the key, so a role-id cannot be re-keyed. Use your OWN role-id for a new agent, or join with this identity's existing key."
));
}
}
}
}
let roster = roster::load(&root);
let session = ulid::Ulid::new().to_string();
let host = host.or_else(config::hostname).unwrap_or_default();
let confer_dir = root.join(".confer");
let identity_path = confer_dir.join("identity.json");
let _idlock = config::state_lock(&confer_dir.join("identity.lock"));
match std::fs::read_to_string(&identity_path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => {
return Err(anyhow!(
"cannot read this clone's identity (.confer/identity.json: {e}) — refusing to \
(re-)role it, since I can't verify it isn't already bound to another role. \
Inspect the file, or pass --force to override."
));
}
Ok(txt) => {
let prev = serde_json::from_str::<serde_json::Value>(&txt)
.ok()
.and_then(|v| v.get("role").and_then(|r| r.as_str()).map(str::to_string));
match prev {
None if !force => {
return Err(anyhow!(
".confer/identity.json exists but names no role (corrupt or partial write?) \
— refusing to (re-)role this clone without --force. Inspect the file, or \
re-create the clone."
));
}
Some(prev) if prev != role && !force => {
return Err(anyhow!(
"this clone already belongs to role '{prev}' — refusing to re-role it to \
'{role}'. It would keep {prev}'s signing key, binding one key to two roles \
and making {prev}'s posts from here appear as '{role}'. For a new role, \
make a SEPARATE clone: `confer clone <hub> --role {role} --managed`. To \
re-role THIS clone anyway (it keeps the current key), pass --force."
));
}
Some(prev) if prev != role => {
eprintln!(
"confer: --force re-roling this clone from '{prev}' to '{role}' — it keeps \
the current signing key, so both role-ids are backed by the same identity \
(they are now linked; see DESIGN.md)."
);
}
_ => {} }
}
}
let pubkey: Option<String> = match &signing_key {
Some(kp) => Some(read_pubkey(std::path::Path::new(kp))?),
None => None,
};
let mut identity = serde_json::json!({
"role": role, "session": session, "host": host, "joined_at": now(),
});
if let Some(kp) = &signing_key {
identity["signing_key"] = serde_json::Value::String(kp.clone());
}
if let Some(pk) = &pubkey {
identity["pubkey"] = serde_json::Value::String(pk.clone());
}
write_atomic(&identity_path, &serde_json::to_string_pretty(&identity)?)?;
match &signing_key {
Some(kp) => {
configure_signing(&root, std::path::Path::new(kp))?;
gitcmd::check(&root, &["config", "user.name", &role])?;
gitcmd::check(
&root,
&["config", "user.email", &format!("{role}@confer.local")],
)?;
println!("signing: commits from this clone will be signed with {kp}");
}
None => {
let _ = gitcmd::check(&root, &["config", "commit.gpgsign", "false"]);
let _ = gitcmd::check(&root, &["config", "gpg.format", "ssh"]); let _ = gitcmd::check(&root, &["config", "user.name", &role]);
let _ = gitcmd::check(
&root,
&["config", "user.email", &format!("{role}@confer.local")],
);
}
}
warn_if_nested(&root);
let sign = signing_key.is_some();
if let Some(pk) = &pubkey {
let hk = config::hub_key(&root);
if matches!(
keyring::pin_or_check(&hk, &role, pk, &now()),
Ok(keyring::Pin::First) | Ok(keyring::Pin::Match)
) {
let _ = keyring::confirm(&hk, &role);
}
}
let _ = tiers::set_default(&config::hub_key(&root), tiers::Tier::Foreign);
println!(
"joined as {} [{role}] (session {session})",
schema::sanitize_term(roster::display(&roster, &role), false)
);
let card_path = root.join("roles").join(format!("{role}.md"));
if card_path.exists() {
let msg = match &pubkey {
Some(pk) if ensure_card_pubkey(&root, &role, pk)? => {
Some("join: publish signing pubkey")
}
_ => None,
};
match msg {
Some(m) => match gitcmd::commit_and_sync(&root, &role, &card_path, m, sign) {
Ok(_) => println!("published signing pubkey to roles/{role}.md."),
Err(e) => eprintln!("confer: pubkey written locally but hub sync failed ({e})."),
},
None => println!("role already registered on the hub (roles/{role}.md)."),
}
} else {
let display = display.unwrap_or_else(|| role.clone());
let mut card = serde_yaml::Mapping::new();
card.insert("display".into(), display.clone().into());
card.insert("host".into(), host.clone().into());
if let Some(d) = &desc {
card.insert("desc".into(), d.clone().into());
}
if let Some(pk) = &pubkey {
card.insert("pubkey".into(), pk.clone().into());
}
let yaml = serde_yaml::to_string(&card)?;
std::fs::create_dir_all(root.join("roles"))?;
std::fs::write(&card_path, format!("---\n{yaml}---\n"))?;
match gitcmd::commit_and_sync(&root, &role, &card_path, &format!("join: register role {role}"), sign) {
Ok(_) => println!("registered on the hub: roles/{role}.md (display '{display}', host '{host}')."),
Err(e) => eprintln!(
"confer: role card written locally but hub sync failed ({e}); it will reach the hub on your next append."
),
}
}
let msgs = store::all_messages(&root)?;
let grps = groups::load(&root);
let open: Vec<&Message> = msgs
.iter()
.filter(|m| {
m.front.msg_type == "request"
&& groups::addressed(m, &role, &grps)
&& matches!(request_status(&msgs, &m.front.id), "OPEN" | "CLAIMED")
})
.collect();
if open.is_empty() {
println!("no open requests assigned to '{role}'.");
} else {
println!("open requests for '{role}':");
let hub_key = config::hub_key(&root);
let mut vc = verify::Cache::default();
for m in open {
let t = verify::status(&root, &hub_key, &roster, &mut vc, m);
println!("{}", format_line(&roster, m, false, Some(&t)));
}
}
crosshub::record(&root, &role); seed_hub_on_join(&root); Ok(())
}
fn seed_hub_on_join(root: &std::path::Path) {
let name = match current_hub_name(root) {
Ok(n) => n,
Err(_) => return, };
if let Ok(o) = gitcmd::output(root, &["config", "--get", "remote.origin.url"]) {
if o.status.success() {
let url = String::from_utf8_lossy(&o.stdout).trim().to_string();
let scheme = if url.starts_with("http") { "https" } else { "ssh" };
let (n, u, s) = (name.clone(), url.clone(), scheme.to_string());
if machineconfig::update_with(move |cfg| {
let hub = cfg.hubs.entry(n).or_default();
if hub.url.is_none() {
hub.url = Some(u);
}
if hub.scheme.is_none() {
hub.scheme = Some(s);
}
Ok(())
})
.is_err()
{
hint(format!("couldn't record routing for '{name}' (set it with `confer config set hubs.{name}.url <url>`)."));
}
}
}
match knownhubs::verify(&name, root) {
knownhubs::Verdict::FirstSight { root: r, tip } => {
if knownhubs::record(&name, &r, &tip, false).is_ok() {
hint(format!(
"recorded (UNCONFIRMED) hub identity for '{name}' (root {}). Verify + confirm with `confer hub repin`.",
short12(&r)
));
} else {
warn_safety(format!("couldn't record the hub-identity pin for '{name}' — run `confer hub repin` once ~/.confer is writable."));
}
}
knownhubs::Verdict::Match { new_tip } => knownhubs::advance_tip(&name, &new_tip),
knownhubs::Verdict::RootMismatch { pinned, got } => warn_trust(format!(
"hub '{name}': ROOT MISMATCH — pinned {} but this repo's root is {}. NOT re-pinning; \
investigate, then `confer hub repin` if this is a legitimate move.",
short12(&pinned),
short12(&got)
)),
knownhubs::Verdict::TipUnreachable { pinned_tip } => warn_trust(format!(
"hub '{name}': confirmed-good tip {} not reachable from HEAD (history rewritten?). NOT \
advancing the pin; investigate.",
short12(&pinned_tip)
)),
knownhubs::Verdict::NotVerifiable(_) => {}
}
}