machined: MachineSigner — server signs as the machine without holding the key
All machine-seed consumers now resolve identity machined-first with a loud legacy fallback, and none of them generate. Federation envelopes are signed through MachineSigner with domain separation (souveraine-machined:v1:federation-envelope:...) so the wire format is identical whichever tier holds the key; verify reconstructs the same frame. Unsignable events are dropped loudly, never sent unsigned. identity show/sign are load-only now — reading an identity must not mint one. Round-trip + tamper tests on the envelope.
This commit is contained in:
parent
5039163a52
commit
116562a615
10 changed files with 238 additions and 67 deletions
|
|
@ -14,3 +14,4 @@
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod signer;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ pub const MAX_REQUEST_BYTES: u64 = 64 * 1024;
|
||||||
/// Version tag baked into every signature's domain separation.
|
/// Version tag baked into every signature's domain separation.
|
||||||
pub const SIGNING_CONTEXT: &str = "souveraine-machined:v1";
|
pub const SIGNING_CONTEXT: &str = "souveraine-machined:v1";
|
||||||
|
|
||||||
|
/// Domain for federation transport envelopes (`SignedEvent`).
|
||||||
|
pub const DOMAIN_FEDERATION_ENVELOPE: &str = "federation-envelope";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "op", rename_all = "snake_case")]
|
#[serde(tag = "op", rename_all = "snake_case")]
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
|
|
|
||||||
102
src/machined/signer.rs
Normal file
102
src/machined/signer.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! MachineSigner — how server code signs as the machine without knowing
|
||||||
|
//! which tier holds the key.
|
||||||
|
//!
|
||||||
|
//! Daemon mode asks souveraine-machined over its socket; the private key
|
||||||
|
//! never enters this process. Legacy mode holds the old user-tier seed
|
||||||
|
//! directly — transitional, loud at resolve time, and it signs the exact
|
||||||
|
//! same domain-separated bytes the daemon does, so the wire format is
|
||||||
|
//! identical either way. Verifiers reconstruct
|
||||||
|
//! `protocol::signing_bytes(domain, payload)` and never care where the key
|
||||||
|
//! lived.
|
||||||
|
//!
|
||||||
|
//! Neither mode generates identity. A machine with no seed is an error with
|
||||||
|
//! provisioning instructions, not a fresh key.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::{client, protocol};
|
||||||
|
use crate::core::identity::SeedId;
|
||||||
|
|
||||||
|
pub enum MachineSigner {
|
||||||
|
/// souveraine-machined answers on its socket; we hold only the pubkey.
|
||||||
|
Daemon { pubkey_hex: String },
|
||||||
|
/// Transitional: the legacy user-tier seed, held in-process.
|
||||||
|
Legacy(Arc<SeedId>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MachineSigner {
|
||||||
|
/// Resolve the machine identity: system tier first, legacy user seed as
|
||||||
|
/// a loud fallback. Blocking (one local socket round-trip / file read) —
|
||||||
|
/// call at startup, not per-event.
|
||||||
|
pub fn resolve(base: &Path) -> Result<Self> {
|
||||||
|
match client::pubkey() {
|
||||||
|
Ok((pubkey_hex, glyph)) => {
|
||||||
|
info!("machine identity via souveraine-machined: {glyph} ({pubkey_hex})");
|
||||||
|
return Ok(Self::Daemon { pubkey_hex });
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"souveraine-machined unavailable ({e:#}); falling back to legacy \
|
||||||
|
user-tier machine seed — provision the system tier with \
|
||||||
|
`sudo souveraine machine init`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy_dir = SeedId::default_dir(base);
|
||||||
|
let seed = SeedId::load(&legacy_dir).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no machine identity: souveraine-machined is not running and no legacy \
|
||||||
|
seed exists at {} — provision one with `sudo souveraine machine init --fresh`",
|
||||||
|
legacy_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self::Legacy(Arc::new(seed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pubkey_hex(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Daemon { pubkey_hex } => pubkey_hex.clone(),
|
||||||
|
Self::Legacy(seed) => seed.public_key_hex(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex Ed25519 signature over `protocol::signing_bytes(domain, payload)`.
|
||||||
|
/// Daemon mode is a blocking local socket round-trip (microseconds); a
|
||||||
|
/// dead daemon mid-run is an error the caller must treat as fail-closed —
|
||||||
|
/// never send unsigned.
|
||||||
|
pub fn sign(&self, domain: &str, payload: &[u8]) -> Result<String> {
|
||||||
|
match self {
|
||||||
|
Self::Daemon { .. } => client::sign(domain, payload),
|
||||||
|
Self::Legacy(seed) => {
|
||||||
|
let bytes = protocol::signing_bytes(domain, payload);
|
||||||
|
Ok(hex::encode(seed.sign(&bytes).to_bytes()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_signature_covers_domain_separated_bytes() {
|
||||||
|
let seed = SeedId::generate();
|
||||||
|
let pubkey = seed.public_key_bytes();
|
||||||
|
let signer = MachineSigner::Legacy(Arc::new(seed));
|
||||||
|
|
||||||
|
let sig_hex = signer.sign("test-domain", b"payload").unwrap();
|
||||||
|
let sig_bytes: [u8; 64] = hex::decode(sig_hex).unwrap().try_into().unwrap();
|
||||||
|
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||||
|
|
||||||
|
let framed = protocol::signing_bytes("test-domain", b"payload");
|
||||||
|
assert!(SeedId::verify_with_pubkey(&pubkey, &framed, &signature));
|
||||||
|
// Raw payload must NOT verify — domain separation is load-bearing.
|
||||||
|
assert!(!SeedId::verify_with_pubkey(&pubkey, b"payload", &signature));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -629,7 +629,8 @@ async fn run_identity(
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
IdentityAction::Show => {
|
IdentityAction::Show => {
|
||||||
let seed = SeedId::load_or_generate(&seed_dir)?;
|
// load, never generate: showing an identity must not mint one.
|
||||||
|
let seed = SeedId::load(&seed_dir)?;
|
||||||
if json {
|
if json {
|
||||||
let mut obj = serde_json::json!({
|
let mut obj = serde_json::json!({
|
||||||
"public_key": seed.public_key_hex(),
|
"public_key": seed.public_key_hex(),
|
||||||
|
|
@ -661,7 +662,9 @@ async fn run_identity(
|
||||||
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph());
|
println!("Generated seed identity: {} (glyph: {})", seed.public_key_hex(), seed.glyph());
|
||||||
}
|
}
|
||||||
IdentityAction::Sign { message } => {
|
IdentityAction::Sign { message } => {
|
||||||
let seed = SeedId::load_or_generate(&seed_dir)?;
|
// load, never generate: signing with a key that didn't exist a
|
||||||
|
// moment ago proves nothing.
|
||||||
|
let seed = SeedId::load(&seed_dir)?;
|
||||||
let sig = seed.sign(message.as_bytes());
|
let sig = seed.sign(message.as_bytes());
|
||||||
let sig_hex = hex::encode(sig.to_bytes());
|
let sig_hex = hex::encode(sig.to_bytes());
|
||||||
if json {
|
if json {
|
||||||
|
|
|
||||||
|
|
@ -56,15 +56,15 @@ impl AgentInventory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the instance-level seed identity. This is the owner identity for
|
/// Load the instance-level seed identity. This is the owner identity for
|
||||||
/// all agents created by this Souveraine instance. Falls back to None
|
/// all agents created by this Souveraine instance. Falls back to None —
|
||||||
/// silently — agents created without an owner can still be managed via
|
/// agents created without an owner can still be managed via per-agent
|
||||||
/// per-agent tokens.
|
/// tokens. Resolution goes machined-first with a loud legacy fallback and
|
||||||
|
/// never generates.
|
||||||
fn load_instance_seed_id(souveraine_root: &Path) -> Option<String> {
|
fn load_instance_seed_id(souveraine_root: &Path) -> Option<String> {
|
||||||
let seed_dir = crate::core::identity::SeedId::default_dir(souveraine_root);
|
match crate::machined::client::machine_pubkey_with_fallback(souveraine_root) {
|
||||||
match crate::core::identity::SeedId::load_or_generate(&seed_dir) {
|
Ok((pubkey, _source)) => Some(pubkey),
|
||||||
Ok(seed) => Some(seed.public_key_hex()),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "instance seed not available — agent ownership disabled");
|
tracing::warn!("machine identity not available — agent ownership disabled: {e:#}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use futures::SinkExt;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
use crate::core::config::{FederationRole, PeerConfig};
|
use crate::core::config::{FederationRole, PeerConfig};
|
||||||
use crate::core::identity::SeedId;
|
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
use crate::machined::signer::MachineSigner;
|
||||||
|
|
||||||
use super::types::SignedEvent;
|
use super::types::SignedEvent;
|
||||||
|
|
||||||
|
|
@ -19,16 +19,16 @@ use super::types::SignedEvent;
|
||||||
/// they arrive on this instance's own `/v1/federation/events` endpoint.
|
/// they arrive on this instance's own `/v1/federation/events` endpoint.
|
||||||
pub struct FederationBridge {
|
pub struct FederationBridge {
|
||||||
event_bus: EventBus,
|
event_bus: EventBus,
|
||||||
seed: Arc<SeedId>,
|
signer: Arc<MachineSigner>,
|
||||||
role: FederationRole,
|
role: FederationRole,
|
||||||
peers: Vec<PeerConfig>,
|
peers: Vec<PeerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationBridge {
|
impl FederationBridge {
|
||||||
pub fn new(event_bus: EventBus, seed: Arc<SeedId>, role: FederationRole) -> Self {
|
pub fn new(event_bus: EventBus, signer: Arc<MachineSigner>, role: FederationRole) -> Self {
|
||||||
Self {
|
Self {
|
||||||
event_bus,
|
event_bus,
|
||||||
seed,
|
signer,
|
||||||
role,
|
role,
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ impl FederationBridge {
|
||||||
tokio::spawn(peer_outbound_task(
|
tokio::spawn(peer_outbound_task(
|
||||||
peer,
|
peer,
|
||||||
self.event_bus.clone(),
|
self.event_bus.clone(),
|
||||||
self.seed.clone(),
|
self.signer.clone(),
|
||||||
self.role,
|
self.role,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ impl FederationBridge {
|
||||||
async fn peer_outbound_task(
|
async fn peer_outbound_task(
|
||||||
peer: PeerConfig,
|
peer: PeerConfig,
|
||||||
event_bus: EventBus,
|
event_bus: EventBus,
|
||||||
seed: Arc<SeedId>,
|
signer: Arc<MachineSigner>,
|
||||||
role: FederationRole,
|
role: FederationRole,
|
||||||
) {
|
) {
|
||||||
let endpoint = federation_endpoint(&peer.url);
|
let endpoint = federation_endpoint(&peer.url);
|
||||||
|
|
@ -80,15 +80,24 @@ async fn peer_outbound_task(
|
||||||
payload: Some(serde_json::json!({
|
payload: Some(serde_json::json!({
|
||||||
"federation_url": endpoint,
|
"federation_url": endpoint,
|
||||||
"label": None::<String>,
|
"label": None::<String>,
|
||||||
"pubkey": seed.public_key_hex(),
|
"pubkey": signer.pubkey_hex(),
|
||||||
"role": role.as_str(),
|
"role": role.as_str(),
|
||||||
})),
|
})),
|
||||||
seed_id: None,
|
seed_id: None,
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
};
|
};
|
||||||
let signed = SignedEvent::sign(&announce, &seed);
|
// Fail-closed: an unsignable announce is not sent. The peer
|
||||||
if let Ok(json) = serde_json::to_string(&signed) {
|
// would reject an unsigned frame anyway; say why here.
|
||||||
let _ = ws.send(Message::Text(json)).await;
|
match SignedEvent::sign(&announce, &signer) {
|
||||||
|
Ok(signed) => {
|
||||||
|
if let Ok(json) = serde_json::to_string(&signed) {
|
||||||
|
let _ = ws.send(Message::Text(json)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(peer = %endpoint, error = %e,
|
||||||
|
"federation: cannot sign announce — machine signer unavailable");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut rx = event_bus.subscribe();
|
let mut rx = event_bus.subscribe();
|
||||||
|
|
@ -109,7 +118,14 @@ async fn peer_outbound_task(
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let signed = SignedEvent::sign(&event, &seed);
|
let signed = match SignedEvent::sign(&event, &signer) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(peer = %endpoint, error = %e,
|
||||||
|
"federation: cannot sign event — dropped, never sent unsigned");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let json = match serde_json::to_string(&signed) {
|
let json = match serde_json::to_string(&signed) {
|
||||||
Ok(j) => j,
|
Ok(j) => j,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use crate::core::config::PeerConfig;
|
use crate::core::config::PeerConfig;
|
||||||
use crate::core::identity::SeedId;
|
use crate::core::identity::SeedId;
|
||||||
use crate::core::nervous::SensorEvent;
|
use crate::core::nervous::SensorEvent;
|
||||||
|
use crate::machined::protocol::{signing_bytes, DOMAIN_FEDERATION_ENVELOPE};
|
||||||
|
use crate::machined::signer::MachineSigner;
|
||||||
|
|
||||||
/// Return whether an inbound transport signer is explicitly trusted by this
|
/// Return whether an inbound transport signer is explicitly trusted by this
|
||||||
/// instance's federation configuration. A valid signature only establishes
|
/// instance's federation configuration. A valid signature only establishes
|
||||||
|
|
@ -17,12 +19,15 @@ pub fn signer_is_trusted(peers: &[PeerConfig], signer_pubkey_hex: &str) -> bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [`SensorEvent`] carried between instances, Ed25519-signed by its origin.
|
/// A [`SensorEvent`] carried between instances, Ed25519-signed by its origin.
|
||||||
/// The signature covers the canonical JSON of `event` as it was at the
|
/// The signature covers the domain-separated frame
|
||||||
/// sender — verified before the event is allowed onto the local bus.
|
/// `signing_bytes(DOMAIN_FEDERATION_ENVELOPE, canonical_json(event))` — so an
|
||||||
|
/// envelope signature can never be replayed as any other payload class the
|
||||||
|
/// machine key signs, and the wire format is identical whether the key lives
|
||||||
|
/// in souveraine-machined or a legacy user-tier seed.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct SignedEvent {
|
pub struct SignedEvent {
|
||||||
pub event: SensorEvent,
|
pub event: SensorEvent,
|
||||||
/// Hex-encoded Ed25519 signature over `serde_json::to_vec(&event)`.
|
/// Hex-encoded Ed25519 signature over the domain-separated frame.
|
||||||
pub signature_hex: String,
|
pub signature_hex: String,
|
||||||
/// Hex-encoded Ed25519 public key of the signing instance.
|
/// Hex-encoded Ed25519 public key of the signing instance.
|
||||||
pub signer_pubkey_hex: String,
|
pub signer_pubkey_hex: String,
|
||||||
|
|
@ -30,14 +35,16 @@ pub struct SignedEvent {
|
||||||
|
|
||||||
impl SignedEvent {
|
impl SignedEvent {
|
||||||
/// Wrap and sign a locally-originated event for transmission to a peer.
|
/// Wrap and sign a locally-originated event for transmission to a peer.
|
||||||
pub fn sign(event: &SensorEvent, seed: &SeedId) -> Self {
|
/// Fails if the signer cannot produce a signature (daemon died mid-run);
|
||||||
let bytes = serde_json::to_vec(event).unwrap_or_default();
|
/// callers must treat that as fail-closed — never send unsigned.
|
||||||
let signature = seed.sign(&bytes);
|
pub fn sign(event: &SensorEvent, signer: &MachineSigner) -> anyhow::Result<Self> {
|
||||||
Self {
|
let canonical = serde_json::to_vec(event)?;
|
||||||
|
let signature_hex = signer.sign(DOMAIN_FEDERATION_ENVELOPE, &canonical)?;
|
||||||
|
Ok(Self {
|
||||||
event: event.clone(),
|
event: event.clone(),
|
||||||
signature_hex: hex::encode(signature.to_bytes()),
|
signature_hex,
|
||||||
signer_pubkey_hex: seed.public_key_hex(),
|
signer_pubkey_hex: signer.pubkey_hex(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify the signature. On success, returns the inner event with its
|
/// Verify the signature. On success, returns the inner event with its
|
||||||
|
|
@ -49,8 +56,9 @@ impl SignedEvent {
|
||||||
let sig_bytes = decode_array::<64>(&self.signature_hex)?;
|
let sig_bytes = decode_array::<64>(&self.signature_hex)?;
|
||||||
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||||
let canonical = serde_json::to_vec(&self.event).ok()?;
|
let canonical = serde_json::to_vec(&self.event).ok()?;
|
||||||
|
let framed = signing_bytes(DOMAIN_FEDERATION_ENVELOPE, &canonical);
|
||||||
|
|
||||||
if SeedId::verify_with_pubkey(&pubkey, &canonical, &signature) {
|
if SeedId::verify_with_pubkey(&pubkey, &framed, &signature) {
|
||||||
let mut event = self.event.clone();
|
let mut event = self.event.clone();
|
||||||
event.seed_id = Some(self.signer_pubkey_hex.clone());
|
event.seed_id = Some(self.signer_pubkey_hex.clone());
|
||||||
Some(event)
|
Some(event)
|
||||||
|
|
@ -68,8 +76,37 @@ fn decode_array<const N: usize>(hex_str: &str) -> Option<[u8; N]> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::signer_is_trusted;
|
use super::{signer_is_trusted, MachineSigner, SignedEvent};
|
||||||
use crate::core::config::PeerConfig;
|
use crate::core::config::PeerConfig;
|
||||||
|
use crate::core::identity::SeedId;
|
||||||
|
use crate::core::nervous::SensorEvent;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_round_trips_and_stamps_signer() {
|
||||||
|
let seed = SeedId::generate();
|
||||||
|
let pubkey_hex = seed.public_key_hex();
|
||||||
|
let signer = MachineSigner::Legacy(Arc::new(seed));
|
||||||
|
let event = SensorEvent {
|
||||||
|
sensor_name: "test".into(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
event_type: "unit".into(),
|
||||||
|
target: None,
|
||||||
|
urgency: 0.1,
|
||||||
|
payload: None,
|
||||||
|
seed_id: None,
|
||||||
|
reply_to: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope = SignedEvent::sign(&event, &signer).unwrap();
|
||||||
|
let verified = envelope.verify().expect("envelope must verify");
|
||||||
|
assert_eq!(verified.seed_id.as_deref(), Some(pubkey_hex.as_str()));
|
||||||
|
|
||||||
|
// Tampering with the event breaks the signature.
|
||||||
|
let mut forged = envelope.clone();
|
||||||
|
forged.event.event_type = "forged".into();
|
||||||
|
assert!(forged.verify().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn configured_peer_key_is_required_for_trust() {
|
fn configured_peer_key_is_required_for_trust() {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ use axum::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::core::config::FederationConfig;
|
use crate::core::config::FederationConfig;
|
||||||
use crate::core::identity::{verify_summon, SeedId};
|
use crate::core::identity::verify_summon;
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
use crate::machined::signer::MachineSigner;
|
||||||
use crate::server::federation::{FederationBridge, SignedEvent};
|
use crate::server::federation::{FederationBridge, SignedEvent};
|
||||||
|
|
||||||
/// Shared state for the lite route set — only what the minimal handlers and
|
/// Shared state for the lite route set — only what the minimal handlers and
|
||||||
|
|
@ -61,8 +62,10 @@ impl LiteListener {
|
||||||
/// bridge to configured peers, and spawn the summon-wake watcher.
|
/// bridge to configured peers, and spawn the summon-wake watcher.
|
||||||
pub fn new(config: &FederationConfig, base: PathBuf) -> anyhow::Result<Arc<Self>> {
|
pub fn new(config: &FederationConfig, base: PathBuf) -> anyhow::Result<Arc<Self>> {
|
||||||
let event_bus = EventBus::default();
|
let event_bus = EventBus::default();
|
||||||
let seed = SeedId::load_or_generate(&SeedId::default_dir(&base))?;
|
// machined first, legacy seed loudly; never generates. A listener
|
||||||
let local_seed_id = seed.public_key_hex();
|
// with no identity cannot exist — it has nothing to answer as.
|
||||||
|
let signer = Arc::new(MachineSigner::resolve(&base)?);
|
||||||
|
let local_seed_id = signer.pubkey_hex();
|
||||||
|
|
||||||
let listener = Arc::new(Self {
|
let listener = Arc::new(Self {
|
||||||
event_bus: event_bus.clone(),
|
event_bus: event_bus.clone(),
|
||||||
|
|
@ -74,7 +77,7 @@ impl LiteListener {
|
||||||
|
|
||||||
// Outbound federation bridge — so this instance can also reach peers.
|
// Outbound federation bridge — so this instance can also reach peers.
|
||||||
if !config.peers.is_empty() {
|
if !config.peers.is_empty() {
|
||||||
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed), config.role);
|
let mut bridge = FederationBridge::new(event_bus.clone(), signer, config.role);
|
||||||
for peer in &config.peers {
|
for peer in &config.peers {
|
||||||
bridge.add_peer(peer.clone());
|
bridge.add_peer(peer.clone());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
use crate::bridge::{build_registry, ProviderRegistry};
|
use crate::bridge::{build_registry, ProviderRegistry};
|
||||||
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
|
use crate::core::compact::{CompactionEngine, DefaultCompactionEngine, UtcClock};
|
||||||
use crate::core::config::ConsciousnessConfig;
|
use crate::core::config::ConsciousnessConfig;
|
||||||
use crate::core::identity::SeedId;
|
|
||||||
use crate::server::gitea_memory::GiteaMemory;
|
use crate::server::gitea_memory::GiteaMemory;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -66,6 +65,10 @@ pub struct SouveraineServer {
|
||||||
/// This instance's Ed25519 public key hex — used to filter self-announcements
|
/// This instance's Ed25519 public key hex — used to filter self-announcements
|
||||||
/// from the device registry. Loaded at construction; None if seed unavailable.
|
/// from the device registry. Loaded at construction; None if seed unavailable.
|
||||||
pub local_seed_id: Option<String>,
|
pub local_seed_id: Option<String>,
|
||||||
|
/// The machine signer behind `local_seed_id` — machined daemon or legacy
|
||||||
|
/// seed. Resolved once at construction; the federation bridge signs
|
||||||
|
/// envelopes through it.
|
||||||
|
pub machine_signer: Option<Arc<crate::machined::signer::MachineSigner>>,
|
||||||
/// Per-conversation turn backchannel. Holds the cancel token for the
|
/// Per-conversation turn backchannel. Holds the cancel token for the
|
||||||
/// turn in flight and a persistent interjection queue, so any surface
|
/// turn in flight and a persistent interjection queue, so any surface
|
||||||
/// can interrupt (`POST /v1/conversations/:id/cancel`) or slip a note
|
/// can interrupt (`POST /v1/conversations/:id/cancel`) or slip a note
|
||||||
|
|
@ -273,19 +276,25 @@ impl SouveraineServer {
|
||||||
gitea_url: std::env::var("SOUVERAINE_GITEA_URL").ok(),
|
gitea_url: std::env::var("SOUVERAINE_GITEA_URL").ok(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Device registry ──
|
// ── Machine identity ──
|
||||||
|
// Resolved once: souveraine-machined (system tier) first, legacy
|
||||||
|
// user-tier seed as a loud fallback. Never generates — a box with no
|
||||||
|
// identity runs federation-less with a warning, it does not silently
|
||||||
|
// mint a key.
|
||||||
let souveraine_base = dirs::home_dir()
|
let souveraine_base = dirs::home_dir()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.join(".souveraine");
|
.join(".souveraine");
|
||||||
let local_seed_id = match crate::core::identity::SeedId::load_or_generate(
|
let machine_signer = match crate::machined::signer::MachineSigner::resolve(&souveraine_base) {
|
||||||
&crate::core::identity::SeedId::default_dir(&souveraine_base),
|
Ok(signer) => Some(Arc::new(signer)),
|
||||||
) {
|
Err(e) => {
|
||||||
Ok(seed) => {
|
tracing::warn!(
|
||||||
let pubkey = seed.public_key_hex();
|
"no machine identity — device registry, summon handling, and \
|
||||||
Some(pubkey)
|
federation are disabled: {e:#}"
|
||||||
|
);
|
||||||
|
None
|
||||||
}
|
}
|
||||||
Err(_) => None,
|
|
||||||
};
|
};
|
||||||
|
let local_seed_id = machine_signer.as_ref().map(|s| s.pubkey_hex());
|
||||||
let sb_for_device_reg = souveraine_base.clone();
|
let sb_for_device_reg = souveraine_base.clone();
|
||||||
let local_role = config.federation.role;
|
let local_role = config.federation.role;
|
||||||
let device_registry = local_seed_id.clone().map(|seed_id| {
|
let device_registry = local_seed_id.clone().map(|seed_id| {
|
||||||
|
|
@ -312,19 +321,13 @@ impl SouveraineServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Summon handler ──
|
// ── Summon handler ──
|
||||||
let sb_for_seed = souveraine_base.clone();
|
let summon_handler = match (&local_seed_id, &machine_signer) {
|
||||||
let local_seed: Option<Arc<SeedId>> = local_seed_id.as_ref().and_then(|_| {
|
(Some(seed_id), Some(signer)) => {
|
||||||
crate::core::identity::SeedId::load_or_generate(
|
|
||||||
&crate::core::identity::SeedId::default_dir(&sb_for_seed),
|
|
||||||
).ok().map(Arc::new)
|
|
||||||
});
|
|
||||||
let summon_handler = match (&local_seed_id, &local_seed) {
|
|
||||||
(Some(seed_id), Some(seed)) => {
|
|
||||||
let handler = Arc::new(
|
let handler = Arc::new(
|
||||||
summon_handler::SummonHandler::new(
|
summon_handler::SummonHandler::new(
|
||||||
seed_id.clone(),
|
seed_id.clone(),
|
||||||
event_bus.clone(),
|
event_bus.clone(),
|
||||||
seed.clone(),
|
signer.clone(),
|
||||||
souveraine_base.clone(),
|
souveraine_base.clone(),
|
||||||
config.federation.auto_wake,
|
config.federation.auto_wake,
|
||||||
),
|
),
|
||||||
|
|
@ -351,6 +354,7 @@ impl SouveraineServer {
|
||||||
device_registry,
|
device_registry,
|
||||||
summon_handler,
|
summon_handler,
|
||||||
local_seed_id,
|
local_seed_id,
|
||||||
|
machine_signer,
|
||||||
turn_signals: Arc::new(dashmap::DashMap::new()),
|
turn_signals: Arc::new(dashmap::DashMap::new()),
|
||||||
sensorium: Arc::new(Mutex::new(crate::core::sensorium::SensoriumCoordinator::new())),
|
sensorium: Arc::new(Mutex::new(crate::core::sensorium::SensoriumCoordinator::new())),
|
||||||
surface_conversations: Arc::new(StdMutex::new(HashMap::new())),
|
surface_conversations: Arc::new(StdMutex::new(HashMap::new())),
|
||||||
|
|
@ -392,14 +396,11 @@ impl SouveraineServer {
|
||||||
let fed = self.app_config.read().await.federation.clone();
|
let fed = self.app_config.read().await.federation.clone();
|
||||||
if fed.enabled && !fed.peers.is_empty() {
|
if fed.enabled && !fed.peers.is_empty() {
|
||||||
let peer_count = fed.peers.len();
|
let peer_count = fed.peers.len();
|
||||||
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
match &self.machine_signer {
|
||||||
match crate::core::identity::SeedId::load_or_generate(
|
Some(signer) => {
|
||||||
&crate::core::identity::SeedId::default_dir(&base),
|
|
||||||
) {
|
|
||||||
Ok(seed) => {
|
|
||||||
let mut bridge = federation::FederationBridge::new(
|
let mut bridge = federation::FederationBridge::new(
|
||||||
self.event_bus.clone(),
|
self.event_bus.clone(),
|
||||||
Arc::new(seed),
|
signer.clone(),
|
||||||
fed.role,
|
fed.role,
|
||||||
);
|
);
|
||||||
for peer in fed.peers {
|
for peer in fed.peers {
|
||||||
|
|
@ -408,8 +409,11 @@ impl SouveraineServer {
|
||||||
bridge.run();
|
bridge.run();
|
||||||
tracing::info!("federation bridge started ({peer_count} peer(s))");
|
tracing::info!("federation bridge started ({peer_count} peer(s))");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
None => {
|
||||||
tracing::error!("federation: failed to load seed identity: {}", e);
|
tracing::error!(
|
||||||
|
"federation: enabled in config but no machine identity is \
|
||||||
|
available — bridge not started"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ use dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::core::identity::SeedId;
|
|
||||||
use crate::core::nervous::handler::TurnInjector;
|
use crate::core::nervous::handler::TurnInjector;
|
||||||
|
use crate::machined::signer::MachineSigner;
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
|
||||||
const RESPONSE_TIMEOUT_SECS: u64 = 60;
|
const RESPONSE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
@ -51,8 +51,10 @@ pub struct SummonHandler {
|
||||||
local_seed_id: String,
|
local_seed_id: String,
|
||||||
/// The nervous system bus.
|
/// The nervous system bus.
|
||||||
event_bus: EventBus,
|
event_bus: EventBus,
|
||||||
/// Instance seed for signing outbound requests.
|
/// Machine signer for signing outbound requests. Not yet exercised —
|
||||||
seed: Arc<SeedId>,
|
/// held so the reply-signing path lands on the signer, never a raw key.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
signer: Arc<MachineSigner>,
|
||||||
/// Base path for agent memory — used to access inbox files.
|
/// Base path for agent memory — used to access inbox files.
|
||||||
souveraine_base: std::path::PathBuf,
|
souveraine_base: std::path::PathBuf,
|
||||||
/// When true, an inbound summon wakes the target agent with a background
|
/// When true, an inbound summon wakes the target agent with a background
|
||||||
|
|
@ -69,7 +71,7 @@ impl SummonHandler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
local_seed_id: String,
|
local_seed_id: String,
|
||||||
event_bus: EventBus,
|
event_bus: EventBus,
|
||||||
seed: Arc<SeedId>,
|
signer: Arc<MachineSigner>,
|
||||||
souveraine_base: std::path::PathBuf,
|
souveraine_base: std::path::PathBuf,
|
||||||
auto_wake: bool,
|
auto_wake: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
|
@ -78,7 +80,7 @@ impl SummonHandler {
|
||||||
inbound: DashMap::new(),
|
inbound: DashMap::new(),
|
||||||
local_seed_id,
|
local_seed_id,
|
||||||
event_bus,
|
event_bus,
|
||||||
seed,
|
signer,
|
||||||
souveraine_base,
|
souveraine_base,
|
||||||
auto_wake,
|
auto_wake,
|
||||||
injector: OnceLock::new(),
|
injector: OnceLock::new(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue