Skip to content

Commit f42fa88

Browse files
committed
feat(coord-fed): Phase 1.3 — coord-tui --print-pubkey + ed25519 derivation
ADR-0016 Phase 1.3. Lets a user export their peer's ed25519 public key as 64 hex chars for manual entry into another peer's known_peers.toml (SSH known_hosts model — no discovery, no PKI). * Cargo.toml — ed25519-dalek = "2.2.0". No `rand` dep: seed generation reads /dev/urandom directly (32 bytes), matching what the Zig adapter does via std.crypto.random.bytes on Linux. * Cli — `--print-pubkey` flag + `--key-path` / BOJ_COORD_KEY_PATH env. Default path: $XDG_CACHE_HOME/coord-tui/peer.key, falling back to ~/.cache/coord-tui/peer.key. * print_pubkey() reads-or-creates the seed file (mode 0600 enforced on create), derives the public key via ed25519_dalek::SigningKey:: from_bytes(&seed).verifying_key(), prints the 64-char lowercase hex on stdout, exits 0. Non-Unix platforms refuse cleanly rather than writing a world-readable seed. * The seed file format (32 raw bytes) IS the shared identity contract between coord-tui and the Zig adapter — either may create it first, the other will load the existing seed unchanged. This is the v1 bootstrap path: no IPC needed for the human-export use case, and the adapter doesn't have to be running. Verified locally: cargo build → clean (only pre-existing dead-code warning, unrelated) /tmp round-trip → first run generates+prints; second run reads same seed and prints identical hex; file ends 32 bytes, mode 0600. bad-seed-length → error 'seed file is N bytes, expected 32', exit 1.
1 parent 4b0b154 commit f42fa88

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

coord-tui/Cargo.lock

Lines changed: 112 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coord-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ crossterm = { version = "0.29", features = ["event-stream"] }
1818
ureq = "2"
1919
serde_json = "1"
2020
clap = { version = "4", features = ["derive", "env"] }
21+
ed25519-dalek = "2.2.0"

coord-tui/src/main.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ struct Cli {
5454
/// Used by shell hooks triggered on tool launch.
5555
#[arg(long)]
5656
id: bool,
57+
58+
/// Print this peer's ed25519 public key as 64 hex characters and exit.
59+
/// Reads (or creates on first run) the seed file at `--key-path`. Used
60+
/// for manual peer-key exchange in ADR-0016 federation. The file is
61+
/// the shared identity contract between coord-tui and the Zig adapter.
62+
#[arg(long)]
63+
print_pubkey: bool,
64+
65+
/// Path to the ed25519 seed file. Default: $XDG_CACHE_HOME/coord-tui/peer.key
66+
/// or ~/.cache/coord-tui/peer.key. The Zig adapter uses the same path
67+
/// when its `boj_coord_identity_init` is called.
68+
#[arg(long, env = "BOJ_COORD_KEY_PATH")]
69+
key_path: Option<String>,
5770
}
5871

5972
// ─── HTTP ─────────────────────────────────────────────────────────────────────
@@ -595,6 +608,102 @@ fn centered(pct_w: u16, h: u16, r: Rect) -> Rect {
595608
Rect::new(x, y, w, h)
596609
}
597610

611+
// ─── ed25519 identity (ADR-0016 Phase 1) ─────────────────────────────────────
612+
//
613+
// Reads (or creates on first run) the 32-byte seed file and derives the
614+
// ed25519 public key. The file format is the shared identity contract
615+
// between coord-tui and the Zig adapter — either can be the first to
616+
// create it; the second one will load the existing seed unchanged.
617+
618+
fn default_key_path() -> std::path::PathBuf {
619+
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
620+
if !xdg.is_empty() {
621+
return std::path::Path::new(&xdg).join("coord-tui").join("peer.key");
622+
}
623+
}
624+
let home = std::env::var("HOME").unwrap_or_default();
625+
std::path::Path::new(&home).join(".cache").join("coord-tui").join("peer.key")
626+
}
627+
628+
fn read_or_create_seed(path: &std::path::Path) -> Result<[u8; 32], String> {
629+
if path.exists() {
630+
let bytes = std::fs::read(path)
631+
.map_err(|e| format!("read seed: {}", e))?;
632+
if bytes.len() != 32 {
633+
return Err(format!("seed file is {} bytes, expected 32", bytes.len()));
634+
}
635+
let mut seed = [0u8; 32];
636+
seed.copy_from_slice(&bytes);
637+
return Ok(seed);
638+
}
639+
640+
if let Some(parent) = path.parent() {
641+
std::fs::create_dir_all(parent)
642+
.map_err(|e| format!("create parent dir {}: {}", parent.display(), e))?;
643+
}
644+
645+
// Generate a fresh 32-byte seed by reading /dev/urandom directly.
646+
// This avoids pulling in a `rand` dep just for this one call and
647+
// matches what the Zig adapter does (std.crypto.random.bytes on
648+
// Linux ultimately calls getrandom(2) / /dev/urandom). The seed is
649+
// a private RFC 8032 ed25519 seed; ed25519-dalek will derive the
650+
// expanded secret scalar at sign time.
651+
let seed = read_urandom_32()?;
652+
write_seed_0600(path, &seed)?;
653+
Ok(seed)
654+
}
655+
656+
#[cfg(unix)]
657+
fn read_urandom_32() -> Result<[u8; 32], String> {
658+
use std::io::Read;
659+
let mut f = std::fs::File::open("/dev/urandom")
660+
.map_err(|e| format!("open /dev/urandom: {}", e))?;
661+
let mut seed = [0u8; 32];
662+
f.read_exact(&mut seed)
663+
.map_err(|e| format!("read /dev/urandom: {}", e))?;
664+
Ok(seed)
665+
}
666+
667+
#[cfg(not(unix))]
668+
fn read_urandom_32() -> Result<[u8; 32], String> {
669+
Err("Phase 1 supports Unix-like platforms only (needs /dev/urandom).".into())
670+
}
671+
672+
#[cfg(unix)]
673+
fn write_seed_0600(path: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> {
674+
use std::os::unix::fs::OpenOptionsExt;
675+
let mut f = std::fs::OpenOptions::new()
676+
.write(true).create_new(true).mode(0o600).open(path)
677+
.map_err(|e| format!("create seed {}: {}", path.display(), e))?;
678+
std::io::Write::write_all(&mut f, seed)
679+
.map_err(|e| format!("write seed: {}", e))
680+
}
681+
682+
#[cfg(not(unix))]
683+
fn write_seed_0600(path: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> {
684+
// Phase 1 supports Linux/macOS only — non-Unix platforms cannot
685+
// restrict the mode at file-create time. Refuse rather than write
686+
// a world-readable seed.
687+
let _ = (path, seed);
688+
Err("Phase 1 supports Unix-like platforms only (mode 0600 required for the seed file).".into())
689+
}
690+
691+
fn print_pubkey(override_path: Option<&str>) -> Result<String, String> {
692+
let path = match override_path {
693+
Some(p) => std::path::PathBuf::from(p),
694+
None => default_key_path(),
695+
};
696+
let seed = read_or_create_seed(&path)?;
697+
use ed25519_dalek::SigningKey;
698+
let signing = SigningKey::from_bytes(&seed);
699+
let pubkey = signing.verifying_key().to_bytes();
700+
let mut hex = String::with_capacity(64);
701+
for b in pubkey {
702+
hex.push_str(&format!("{:02x}", b));
703+
}
704+
Ok(hex)
705+
}
706+
598707
// ─── Context detection ────────────────────────────────────────────────────────
599708

600709
fn detect_context() -> String {
@@ -642,6 +751,19 @@ fn main() -> io::Result<()> {
642751
let cli = Cli::parse();
643752
let context = cli.context.unwrap_or_else(detect_context);
644753

754+
if cli.print_pubkey {
755+
match print_pubkey(cli.key_path.as_deref()) {
756+
Ok(hex) => {
757+
println!("{}", hex);
758+
return Ok(());
759+
}
760+
Err(e) => {
761+
eprintln!("coord-tui --print-pubkey: {}", e);
762+
std::process::exit(1);
763+
}
764+
}
765+
}
766+
645767
if cli.id {
646768
silent_register(&cli.url, &cli.kind, &context);
647769
return Ok(());

0 commit comments

Comments
 (0)