huskies: merge 727_story_ed25519_node_identity_keypair_generation_persistence_and_identity_endpoint
This commit is contained in:
Generated
+1
@@ -2296,6 +2296,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
|
"ed25519-dalek",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"fastcrypto",
|
"fastcrypto",
|
||||||
"filetime",
|
"filetime",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ libsqlite3-sys = { version = "0.35.0", features = ["bundled"] }
|
|||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
wait-timeout = "0.2.1"
|
wait-timeout = "0.2.1"
|
||||||
bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] }
|
bft-json-crdt = { path = "../crates/bft-json-crdt", default-features = false, features = ["bft"] }
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
fastcrypto = "0.1.8"
|
fastcrypto = "0.1.8"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! Node identity endpoint — exposes this node's Ed25519 public key.
|
||||||
|
//!
|
||||||
|
//! `GET /identity` returns the node's ID and public key as JSON. No
|
||||||
|
//! authentication is required; only the public half of the keypair is
|
||||||
|
//! disclosed.
|
||||||
|
|
||||||
|
use poem::handler;
|
||||||
|
use poem::web::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// JSON response body for `GET /identity`.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct IdentityResponse {
|
||||||
|
/// 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: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /identity` — return this node's Ed25519 public key.
|
||||||
|
///
|
||||||
|
/// Returns `{"node_id": "<64-hex>", "pubkey": "<64-hex>"}`.
|
||||||
|
/// No authentication required; the private key is never exposed.
|
||||||
|
#[handler]
|
||||||
|
pub fn identity_handler() -> Json<IdentityResponse> {
|
||||||
|
match crate::node_identity::get_identity() {
|
||||||
|
Some(id) => Json(IdentityResponse {
|
||||||
|
node_id: id.node_id.clone(),
|
||||||
|
pubkey: id.pubkey_hex.clone(),
|
||||||
|
}),
|
||||||
|
None => Json(IdentityResponse {
|
||||||
|
node_id: "uninitialized".to_string(),
|
||||||
|
pubkey: "uninitialized".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use poem::{Route, get, test::TestClient};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_endpoint_returns_json() {
|
||||||
|
// Initialise a temporary key file so get_identity() returns Some.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let key_path = tmp.path().join("node_identity.key");
|
||||||
|
crate::node_identity::init_identity(&key_path).unwrap();
|
||||||
|
|
||||||
|
let app = Route::new().at("/identity", get(identity_handler));
|
||||||
|
let cli = TestClient::new(app);
|
||||||
|
let resp = cli.get("/identity").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await.value().deserialize();
|
||||||
|
let node_id = body["node_id"].as_str().unwrap();
|
||||||
|
let pubkey = body["pubkey"].as_str().unwrap();
|
||||||
|
assert_eq!(node_id.len(), 64);
|
||||||
|
assert!(node_id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
assert_eq!(node_id, pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod chat;
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod identity;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
@@ -92,6 +93,7 @@ pub fn build_routes(
|
|||||||
post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
|
post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
|
||||||
)
|
)
|
||||||
.at("/health", get(health::health))
|
.at("/health", get(health::health))
|
||||||
|
.at("/identity", get(identity::identity_handler))
|
||||||
.at(
|
.at(
|
||||||
"/oauth/authorize",
|
"/oauth/authorize",
|
||||||
get(oauth::oauth_authorize).data(oauth_state.clone()),
|
get(oauth::oauth_authorize).data(oauth_state.clone()),
|
||||||
|
|||||||
@@ -234,6 +234,24 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
log_buffer::global().set_log_file(log_dir.join("server.log"));
|
log_buffer::global().set_log_file(log_dir.join("server.log"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialise the node's Ed25519 identity keypair (file-based, mode 0600).
|
||||||
|
// The key is stored at .huskies/node_identity.key and persisted across
|
||||||
|
// restarts. The public key is exposed via GET /identity.
|
||||||
|
{
|
||||||
|
let key_path = app_state
|
||||||
|
.project_root
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(|root| root.join(".huskies").join("node_identity.key"))
|
||||||
|
.unwrap_or_else(|| cwd.join(".huskies").join("node_identity.key"));
|
||||||
|
if let Err(e) = node_identity::init_identity(&key_path) {
|
||||||
|
slog!("[identity] Failed to initialise node identity keypair: {e}");
|
||||||
|
} else if let Some(id) = node_identity::get_identity() {
|
||||||
|
slog!("[identity] Node ID: {}", id.node_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialise the SQLite pipeline shadow-write database and CRDT state layer.
|
// Initialise the SQLite pipeline shadow-write database and CRDT state layer.
|
||||||
// Clone the path out before the await so we don't hold the MutexGuard across
|
// Clone the path out before the await so we don't hold the MutexGuard across
|
||||||
// an await point.
|
// an await point.
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ use bft_json_crdt::keypair::{
|
|||||||
ED25519_PUBLIC_KEY_LENGTH, ED25519_SIGNATURE_LENGTH, Ed25519KeyPair, Ed25519PublicKey,
|
ED25519_PUBLIC_KEY_LENGTH, ED25519_SIGNATURE_LENGTH, Ed25519KeyPair, Ed25519PublicKey,
|
||||||
Ed25519Signature, sign, verify,
|
Ed25519Signature, sign, verify,
|
||||||
};
|
};
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
use fastcrypto::traits::{KeyPair, ToFromBytes};
|
use fastcrypto::traits::{KeyPair, ToFromBytes};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -133,6 +135,102 @@ pub fn public_key_hex(keypair: &Ed25519KeyPair) -> String {
|
|||||||
hex_encode(keypair.public().as_bytes())
|
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 ──────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn hex_encode(bytes: &[u8]) -> String {
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
@@ -247,4 +345,84 @@ mod tests {
|
|||||||
let kp = make_keypair();
|
let kp = make_keypair();
|
||||||
assert_eq!(public_key_hex(&kp), public_key_hex(&kp));
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user