2026-05-21 19:48:02 +00:00
|
|
|
//! On-disk identity persistence and string encodings used by peer CLIs.
|
2026-02-19 14:39:33 +00:00
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
use std::fs;
|
2026-02-19 14:39:33 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-02-19 14:39:33 +00:00
|
|
|
use crate::crypto::Keypair;
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
/// Hex-encode a byte slice as lowercase ASCII.
|
|
|
|
|
pub fn hex_encode(bytes: &[u8]) -> String {
|
|
|
|
|
let mut s = String::with_capacity(bytes.len() * 2);
|
|
|
|
|
for b in bytes {
|
|
|
|
|
s.push(hex_digit(b >> 4));
|
|
|
|
|
s.push(hex_digit(b & 0xf));
|
|
|
|
|
}
|
|
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Hex-decode a string into bytes. Returns `None` on invalid input.
|
|
|
|
|
pub fn hex_decode(hex: &str) -> Option<Vec<u8>> {
|
|
|
|
|
if hex.len() % 2 != 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let mut out = Vec::with_capacity(hex.len() / 2);
|
|
|
|
|
for chunk in hex.as_bytes().chunks(2) {
|
|
|
|
|
let hi = from_hex_digit(chunk[0])?;
|
|
|
|
|
let lo = from_hex_digit(chunk[1])?;
|
|
|
|
|
out.push((hi << 4) | lo);
|
|
|
|
|
}
|
|
|
|
|
Some(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hex_digit(n: u8) -> char {
|
|
|
|
|
match n {
|
|
|
|
|
0..=9 => (b'0' + n) as char,
|
|
|
|
|
10..=15 => (b'a' + n - 10) as char,
|
|
|
|
|
_ => unreachable!(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn from_hex_digit(b: u8) -> Option<u8> {
|
|
|
|
|
match b {
|
|
|
|
|
b'0'..=b'9' => Some(b - b'0'),
|
|
|
|
|
b'a'..=b'f' => Some(b - b'a' + 10),
|
|
|
|
|
b'A'..=b'F' => Some(b - b'A' + 10),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Base58 ──────────────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Bitcoin-flavoured base58 alphabet: no `0OIl`. Encoding preserves leading
|
|
|
|
|
// zero bytes as leading `'1'` characters.
|
|
|
|
|
|
2026-06-23 15:42:28 +00:00
|
|
|
const B58_ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
2026-02-19 14:39:33 +00:00
|
|
|
|
|
|
|
|
/// Base58-encode a byte slice (Bitcoin alphabet).
|
|
|
|
|
pub fn base58_encode(bytes: &[u8]) -> String {
|
2026-05-21 19:48:02 +00:00
|
|
|
let zeros = bytes.iter().take_while(|b| **b == 0).count();
|
|
|
|
|
let mut buf: Vec<u8> = Vec::with_capacity(bytes.len() * 138 / 100 + 1);
|
|
|
|
|
for &b in &bytes[zeros..] {
|
|
|
|
|
let mut carry = b as u32;
|
|
|
|
|
for digit in buf.iter_mut() {
|
|
|
|
|
carry += (*digit as u32) << 8;
|
|
|
|
|
*digit = (carry % 58) as u8;
|
|
|
|
|
carry /= 58;
|
|
|
|
|
}
|
|
|
|
|
while carry > 0 {
|
|
|
|
|
buf.push((carry % 58) as u8);
|
|
|
|
|
carry /= 58;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut out = String::with_capacity(zeros + buf.len());
|
|
|
|
|
for _ in 0..zeros {
|
|
|
|
|
out.push(B58_ALPHABET[0] as char);
|
|
|
|
|
}
|
|
|
|
|
for digit in buf.iter().rev() {
|
|
|
|
|
out.push(B58_ALPHABET[*digit as usize] as char);
|
|
|
|
|
}
|
|
|
|
|
out
|
2026-02-19 14:39:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
/// Base58-decode a string into exactly 32 bytes (the swactor node-id width).
|
|
|
|
|
///
|
|
|
|
|
/// Returns `None` on invalid characters or when the decoded byte length is
|
|
|
|
|
/// not 32.
|
|
|
|
|
pub fn base58_decode(input: &str) -> Option<[u8; 32]> {
|
|
|
|
|
let mut zeros = 0usize;
|
|
|
|
|
let mut chars = input.chars();
|
|
|
|
|
let mut first_nonzero = None;
|
|
|
|
|
for c in chars.by_ref() {
|
|
|
|
|
if c == B58_ALPHABET[0] as char {
|
|
|
|
|
zeros += 1;
|
|
|
|
|
} else {
|
|
|
|
|
first_nonzero = Some(c);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut buf: Vec<u8> = Vec::with_capacity(input.len());
|
2026-06-23 15:42:28 +00:00
|
|
|
let rest = first_nonzero.into_iter().chain(chars).collect::<String>();
|
2026-05-21 19:48:02 +00:00
|
|
|
for c in rest.chars() {
|
|
|
|
|
let v = B58_ALPHABET.iter().position(|&a| a == c as u8)? as u32;
|
|
|
|
|
let mut carry = v;
|
|
|
|
|
for digit in buf.iter_mut() {
|
|
|
|
|
carry += (*digit as u32) * 58;
|
|
|
|
|
*digit = (carry & 0xff) as u8;
|
|
|
|
|
carry >>= 8;
|
|
|
|
|
}
|
|
|
|
|
while carry > 0 {
|
|
|
|
|
buf.push((carry & 0xff) as u8);
|
|
|
|
|
carry >>= 8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let mut out = vec![0u8; zeros];
|
|
|
|
|
for b in buf.into_iter().rev() {
|
|
|
|
|
out.push(b);
|
|
|
|
|
}
|
|
|
|
|
if out.len() != 32 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let mut arr = [0u8; 32];
|
|
|
|
|
arr.copy_from_slice(&out);
|
|
|
|
|
Some(arr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Persistent keypair file ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
|
|
|
struct KeyFile {
|
|
|
|
|
secret_key_hex: String,
|
2026-02-19 14:39:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
/// Load a keypair from `path`, or generate and write a new one if absent.
|
|
|
|
|
///
|
|
|
|
|
/// File format: JSON `{"secret_key_hex": "<64 hex chars>"}`.
|
|
|
|
|
///
|
|
|
|
|
/// # Panics
|
2026-02-19 14:39:33 +00:00
|
|
|
///
|
2026-05-21 19:48:02 +00:00
|
|
|
/// Panics if `path` exists but cannot be parsed, or if writing a freshly
|
|
|
|
|
/// generated keypair fails. The CLI uses this on startup; a corrupt
|
|
|
|
|
/// identity file is operator error worth crashing on.
|
2026-02-19 14:39:33 +00:00
|
|
|
pub fn load_or_generate_keypair(path: &Path) -> Keypair {
|
|
|
|
|
if path.exists() {
|
2026-05-21 19:48:02 +00:00
|
|
|
let raw = fs::read_to_string(path)
|
|
|
|
|
.unwrap_or_else(|e| panic!("failed to read keypair at {}: {e}", path.display()));
|
|
|
|
|
let file: KeyFile = serde_json::from_str(&raw)
|
|
|
|
|
.unwrap_or_else(|e| panic!("invalid keypair file at {}: {e}", path.display()));
|
|
|
|
|
let bytes = hex_decode(&file.secret_key_hex)
|
|
|
|
|
.unwrap_or_else(|| panic!("keypair file {} has invalid hex", path.display()));
|
|
|
|
|
if bytes.len() != 32 {
|
|
|
|
|
panic!(
|
|
|
|
|
"keypair file {} has wrong length ({} bytes, expected 32)",
|
|
|
|
|
path.display(),
|
|
|
|
|
bytes.len()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Keypair::from_bytes(&bytes)
|
2026-02-19 14:39:33 +00:00
|
|
|
} else {
|
2026-05-21 19:48:02 +00:00
|
|
|
let kp = Keypair::generate();
|
|
|
|
|
let file = KeyFile {
|
|
|
|
|
secret_key_hex: hex_encode(&kp.secret_bytes()),
|
|
|
|
|
};
|
2026-06-23 15:42:28 +00:00
|
|
|
let raw = serde_json::to_string_pretty(&file).expect("KeyFile is always serializable");
|
2026-02-19 14:39:33 +00:00
|
|
|
if let Some(parent) = path.parent() {
|
2026-06-23 15:42:28 +00:00
|
|
|
fs::create_dir_all(parent)
|
|
|
|
|
.unwrap_or_else(|e| panic!("failed to create dir {}: {e}", parent.display()));
|
2026-02-19 14:39:33 +00:00
|
|
|
}
|
2026-05-21 19:48:02 +00:00
|
|
|
fs::write(path, raw)
|
|
|
|
|
.unwrap_or_else(|e| panic!("failed to write keypair at {}: {e}", path.display()));
|
|
|
|
|
kp
|
2026-02-19 14:39:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-02-19 14:39:33 +00:00
|
|
|
|
2026-05-21 19:48:02 +00:00
|
|
|
#[test]
|
|
|
|
|
fn hex_roundtrip_arbitrary_lengths() {
|
|
|
|
|
for bytes in &[
|
|
|
|
|
vec![],
|
|
|
|
|
vec![0x00, 0xff, 0x7e],
|
|
|
|
|
(0u8..=255).collect::<Vec<_>>(),
|
|
|
|
|
] {
|
|
|
|
|
let s = hex_encode(bytes);
|
|
|
|
|
assert_eq!(hex_decode(&s).as_deref(), Some(bytes.as_slice()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn base58_roundtrip_node_id_width() {
|
2026-06-23 15:42:28 +00:00
|
|
|
let inputs: [[u8; 32]; 3] = [[0u8; 32], [0xff; 32], {
|
|
|
|
|
let mut a = [0u8; 32];
|
|
|
|
|
for (i, slot) in a.iter_mut().enumerate() {
|
|
|
|
|
*slot = (i as u8).wrapping_mul(31);
|
|
|
|
|
}
|
|
|
|
|
a
|
|
|
|
|
}];
|
2026-05-21 19:48:02 +00:00
|
|
|
for bytes in inputs {
|
|
|
|
|
let s = base58_encode(&bytes);
|
|
|
|
|
assert_eq!(base58_decode(&s), Some(bytes));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn base58_rejects_non_32_byte_payloads() {
|
|
|
|
|
assert!(base58_decode("").is_none());
|
|
|
|
|
// 8 bytes encoded — should reject because the decoded width is wrong.
|
|
|
|
|
let small = base58_encode(b"abcdefgh");
|
|
|
|
|
assert!(base58_decode(&small).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_or_generate_is_stable_across_calls() {
|
|
|
|
|
let dir = std::env::temp_dir().join(format!(
|
|
|
|
|
"swactor-transport-test-{}-{}",
|
|
|
|
|
std::process::id(),
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_nanos()
|
|
|
|
|
));
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
let key_path = dir.join("node.key.json");
|
|
|
|
|
|
|
|
|
|
let first = load_or_generate_keypair(&key_path);
|
|
|
|
|
let second = load_or_generate_keypair(&key_path);
|
|
|
|
|
assert_eq!(first.node_id(), second.node_id());
|
|
|
|
|
|
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
|
|
|
}
|
2026-02-19 14:39:33 +00:00
|
|
|
}
|