huskies: merge 727_story_ed25519_node_identity_keypair_generation_persistence_and_identity_endpoint

This commit is contained in:
dave
2026-04-27 18:31:29 +00:00
parent b008235d0d
commit 80661fa622
6 changed files with 262 additions and 0 deletions
+178
View File
@@ -44,8 +44,10 @@ use bft_json_crdt::keypair::{
ED25519_PUBLIC_KEY_LENGTH, ED25519_SIGNATURE_LENGTH, Ed25519KeyPair, Ed25519PublicKey,
Ed25519Signature, sign, verify,
};
use ed25519_dalek::SigningKey;
use fastcrypto::traits::{KeyPair, ToFromBytes};
use rand::RngCore;
use std::sync::OnceLock;
// ── Types ─────────────────────────────────────────────────────────────
@@ -133,6 +135,102 @@ pub fn public_key_hex(keypair: &Ed25519KeyPair) -> String {
hex_encode(keypair.public().as_bytes())
}
// ── File-based keypair persistence (ed25519-dalek) ────────────────────────
/// Node identity loaded from (or freshly generated into) a `0600` key file.
///
/// The `node_id` is the lowercase hex-encoding of the 32-byte Ed25519 public
/// key — the same value used as the CRDT author across all subsystems.
#[derive(Clone, Debug)]
pub struct NodeIdentity {
/// Node ID: lowercase hex-encoding of the 32-byte Ed25519 public key.
pub node_id: String,
/// Lowercase hex-encoding of the 32-byte Ed25519 public key.
pub pubkey_hex: String,
}
/// Global node identity, initialised once at server startup.
static IDENTITY: OnceLock<NodeIdentity> = OnceLock::new();
/// Load or create the node's Ed25519 keypair, storing it in a `0600` file.
///
/// - **First boot**: generates a new keypair with `ed25519-dalek`, writes the
/// 32-byte signing-key seed to `path` with Unix mode `0600`, then returns
/// the derived `NodeIdentity`.
/// - **Subsequent boots**: reads the 32-byte seed from `path`, reconstructs
/// the keypair deterministically, and returns the same `NodeIdentity`.
///
/// The file stores the raw 32-byte seed only. No headers, no PEM, no base64.
pub fn load_or_create_keypair_file(path: &std::path::Path) -> std::io::Result<NodeIdentity> {
let signing_key = if path.exists() {
let bytes = std::fs::read(path)?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"node identity key file must contain exactly 32 bytes",
)
})?;
SigningKey::from_bytes(&seed)
} else {
// Generate a fresh keypair and persist the seed.
let sk = SigningKey::generate(&mut rand::rngs::OsRng);
let seed = sk.to_bytes();
// Create the file with mode 0600 at creation time (Unix) so the seed
// is never visible to other users even transiently.
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(&seed)?;
}
// Non-Unix fallback: write first, then set permissions.
#[cfg(not(unix))]
{
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, &seed)?;
}
sk
};
let pubkey_bytes = signing_key.verifying_key().to_bytes();
let pubkey_hex = hex_encode(&pubkey_bytes);
Ok(NodeIdentity {
node_id: pubkey_hex.clone(),
pubkey_hex,
})
}
/// Initialise the global node identity from a key file.
///
/// Should be called once at server startup. Subsequent calls are no-ops.
pub fn init_identity(path: &std::path::Path) -> std::io::Result<()> {
if IDENTITY.get().is_none() {
let identity = load_or_create_keypair_file(path)?;
let _ = IDENTITY.set(identity);
}
Ok(())
}
/// Return a reference to the global node identity, or `None` if
/// [`init_identity`] has not yet been called.
pub fn get_identity() -> Option<&'static NodeIdentity> {
IDENTITY.get()
}
// ── Internal helpers ──────────────────────────────────────────────────
fn hex_encode(bytes: &[u8]) -> String {
@@ -247,4 +345,84 @@ mod tests {
let kp = make_keypair();
assert_eq!(public_key_hex(&kp), public_key_hex(&kp));
}
// ── File-based persistence tests ──────────────────────────────────
#[test]
fn keypair_file_creates_on_first_boot() {
let tmp = tempfile::tempdir().unwrap();
let key_path = tmp.path().join("node_identity.key");
assert!(!key_path.exists(), "key file should not exist yet");
let identity = load_or_create_keypair_file(&key_path).unwrap();
assert!(key_path.exists(), "key file should be created");
assert_eq!(identity.node_id.len(), 64);
assert!(identity.node_id.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(identity.node_id, identity.pubkey_hex);
}
#[test]
fn keypair_file_persists_across_restarts() {
let tmp = tempfile::tempdir().unwrap();
let key_path = tmp.path().join("node_identity.key");
// Simulate first boot.
let id1 = load_or_create_keypair_file(&key_path).unwrap();
// Simulate restart: load the same key file again.
let id2 = load_or_create_keypair_file(&key_path).unwrap();
assert_eq!(
id1.pubkey_hex, id2.pubkey_hex,
"pubkey must be unchanged after restart"
);
assert_eq!(
id1.node_id, id2.node_id,
"node_id must be unchanged after restart"
);
}
#[test]
fn keypair_file_generates_unique_keys() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
let id1 = load_or_create_keypair_file(&tmp1.path().join("id.key")).unwrap();
let id2 = load_or_create_keypair_file(&tmp2.path().join("id.key")).unwrap();
assert_ne!(
id1.pubkey_hex, id2.pubkey_hex,
"two independent nodes must have different keys"
);
}
#[cfg(unix)]
#[test]
fn keypair_file_has_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let key_path = tmp.path().join("node_identity.key");
load_or_create_keypair_file(&key_path).unwrap();
let metadata = std::fs::metadata(&key_path).unwrap();
let mode = metadata.permissions().mode();
// The last 9 bits: owner=rw (0o600), group=--- (0o000), other=--- (0o000).
assert_eq!(
mode & 0o777,
0o600,
"key file must have mode 0600, got {mode:#o}"
);
}
#[test]
fn keypair_file_rejects_wrong_size() {
let tmp = tempfile::tempdir().unwrap();
let key_path = tmp.path().join("bad.key");
std::fs::write(&key_path, b"tooshort").unwrap();
let result = load_or_create_keypair_file(&key_path);
assert!(result.is_err(), "should error on wrong-size key file");
}
}