Watch
1
0
Fork
You've already forked souveraine
0

identity: add signed node commission records

This commit is contained in:
Fimeg 2026-07-12 15:39:40 -04:00
commit a2731b418e
3 changed files with 272 additions and 3 deletions

View file

@ -29,7 +29,7 @@ use rand::RngCore;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use tracing::{info, warn};
use super::bifrost::{
ChatCompletionRequest, CompletionResult, InferenceStrain, Message, ParsedToolCall,
@ -858,7 +858,7 @@ fn classify_status(status: reqwest::StatusCode) -> ErrorClass {
mod tests {
use super::*;
use crate::bridge::bifrost::{
MessageToolCall, MessageToolCallFunction, ToolFunction,
ContentValue, MessageToolCall, MessageToolCallFunction, ToolFunction,
};
#[test]
@ -887,7 +887,7 @@ mod tests {
Message::text("user", "please read /etc/hostname"),
Message {
role: "assistant".into(),
content: String::new(),
content: ContentValue::Text(String::new()),
tool_calls: Some(vec![MessageToolCall {
id: "call_1".into(),
tool_type: "function".into(),

View file

@ -1,3 +1,4 @@
pub mod node;
pub mod seed;
pub mod summon;

268
src/core/identity/node.rs Normal file
View file

@ -0,0 +1,268 @@
//! Versioned identity records for a commissioned federation node.
//!
//! These types are intentionally transport- and storage-neutral. They define
//! what a future `node invite` / `node join` ceremony signs without yet
//! selecting a commissioning authority, a RedFlag integration, or a token
//! format.
use chrono::{DateTime, Utc};
use ed25519_dalek::Signature;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::SeedId;
/// First version of the node commission payload.
pub const NODE_COMMISSION_VERSION: u16 = 1;
/// An immutable, randomly minted identifier for one physical federation node.
///
/// This is deliberately distinct from a hostname or OS machine ID. Those may
/// change; a node ID names the branch and the commission for its lifetime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(Uuid);
impl NodeId {
pub fn generate() -> Self {
Self(Uuid::new_v4())
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// A node-generated request to join one agent's federation.
///
/// The node private key stays on the device. `hardware_key_fingerprint` is an
/// opaque RedFlag-facing reference for now; its concrete attestation format is
/// a later integration decision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeJoinRequest {
pub version: u16,
pub node_id: NodeId,
pub agent_root_pubkey_hex: String,
pub node_pubkey_hex: String,
pub hardware_key_fingerprint: String,
pub label: String,
pub requested_at: DateTime<Utc>,
}
impl NodeJoinRequest {
pub fn new(
agent_root_pubkey_hex: String,
node_pubkey_hex: String,
hardware_key_fingerprint: String,
label: String,
) -> Self {
Self {
version: NODE_COMMISSION_VERSION,
node_id: NodeId::generate(),
agent_root_pubkey_hex,
node_pubkey_hex,
hardware_key_fingerprint,
label,
requested_at: Utc::now(),
}
}
pub fn validate(&self) -> Result<(), NodeIdentityError> {
if self.version != NODE_COMMISSION_VERSION {
return Err(NodeIdentityError::UnsupportedVersion(self.version));
}
validate_pubkey("agent root", &self.agent_root_pubkey_hex)?;
validate_pubkey("node", &self.node_pubkey_hex)?;
if self.hardware_key_fingerprint.trim().is_empty() {
return Err(NodeIdentityError::MissingHardwareFingerprint);
}
if self.label.trim().is_empty() || self.label.chars().count() > 64 {
return Err(NodeIdentityError::InvalidLabel);
}
Ok(())
}
}
/// A commission issued by an authority trusted by the agent root.
///
/// The commissioner may initially be the root key. Keeping its public key
/// explicit permits a later RedFlag-backed delegated authority without
/// changing the signed request shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeCommission {
pub request: NodeJoinRequest,
pub commissioner_pubkey_hex: String,
pub issued_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub signature_hex: String,
}
impl NodeCommission {
pub fn issue(
commissioner: &SeedId,
request: NodeJoinRequest,
expires_at: Option<DateTime<Utc>>,
) -> Result<Self, NodeIdentityError> {
request.validate()?;
let commissioner_pubkey_hex = commissioner.public_key_hex();
let issued_at = Utc::now();
let bytes =
commission_signing_bytes(&request, &commissioner_pubkey_hex, issued_at, expires_at);
let signature_hex = hex::encode(commissioner.sign(&bytes).to_bytes());
Ok(Self {
request,
commissioner_pubkey_hex,
issued_at,
expires_at,
signature_hex,
})
}
/// Verify payload structure, expiry, and the commissioner's signature.
/// Trusting that commissioner for this agent is deliberately the caller's
/// policy decision; this method only establishes cryptographic validity.
pub fn verify(&self, now: DateTime<Utc>) -> Result<(), NodeIdentityError> {
self.request.validate()?;
validate_pubkey("commissioner", &self.commissioner_pubkey_hex)?;
if self.expires_at.is_some_and(|expiry| expiry <= now) {
return Err(NodeIdentityError::Expired);
}
let pubkey: [u8; 32] = hex::decode(&self.commissioner_pubkey_hex)
.ok()
.and_then(|bytes| bytes.try_into().ok())
.ok_or(NodeIdentityError::InvalidPublicKey("commissioner"))?;
let signature_bytes: [u8; 64] = hex::decode(&self.signature_hex)
.ok()
.and_then(|bytes| bytes.try_into().ok())
.ok_or(NodeIdentityError::InvalidSignature)?;
let signature = Signature::from_bytes(&signature_bytes);
let bytes = commission_signing_bytes(
&self.request,
&self.commissioner_pubkey_hex,
self.issued_at,
self.expires_at,
);
if SeedId::verify_with_pubkey(&pubkey, &bytes, &signature) {
Ok(())
} else {
Err(NodeIdentityError::InvalidSignature)
}
}
}
/// The canonical bytes covered by a node commission.
pub fn commission_signing_bytes(
request: &NodeJoinRequest,
commissioner_pubkey_hex: &str,
issued_at: DateTime<Utc>,
expires_at: Option<DateTime<Utc>>,
) -> Vec<u8> {
serde_json::to_vec(&(
NODE_COMMISSION_VERSION,
request,
commissioner_pubkey_hex,
issued_at,
expires_at,
))
.expect("node commission payload must serialize")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeIdentityError {
UnsupportedVersion(u16),
InvalidPublicKey(&'static str),
MissingHardwareFingerprint,
InvalidLabel,
Expired,
InvalidSignature,
}
impl std::fmt::Display for NodeIdentityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedVersion(version) => {
write!(f, "unsupported node commission version {version}")
}
Self::InvalidPublicKey(kind) => write!(f, "invalid {kind} public key"),
Self::MissingHardwareFingerprint => write!(f, "missing hardware key fingerprint"),
Self::InvalidLabel => {
write!(f, "node label must be non-empty and at most 64 characters")
}
Self::Expired => write!(f, "node commission has expired"),
Self::InvalidSignature => write!(f, "invalid node commission signature"),
}
}
}
impl std::error::Error for NodeIdentityError {}
fn validate_pubkey(kind: &'static str, public_key_hex: &str) -> Result<(), NodeIdentityError> {
match hex::decode(public_key_hex)
.ok()
.and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
{
Some(_) => Ok(()),
None => Err(NodeIdentityError::InvalidPublicKey(kind)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(agent: &SeedId, node: &SeedId) -> NodeJoinRequest {
NodeJoinRequest::new(
agent.public_key_hex(),
node.public_key_hex(),
"redflag:device-public-key:abc123".to_string(),
"phone".to_string(),
)
}
#[test]
fn commission_round_trip_verifies() {
let authority = SeedId::generate();
let node = SeedId::generate();
let commission =
NodeCommission::issue(&authority, request(&authority, &node), None).unwrap();
assert!(commission.verify(Utc::now()).is_ok());
assert_eq!(commission.request.label, "phone");
}
#[test]
fn changing_a_commissioned_field_invalidates_the_signature() {
let authority = SeedId::generate();
let node = SeedId::generate();
let mut commission =
NodeCommission::issue(&authority, request(&authority, &node), None).unwrap();
commission.request.label = "hearth".to_string();
assert_eq!(
commission.verify(Utc::now()),
Err(NodeIdentityError::InvalidSignature)
);
}
#[test]
fn expired_commission_fails_closed() {
let authority = SeedId::generate();
let node = SeedId::generate();
let commission = NodeCommission::issue(
&authority,
request(&authority, &node),
Some(Utc::now() - chrono::Duration::seconds(1)),
)
.unwrap();
assert_eq!(
commission.verify(Utc::now()),
Err(NodeIdentityError::Expired)
);
}
}