feat(hololake): add signed dynamic capability routing
This commit is contained in:
parent
9d1801266c
commit
ec8bbfd604
12 changed files with 1085 additions and 17 deletions
|
|
@ -0,0 +1,50 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.dynamic-capability-routing-contract/v1",
|
||||||
|
"record_id": "HLP-NATIVE-DYNAMIC-CAPABILITY-ROUTING-001",
|
||||||
|
"state": "LOCAL_SIGNED_REGISTRY_AND_HEALTH_ROUTING_IMPLEMENTED_PRODUCTION_WRITE_ADMISSION_DISABLED",
|
||||||
|
"purpose": "Dynamically select a registered team or user-owned node for an exact capability without hosting the user's private work lake or carrying workload concurrency in HoloLake.",
|
||||||
|
"registry": {
|
||||||
|
"owner": "HOLOLAKE_NATIVE_LOCAL_CORE",
|
||||||
|
"dynamic_node_count": true,
|
||||||
|
"fixed_node_slots": false,
|
||||||
|
"endpoint_hardcoding_allowed": false,
|
||||||
|
"platform_carries_user_workload_concurrency": false,
|
||||||
|
"node_private_keys_leave_node": false,
|
||||||
|
"registry_mutation_requires_trusted_signature": true,
|
||||||
|
"signer_trust_is_external_to_registry_payload": true,
|
||||||
|
"unprovisioned_trust_fails_closed": true,
|
||||||
|
"webview_registry_or_health_mutation_allowed": false,
|
||||||
|
"production_registry_source": "REPO_012_OR_SIGNED_HOLOLAKE_BROADCAST"
|
||||||
|
},
|
||||||
|
"route_resolution": {
|
||||||
|
"required_bindings": [
|
||||||
|
"HUMAN_ID",
|
||||||
|
"PERSONA_ID",
|
||||||
|
"DOMAIN_ID",
|
||||||
|
"CAPABILITY_ID",
|
||||||
|
"NODE_PROTOCOL_PUBLIC_KEY",
|
||||||
|
"FRESH_SIGNED_HEALTH_RECEIPT"
|
||||||
|
],
|
||||||
|
"ssh_or_root_is_identity": false,
|
||||||
|
"stale_or_unsigned_health_is_eligible": false,
|
||||||
|
"write_authority_from_health_receipt": false,
|
||||||
|
"deterministic_selection": "PRIORITY_THEN_NODE_ID",
|
||||||
|
"empty_route_fails_closed": true
|
||||||
|
,"external_ai_route_requires_active_local_session": true
|
||||||
|
},
|
||||||
|
"health_receipt": {
|
||||||
|
"signature": "ED25519",
|
||||||
|
"canonical_bytes": "SCHEMA_NODE_REGISTRY_VERSION_NODE_ID_OBSERVED_AT_EXPIRES_AT_SEQUENCE_STATUS_CAPABILITIES",
|
||||||
|
"replay_sequence_strictly_increases": true,
|
||||||
|
"future_clock_skew_ms": 300000,
|
||||||
|
"status_required": "READY"
|
||||||
|
},
|
||||||
|
"five_domain_visibility": "SUBMERGED_SYSTEM_FOUNDATION_NOT_STAGE_ONE_PRIMARY_NAVIGATION",
|
||||||
|
"production_truth": {
|
||||||
|
"jd_fd_primary_system_claimed": true,
|
||||||
|
"jd_fd_primary_transport_key_bound": false,
|
||||||
|
"jd_fd_primary_write_admission_runtime": false,
|
||||||
|
"enterprise_nodes_claimed": 0,
|
||||||
|
"production_health_receipts_observed": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
15
product-source/hololake-native-desktop/docs/ARCHITECTURE.md
Normal file
15
product-source/hololake-native-desktop/docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# HoloLake Native Desktop Architecture
|
||||||
|
|
||||||
|
The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation.
|
||||||
|
|
||||||
|
## External programming AI entry
|
||||||
|
|
||||||
|
MCP may discover HoloLake, but it does not own continuity. The installed application starts a user-only Unix socket broker. A programming AI opens or resumes a HoloLake-issued local session, then uses the installed executable's `--connector` mode for newline-delimited protocol traffic. Session secrets are stored only as hashes. Events use exact cursors and idempotency keys.
|
||||||
|
|
||||||
|
## Dynamic capability routing
|
||||||
|
|
||||||
|
HoloLake stores a signed node capability registry rather than fixed server slots. A candidate route must bind the exact human, persona, domain and capability. It is eligible only while a fresh node-signed health receipt reports the capability as ready. Selection is deterministic by priority and node id.
|
||||||
|
|
||||||
|
The registry signer trust root is embedded independently from registry payloads and is currently `UNPROVISIONED_FAIL_CLOSED`. Registry and health mutation are not exposed to the webview. External AI route queries require possession of the active HoloLake local session secret. A route receipt never grants write authority; production node write admission remains a separate unimplemented gate.
|
||||||
|
|
||||||
|
HoloLake routes work but does not host the user's private work lake, pool users into a multi-tenant runtime, or carry server workload concurrency.
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# ADR 0001: Signed dynamic capability routing
|
||||||
|
|
||||||
|
Status: Accepted locally; production trust provisioning pending.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Stage one must connect a changing fleet of team and user-owned nodes without hardcoded endpoints. SSH access, root access, a URL, or a health probe cannot establish Guanghu node identity or write authority. The client must also preserve the one-human-one-owned-root-node boundary and avoid becoming a hosted concurrency layer.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use a Rust-owned, file-backed dynamic registry whose complete canonical payload is verified with an independently embedded Ed25519 trust root. Each node carries a protocol public key and explicit human, persona, domain and capability bindings. Runtime eligibility additionally requires a fresh Ed25519 health receipt from that node with a strictly increasing sequence.
|
||||||
|
|
||||||
|
Route resolution is available to external programming AI only through an authenticated active local HoloLake session. It returns a non-authorizing route receipt. Webview code cannot install registries or submit health receipts.
|
||||||
|
|
||||||
|
The embedded trust file initially contains no signer. This makes production registry installation fail closed until the registered HoloLake/JD-controller signing identity is provisioned.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The fleet can grow without client code changes or fixed server slots.
|
||||||
|
- Stale, replayed, unsigned or incorrectly bound nodes are not routable.
|
||||||
|
- Registry publication, health, route selection and write admission remain separate facts.
|
||||||
|
- Production node enrollment and write execution still require their own source, signing, deployment and readback receipts.
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"schema": "hololake.native-desktop-foundation/v1",
|
"schema": "hololake.native-desktop-foundation/v1",
|
||||||
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
|
"record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001",
|
||||||
"state": "TAURI_FOUNDATION_WITH_ACCOUNT_SINGLE_WRITER_AND_DIRECT_LOCAL_BROKER",
|
"state": "TAURI_FOUNDATION_WITH_DIRECT_LOCAL_BROKER_AND_DYNAMIC_CAPABILITY_ROUTING",
|
||||||
"canonical_shell": "TAURI_V2_RUST_REACT",
|
"canonical_shell": "TAURI_V2_RUST_REACT",
|
||||||
"product_ui_implementation_started": false,
|
"product_ui_implementation_started": false,
|
||||||
"selected_visual_direction_present": false,
|
"selected_visual_direction_present": false,
|
||||||
|
|
@ -24,6 +24,12 @@
|
||||||
"account_single_writer_kernel_implemented": true,
|
"account_single_writer_kernel_implemented": true,
|
||||||
"external_local_broker_implemented": true,
|
"external_local_broker_implemented": true,
|
||||||
"resumable_direct_local_session_kernel_implemented": true,
|
"resumable_direct_local_session_kernel_implemented": true,
|
||||||
|
"dynamic_capability_routing_contract": "contracts/dynamic-capability-routing.json",
|
||||||
|
"dynamic_capability_registry_implemented": true,
|
||||||
|
"signed_node_health_routing_implemented": true,
|
||||||
|
"dynamic_routing_trust_state": "UNPROVISIONED_FAIL_CLOSED",
|
||||||
|
"dynamic_routing_webview_mutation_exposed": false,
|
||||||
|
"production_node_write_admission_implemented": false,
|
||||||
"stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED",
|
"stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED",
|
||||||
"stage_one_internal_ai_interaction": false,
|
"stage_one_internal_ai_interaction": false,
|
||||||
"stage_one_model_api_configuration": false,
|
"stage_one_model_api_configuration": false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const contract = JSON.parse(
|
||||||
|
fs.readFileSync(new URL('../contracts/dynamic-capability-routing.json', import.meta.url), 'utf8'),
|
||||||
|
)
|
||||||
|
const foundation = JSON.parse(
|
||||||
|
fs.readFileSync(new URL('../foundation.json', import.meta.url), 'utf8'),
|
||||||
|
)
|
||||||
|
|
||||||
|
test('server capability routing is dynamic and never hardcodes a finite fleet', () => {
|
||||||
|
assert.equal(contract.registry.dynamic_node_count, true)
|
||||||
|
assert.equal(contract.registry.fixed_node_slots, false)
|
||||||
|
assert.equal(contract.registry.endpoint_hardcoding_allowed, false)
|
||||||
|
assert.equal(contract.registry.signer_trust_is_external_to_registry_payload, true)
|
||||||
|
assert.equal(contract.registry.unprovisioned_trust_fails_closed, true)
|
||||||
|
assert.equal(contract.registry.webview_registry_or_health_mutation_allowed, false)
|
||||||
|
assert.equal(contract.registry.platform_carries_user_workload_concurrency, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('routing requires exact human persona domain capability and signed health bindings', () => {
|
||||||
|
assert.deepEqual(contract.route_resolution.required_bindings, [
|
||||||
|
'HUMAN_ID',
|
||||||
|
'PERSONA_ID',
|
||||||
|
'DOMAIN_ID',
|
||||||
|
'CAPABILITY_ID',
|
||||||
|
'NODE_PROTOCOL_PUBLIC_KEY',
|
||||||
|
'FRESH_SIGNED_HEALTH_RECEIPT',
|
||||||
|
])
|
||||||
|
assert.equal(contract.route_resolution.ssh_or_root_is_identity, false)
|
||||||
|
assert.equal(contract.route_resolution.stale_or_unsigned_health_is_eligible, false)
|
||||||
|
assert.equal(contract.route_resolution.external_ai_route_requires_active_local_session, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the native mainline reports only implemented dynamic routing capabilities', () => {
|
||||||
|
assert.equal(foundation.dynamic_capability_registry_implemented, true)
|
||||||
|
assert.equal(foundation.signed_node_health_routing_implemented, true)
|
||||||
|
assert.equal(foundation.production_node_write_admission_implemented, false)
|
||||||
|
})
|
||||||
|
|
@ -1345,6 +1345,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
name = "hololake-native-desktop"
|
name = "hololake-native-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"dirs",
|
"dirs",
|
||||||
"fs2",
|
"fs2",
|
||||||
"ring",
|
"ring",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
|
base64 = "0.22"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "=2.10.2", features = [] }
|
tauri = { version = "=2.10.2", features = [] }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"schema": "hololake.dynamic-routing-trust/v1",
|
||||||
|
"state": "UNPROVISIONED_FAIL_CLOSED",
|
||||||
|
"signers": []
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
use crate::direct_local_session::{
|
use crate::direct_local_session::{
|
||||||
append_event_at, direct_session_root, open_at, resume_at, AppendSessionEventInput,
|
append_event_at, authenticate_at, direct_session_root, open_at, resume_at,
|
||||||
OpenSessionInput, ResumeSessionInput,
|
AppendSessionEventInput, AuthenticateSessionInput, OpenSessionInput, ResumeSessionInput,
|
||||||
|
};
|
||||||
|
use crate::dynamic_capability_routing::{
|
||||||
|
install_trusted_registry_at, record_health_at, resolve_at as resolve_capability_route_at,
|
||||||
|
routing_root as dynamic_routing_root, DynamicNodeRegistry, ResolveCapabilityRouteInput,
|
||||||
|
SignedNodeHealth,
|
||||||
};
|
};
|
||||||
use ring::digest::{digest, SHA256};
|
use ring::digest::{digest, SHA256};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -12,7 +17,7 @@ use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
mpsc, Arc,
|
||||||
};
|
};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -25,12 +30,16 @@ const MAX_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
pub struct DirectLocalBrokerHandle {
|
pub struct DirectLocalBrokerHandle {
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
|
worker: Option<thread::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for DirectLocalBrokerHandle {
|
impl Drop for DirectLocalBrokerHandle {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.shutdown.store(true, Ordering::Release);
|
self.shutdown.store(true, Ordering::Release);
|
||||||
let _ = UnixStream::connect(&self.socket_path);
|
let _ = UnixStream::connect(&self.socket_path);
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +54,30 @@ enum BrokerRequest {
|
||||||
OpenSession(OpenSessionInput),
|
OpenSession(OpenSessionInput),
|
||||||
ResumeSession(ResumeSessionInput),
|
ResumeSession(ResumeSessionInput),
|
||||||
AppendEvent(AppendSessionEventInput),
|
AppendEvent(AppendSessionEventInput),
|
||||||
|
ResolveCapabilityRoute(AuthenticatedRouteInput),
|
||||||
|
InstallDynamicNodeRegistry(AuthenticatedRegistryInput),
|
||||||
|
RecordSignedNodeHealth(AuthenticatedHealthInput),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct AuthenticatedRouteInput {
|
||||||
|
session: AuthenticateSessionInput,
|
||||||
|
route: ResolveCapabilityRouteInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct AuthenticatedRegistryInput {
|
||||||
|
session: AuthenticateSessionInput,
|
||||||
|
registry: DynamicNodeRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct AuthenticatedHealthInput {
|
||||||
|
session: AuthenticateSessionInput,
|
||||||
|
health: SignedNodeHealth,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -75,14 +108,16 @@ struct ConnectorDescriptor {
|
||||||
state: String,
|
state: String,
|
||||||
transport: String,
|
transport: String,
|
||||||
socket_path: String,
|
socket_path: String,
|
||||||
|
process_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(app: &AppHandle) -> Result<DirectLocalBrokerHandle, Box<dyn std::error::Error>> {
|
pub fn start(app: &AppHandle) -> Result<DirectLocalBrokerHandle, Box<dyn std::error::Error>> {
|
||||||
let session_root = direct_session_root(app).map_err(std::io::Error::other)?;
|
let session_root = direct_session_root(app).map_err(std::io::Error::other)?;
|
||||||
|
let routing_root = dynamic_routing_root(app).map_err(std::io::Error::other)?;
|
||||||
let app_data = app.path().app_data_dir()?;
|
let app_data = app.path().app_data_dir()?;
|
||||||
let descriptor_path = app_data.join("direct-local-broker-v1.json");
|
let descriptor_path = app_data.join("direct-local-broker-v1.json");
|
||||||
let socket_path = short_socket_path(&app_data);
|
let socket_path = short_socket_path(&app_data);
|
||||||
start_at(session_root, descriptor_path, socket_path).map_err(|error| error.into())
|
start_at(session_root, routing_root, descriptor_path, socket_path).map_err(|error| error.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_connector() -> Result<(), String> {
|
pub fn run_connector() -> Result<(), String> {
|
||||||
|
|
@ -98,8 +133,7 @@ pub fn run_connector() -> Result<(), String> {
|
||||||
{
|
{
|
||||||
return Err("HOLOLAKE_BROKER_DESCRIPTOR_UNSUPPORTED".into());
|
return Err("HOLOLAKE_BROKER_DESCRIPTOR_UNSUPPORTED".into());
|
||||||
}
|
}
|
||||||
let mut stream = UnixStream::connect(&descriptor.socket_path)
|
let mut stream = connect_with_startup_grace(&descriptor)?;
|
||||||
.map_err(|error| format!("HOLOLAKE_DIRECT_LOCAL_BROKER_NOT_RUNNING: {error}"))?;
|
|
||||||
let read_stream = stream
|
let read_stream = stream
|
||||||
.try_clone()
|
.try_clone()
|
||||||
.map_err(|error| format!("HOLOLAKE_CONNECTOR_CLONE_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_CONNECTOR_CLONE_FAILED: {error}"))?;
|
||||||
|
|
@ -125,6 +159,34 @@ pub fn run_connector() -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connect_with_startup_grace(descriptor: &ConnectorDescriptor) -> Result<UnixStream, String> {
|
||||||
|
if descriptor.process_id == 0 {
|
||||||
|
return Err("HOLOLAKE_BROKER_DESCRIPTOR_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
let mut last_error = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
match UnixStream::connect(&descriptor.socket_path) {
|
||||||
|
Ok(stream) => return Ok(stream),
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
last_error = Some(error);
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(format!("HOLOLAKE_DIRECT_LOCAL_BROKER_NOT_RUNNING: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"HOLOLAKE_DIRECT_LOCAL_BROKER_NOT_RUNNING: {}",
|
||||||
|
last_error
|
||||||
|
.map(|error| error.to_string())
|
||||||
|
.unwrap_or_else(|| "startup grace exhausted".into())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn connector_descriptor_path() -> Result<PathBuf, String> {
|
fn connector_descriptor_path() -> Result<PathBuf, String> {
|
||||||
if let Some(path) = std::env::var_os("HOLOLAKE_BROKER_DESCRIPTOR") {
|
if let Some(path) = std::env::var_os("HOLOLAKE_BROKER_DESCRIPTOR") {
|
||||||
return Ok(PathBuf::from(path));
|
return Ok(PathBuf::from(path));
|
||||||
|
|
@ -139,6 +201,7 @@ fn connector_descriptor_path() -> Result<PathBuf, String> {
|
||||||
|
|
||||||
fn start_at(
|
fn start_at(
|
||||||
session_root: PathBuf,
|
session_root: PathBuf,
|
||||||
|
routing_root: PathBuf,
|
||||||
descriptor_path: PathBuf,
|
descriptor_path: PathBuf,
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
) -> Result<DirectLocalBrokerHandle, String> {
|
) -> Result<DirectLocalBrokerHandle, String> {
|
||||||
|
|
@ -179,28 +242,43 @@ fn start_at(
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let worker_shutdown = Arc::clone(&shutdown);
|
let worker_shutdown = Arc::clone(&shutdown);
|
||||||
let worker_socket = socket_path.clone();
|
let worker_socket = socket_path.clone();
|
||||||
thread::Builder::new()
|
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||||||
|
let worker = thread::Builder::new()
|
||||||
.name("hololake-direct-local-broker".into())
|
.name("hololake-direct-local-broker".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
serve(listener, session_root, &worker_shutdown);
|
let _ = ready_sender.send(());
|
||||||
|
serve(listener, session_root, routing_root, &worker_shutdown);
|
||||||
let _ = fs::remove_file(worker_socket);
|
let _ = fs::remove_file(worker_socket);
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("HOLOLAKE_BROKER_THREAD_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_BROKER_THREAD_FAILED: {error}"))?;
|
||||||
|
ready_receiver
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_BROKER_STARTUP_FAILED: {error}"))?;
|
||||||
|
|
||||||
Ok(DirectLocalBrokerHandle {
|
Ok(DirectLocalBrokerHandle {
|
||||||
shutdown,
|
shutdown,
|
||||||
socket_path,
|
socket_path,
|
||||||
|
worker: Some(worker),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve(listener: UnixListener, session_root: PathBuf, shutdown: &AtomicBool) {
|
fn serve(
|
||||||
|
listener: UnixListener,
|
||||||
|
session_root: PathBuf,
|
||||||
|
routing_root: PathBuf,
|
||||||
|
shutdown: &AtomicBool,
|
||||||
|
) {
|
||||||
while !shutdown.load(Ordering::Acquire) {
|
while !shutdown.load(Ordering::Acquire) {
|
||||||
match listener.accept() {
|
match listener.accept() {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
|
if shutdown.load(Ordering::Acquire) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let root = session_root.clone();
|
let root = session_root.clone();
|
||||||
|
let routes = routing_root.clone();
|
||||||
let _ = thread::Builder::new()
|
let _ = thread::Builder::new()
|
||||||
.name("hololake-direct-local-client".into())
|
.name("hololake-direct-local-client".into())
|
||||||
.spawn(move || serve_connection(stream, &root));
|
.spawn(move || serve_connection(stream, &root, &routes));
|
||||||
}
|
}
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
thread::sleep(Duration::from_millis(25));
|
thread::sleep(Duration::from_millis(25));
|
||||||
|
|
@ -210,7 +288,7 @@ fn serve(listener: UnixListener, session_root: PathBuf, shutdown: &AtomicBool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serve_connection(mut stream: UnixStream, session_root: &Path) {
|
fn serve_connection(mut stream: UnixStream, session_root: &Path, routing_root: &Path) {
|
||||||
let read_stream = match stream.try_clone() {
|
let read_stream = match stream.try_clone() {
|
||||||
Ok(stream) => stream,
|
Ok(stream) => stream,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
|
|
@ -232,7 +310,7 @@ fn serve_connection(mut stream: UnixStream, session_root: &Path) {
|
||||||
let response = if read as u64 > MAX_REQUEST_BYTES || !bytes.ends_with(b"\n") {
|
let response = if read as u64 > MAX_REQUEST_BYTES || !bytes.ends_with(b"\n") {
|
||||||
BrokerResponse::error("HOLOLAKE_BROKER_REQUEST_TOO_LARGE")
|
BrokerResponse::error("HOLOLAKE_BROKER_REQUEST_TOO_LARGE")
|
||||||
} else {
|
} else {
|
||||||
dispatch(session_root, &bytes[..bytes.len() - 1])
|
dispatch(session_root, routing_root, &bytes[..bytes.len() - 1])
|
||||||
};
|
};
|
||||||
if serde_json::to_writer(&mut stream, &response).is_err()
|
if serde_json::to_writer(&mut stream, &response).is_err()
|
||||||
|| stream.write_all(b"\n").is_err()
|
|| stream.write_all(b"\n").is_err()
|
||||||
|
|
@ -246,7 +324,7 @@ fn serve_connection(mut stream: UnixStream, session_root: &Path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch(session_root: &Path, bytes: &[u8]) -> BrokerResponse {
|
fn dispatch(session_root: &Path, routing_root: &Path, bytes: &[u8]) -> BrokerResponse {
|
||||||
let request: BrokerRequest = match serde_json::from_slice(bytes) {
|
let request: BrokerRequest = match serde_json::from_slice(bytes) {
|
||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -266,6 +344,35 @@ fn dispatch(session_root: &Path, bytes: &[u8]) -> BrokerResponse {
|
||||||
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
||||||
BrokerRequest::AppendEvent(input) => append_event_at(session_root, input)
|
BrokerRequest::AppendEvent(input) => append_event_at(session_root, input)
|
||||||
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
.and_then(|receipt| serde_json::to_value(receipt).map_err(|error| error.to_string())),
|
||||||
|
BrokerRequest::ResolveCapabilityRoute(input) => {
|
||||||
|
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||||
|
return BrokerResponse::error(&error);
|
||||||
|
}
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"));
|
||||||
|
now.and_then(|now| resolve_capability_route_at(routing_root, input.route, now))
|
||||||
|
.and_then(|receipt| {
|
||||||
|
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
BrokerRequest::InstallDynamicNodeRegistry(input) => {
|
||||||
|
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||||
|
return BrokerResponse::error(&error);
|
||||||
|
}
|
||||||
|
install_trusted_registry_at(routing_root, input.registry).and_then(|receipt| {
|
||||||
|
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
BrokerRequest::RecordSignedNodeHealth(input) => {
|
||||||
|
if let Err(error) = authenticate_at(session_root, &input.session) {
|
||||||
|
return BrokerResponse::error(&error);
|
||||||
|
}
|
||||||
|
record_health_at(routing_root, input.health).and_then(|receipt| {
|
||||||
|
serde_json::to_value(receipt).map_err(|error| error.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(value) => BrokerResponse::success(value),
|
Ok(value) => BrokerResponse::success(value),
|
||||||
|
|
@ -351,7 +458,9 @@ mod tests {
|
||||||
let descriptor = temp.path().join("broker.json");
|
let descriptor = temp.path().join("broker.json");
|
||||||
let sessions = temp.path().join("sessions");
|
let sessions = temp.path().join("sessions");
|
||||||
fs::create_dir_all(&sessions).unwrap();
|
fs::create_dir_all(&sessions).unwrap();
|
||||||
let _broker = start_at(sessions.clone(), descriptor, socket.clone()).unwrap();
|
let routes = temp.path().join("routes");
|
||||||
|
fs::create_dir_all(&routes).unwrap();
|
||||||
|
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||||
let ticket = issue_ticket_at(
|
let ticket = issue_ticket_at(
|
||||||
&sessions,
|
&sessions,
|
||||||
IssueDiscoveryTicketInput {
|
IssueDiscoveryTicketInput {
|
||||||
|
|
@ -407,7 +516,15 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let first = start_at(sessions.clone(), descriptor.clone(), socket.clone()).unwrap();
|
let routes = temp.path().join("routes");
|
||||||
|
fs::create_dir_all(&routes).unwrap();
|
||||||
|
let first = start_at(
|
||||||
|
sessions.clone(),
|
||||||
|
routes.clone(),
|
||||||
|
descriptor.clone(),
|
||||||
|
socket.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let opened = request(
|
let opened = request(
|
||||||
&socket,
|
&socket,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
|
@ -427,7 +544,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
thread::sleep(Duration::from_millis(10));
|
thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
let _second = start_at(sessions, descriptor, socket.clone()).unwrap();
|
let _second = start_at(sessions, routes, descriptor, socket.clone()).unwrap();
|
||||||
let resumed = request(
|
let resumed = request(
|
||||||
&socket,
|
&socket,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
|
@ -442,4 +559,99 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(resumed["result"]["state"], "RESUMED");
|
assert_eq!(resumed["result"]["state"], "RESUMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn broker_route_resolution_requires_the_active_session_secret() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let socket = temp.path().join("runtime/broker.sock");
|
||||||
|
let descriptor = temp.path().join("broker.json");
|
||||||
|
let sessions = temp.path().join("sessions");
|
||||||
|
let routes = temp.path().join("routes");
|
||||||
|
fs::create_dir_all(&sessions).unwrap();
|
||||||
|
fs::create_dir_all(&routes).unwrap();
|
||||||
|
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||||
|
let response = request(
|
||||||
|
&socket,
|
||||||
|
serde_json::json!({
|
||||||
|
"operation": "RESOLVE_CAPABILITY_ROUTE",
|
||||||
|
"input": {
|
||||||
|
"session": {
|
||||||
|
"accountId": "human-1",
|
||||||
|
"sessionId": "invented-session",
|
||||||
|
"resumeSecret": "invented-secret-long-enough"
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"humanId": "human-1",
|
||||||
|
"personaId": "ICE-P-ZY001",
|
||||||
|
"domainId": "DOM-FIFTH-0001",
|
||||||
|
"capabilityId": "GH-PNCC-READ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(response["ok"], false);
|
||||||
|
assert_eq!(
|
||||||
|
response["error"],
|
||||||
|
"HOLOLAKE_DIRECT_ACTIVE_SESSION_NOT_FOUND"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_ai_still_cannot_install_registry_before_trust_is_provisioned() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let socket = temp.path().join("runtime/broker.sock");
|
||||||
|
let descriptor = temp.path().join("broker.json");
|
||||||
|
let sessions = temp.path().join("sessions");
|
||||||
|
let routes = temp.path().join("routes");
|
||||||
|
fs::create_dir_all(&sessions).unwrap();
|
||||||
|
fs::create_dir_all(&routes).unwrap();
|
||||||
|
let _broker = start_at(sessions.clone(), routes, descriptor, socket.clone()).unwrap();
|
||||||
|
let ticket = issue_ticket_at(
|
||||||
|
&sessions,
|
||||||
|
IssueDiscoveryTicketInput {
|
||||||
|
account_id: "human-1".into(),
|
||||||
|
lane_id: "DEV-1".into(),
|
||||||
|
client_instance_id: "codex-1".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let opened = request(
|
||||||
|
&socket,
|
||||||
|
serde_json::json!({
|
||||||
|
"operation": "OPEN_SESSION",
|
||||||
|
"input": {
|
||||||
|
"accountId": "human-1",
|
||||||
|
"laneId": "DEV-1",
|
||||||
|
"clientInstanceId": "codex-1",
|
||||||
|
"discoveryTicket": ticket.discovery_ticket
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let response = request(
|
||||||
|
&socket,
|
||||||
|
serde_json::json!({
|
||||||
|
"operation": "INSTALL_DYNAMIC_NODE_REGISTRY",
|
||||||
|
"input": {
|
||||||
|
"session": {
|
||||||
|
"accountId": "human-1",
|
||||||
|
"sessionId": opened["result"]["sessionId"],
|
||||||
|
"resumeSecret": opened["result"]["resumeSecret"]
|
||||||
|
},
|
||||||
|
"registry": {
|
||||||
|
"schema": "hololake.dynamic-node-registry/v1",
|
||||||
|
"registryId": "candidate-registry",
|
||||||
|
"version": 1,
|
||||||
|
"nodes": [],
|
||||||
|
"signerId": "invented-signer",
|
||||||
|
"signature": "invented-signature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(response["ok"], false);
|
||||||
|
assert_eq!(
|
||||||
|
response["error"],
|
||||||
|
"HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,14 @@ pub struct AppendSessionEventInput {
|
||||||
pub expected_previous_sequence: u64,
|
pub expected_previous_sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthenticateSessionInput {
|
||||||
|
pub account_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub resume_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DirectSessionReceipt {
|
pub struct DirectSessionReceipt {
|
||||||
|
|
@ -397,6 +405,23 @@ pub(crate) fn append_event_at(
|
||||||
Ok(event_receipt("APPENDED", &event))
|
Ok(event_receipt("APPENDED", &event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn authenticate_at(root: &Path, input: &AuthenticateSessionInput) -> Result<(), String> {
|
||||||
|
validate_identifier(&input.account_id, "ACCOUNT")?;
|
||||||
|
validate_identifier(&input.session_id, "SESSION")?;
|
||||||
|
validate_secret(&input.resume_secret, "RESUME_SECRET")?;
|
||||||
|
let account_key = sha256_hex(input.account_id.as_bytes());
|
||||||
|
require_active_session(root, &account_key, &input.session_id)?;
|
||||||
|
let path = session_path(root, &account_key, &input.session_id);
|
||||||
|
let _lock = lock_session(&path)?;
|
||||||
|
let record = read_session(&path)?;
|
||||||
|
authorize(
|
||||||
|
&record,
|
||||||
|
&account_key,
|
||||||
|
&input.session_id,
|
||||||
|
&input.resume_secret,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn session_path(root: &Path, account_key: &str, session_id: &str) -> PathBuf {
|
fn session_path(root: &Path, account_key: &str, session_id: &str) -> PathBuf {
|
||||||
root.join("accounts")
|
root.join("accounts")
|
||||||
.join(account_key)
|
.join(account_key)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,690 @@
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
|
use fs2::FileExt;
|
||||||
|
use ring::signature::{UnparsedPublicKey, ED25519};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const REGISTRY_SCHEMA: &str = "hololake.dynamic-node-registry/v1";
|
||||||
|
const HEALTH_SCHEMA: &str = "hololake.signed-node-health/v1";
|
||||||
|
const ROUTE_SCHEMA: &str = "hololake.capability-route-receipt/v1";
|
||||||
|
const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||||
|
const MAX_CLOCK_SKEW_MS: u64 = 300_000;
|
||||||
|
const MAX_NODES: usize = 4096;
|
||||||
|
const MAX_BINDINGS_PER_NODE: usize = 256;
|
||||||
|
const EMBEDDED_ROUTING_TRUST: &str = include_str!("../dynamic-routing-trust.json");
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub(crate) struct DynamicNodeRegistry {
|
||||||
|
schema: String,
|
||||||
|
registry_id: String,
|
||||||
|
version: u64,
|
||||||
|
nodes: Vec<DynamicNodeRecord>,
|
||||||
|
signer_id: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct DynamicRoutingTrust {
|
||||||
|
schema: String,
|
||||||
|
state: RoutingTrustState,
|
||||||
|
signers: Vec<RoutingTrustSigner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
enum RoutingTrustState {
|
||||||
|
UnprovisionedFailClosed,
|
||||||
|
Provisioned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct RoutingTrustSigner {
|
||||||
|
signer_id: String,
|
||||||
|
public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
struct DynamicNodeRecord {
|
||||||
|
node_id: String,
|
||||||
|
domain_id: String,
|
||||||
|
endpoint: String,
|
||||||
|
protocol_public_key: String,
|
||||||
|
human_ids: Vec<String>,
|
||||||
|
persona_ids: Vec<String>,
|
||||||
|
capabilities: Vec<String>,
|
||||||
|
priority: u32,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub(crate) struct SignedNodeHealth {
|
||||||
|
schema: String,
|
||||||
|
registry_version: u64,
|
||||||
|
node_id: String,
|
||||||
|
observed_at_unix_ms: u64,
|
||||||
|
expires_at_unix_ms: u64,
|
||||||
|
sequence: u64,
|
||||||
|
status: HealthStatus,
|
||||||
|
capabilities: Vec<String>,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
enum HealthStatus {
|
||||||
|
Ready,
|
||||||
|
Degraded,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct ResolveCapabilityRouteInput {
|
||||||
|
human_id: String,
|
||||||
|
persona_id: String,
|
||||||
|
domain_id: String,
|
||||||
|
capability_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CapabilityRouteReceipt {
|
||||||
|
schema: &'static str,
|
||||||
|
state: &'static str,
|
||||||
|
registry_id: String,
|
||||||
|
registry_version: u64,
|
||||||
|
node_id: String,
|
||||||
|
domain_id: String,
|
||||||
|
endpoint: String,
|
||||||
|
capability_id: String,
|
||||||
|
human_id: String,
|
||||||
|
persona_id: String,
|
||||||
|
health_sequence: u64,
|
||||||
|
health_expires_at_unix_ms: u64,
|
||||||
|
write_authority_granted: bool,
|
||||||
|
receipt_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct RegistryInstallReceipt {
|
||||||
|
schema: &'static str,
|
||||||
|
state: &'static str,
|
||||||
|
registry_id: String,
|
||||||
|
version: u64,
|
||||||
|
node_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct HealthRecordReceipt {
|
||||||
|
schema: &'static str,
|
||||||
|
state: &'static str,
|
||||||
|
node_id: String,
|
||||||
|
sequence: u64,
|
||||||
|
expires_at_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_trusted_registry_at(
|
||||||
|
root: &Path,
|
||||||
|
registry: DynamicNodeRegistry,
|
||||||
|
) -> Result<RegistryInstallReceipt, String> {
|
||||||
|
let public_key = trusted_registry_key(EMBEDDED_ROUTING_TRUST, ®istry.signer_id)?;
|
||||||
|
install_registry_at(root, registry, &public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn routing_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let root = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
|
||||||
|
.join("dynamic-capability-routing-v1");
|
||||||
|
fs::create_dir_all(&root)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_registry_at(
|
||||||
|
root: &Path,
|
||||||
|
registry: DynamicNodeRegistry,
|
||||||
|
trusted_public_key: &str,
|
||||||
|
) -> Result<RegistryInstallReceipt, String> {
|
||||||
|
let _lock = lock_file(&root.join("registry.lock"), "REGISTRY")?;
|
||||||
|
validate_registry(®istry)?;
|
||||||
|
verify_signature(
|
||||||
|
trusted_public_key,
|
||||||
|
®istry.signature,
|
||||||
|
canonical_registry_bytes(®istry).as_bytes(),
|
||||||
|
"REGISTRY",
|
||||||
|
)?;
|
||||||
|
let path = root.join("registry.json");
|
||||||
|
if path.exists() {
|
||||||
|
let current: DynamicNodeRegistry = read_json(&path, "REGISTRY")?;
|
||||||
|
if registry.registry_id != current.registry_id || registry.version <= current.version {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_REGISTRY_VERSION_NOT_INCREASING".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_json_atomic(&path, ®istry, "REGISTRY")?;
|
||||||
|
Ok(RegistryInstallReceipt {
|
||||||
|
schema: REGISTRY_SCHEMA,
|
||||||
|
state: "INSTALLED",
|
||||||
|
registry_id: registry.registry_id,
|
||||||
|
version: registry.version,
|
||||||
|
node_count: registry.nodes.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_health_at(
|
||||||
|
root: &Path,
|
||||||
|
health: SignedNodeHealth,
|
||||||
|
) -> Result<HealthRecordReceipt, String> {
|
||||||
|
validate_identifier(&health.node_id, "NODE")?;
|
||||||
|
let _lock = lock_file(
|
||||||
|
&root.join("health").join(format!("{}.lock", health.node_id)),
|
||||||
|
"NODE_HEALTH",
|
||||||
|
)?;
|
||||||
|
validate_capabilities(&health.capabilities)?;
|
||||||
|
if health.schema != HEALTH_SCHEMA || health.expires_at_unix_ms <= health.observed_at_unix_ms {
|
||||||
|
return Err("HOLOLAKE_NODE_HEALTH_INVALID".into());
|
||||||
|
}
|
||||||
|
let registry: DynamicNodeRegistry = read_json(&root.join("registry.json"), "REGISTRY")?;
|
||||||
|
if health.registry_version != registry.version {
|
||||||
|
return Err("HOLOLAKE_NODE_HEALTH_REGISTRY_VERSION_MISMATCH".into());
|
||||||
|
}
|
||||||
|
let node = registry
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|node| node.node_id == health.node_id)
|
||||||
|
.ok_or("HOLOLAKE_NODE_NOT_REGISTERED")?;
|
||||||
|
verify_signature(
|
||||||
|
&node.protocol_public_key,
|
||||||
|
&health.signature,
|
||||||
|
canonical_health_bytes(&health).as_bytes(),
|
||||||
|
"NODE_HEALTH",
|
||||||
|
)?;
|
||||||
|
let path = root.join("health").join(format!("{}.json", health.node_id));
|
||||||
|
if path.exists() {
|
||||||
|
let current: SignedNodeHealth = read_json(&path, "NODE_HEALTH")?;
|
||||||
|
if health.sequence <= current.sequence {
|
||||||
|
return Err("HOLOLAKE_NODE_HEALTH_REPLAYED".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_json_atomic(&path, &health, "NODE_HEALTH")?;
|
||||||
|
Ok(HealthRecordReceipt {
|
||||||
|
schema: HEALTH_SCHEMA,
|
||||||
|
state: "RECORDED",
|
||||||
|
node_id: health.node_id,
|
||||||
|
sequence: health.sequence,
|
||||||
|
expires_at_unix_ms: health.expires_at_unix_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_at(
|
||||||
|
root: &Path,
|
||||||
|
input: ResolveCapabilityRouteInput,
|
||||||
|
now: u64,
|
||||||
|
) -> Result<CapabilityRouteReceipt, String> {
|
||||||
|
validate_identifier(&input.human_id, "HUMAN")?;
|
||||||
|
validate_identifier(&input.persona_id, "PERSONA")?;
|
||||||
|
validate_identifier(&input.domain_id, "DOMAIN")?;
|
||||||
|
validate_identifier(&input.capability_id, "CAPABILITY")?;
|
||||||
|
let registry: DynamicNodeRegistry = read_json(&root.join("registry.json"), "REGISTRY")?;
|
||||||
|
let mut eligible = Vec::new();
|
||||||
|
for node in registry.nodes.iter().filter(|node| {
|
||||||
|
node.enabled
|
||||||
|
&& node.domain_id == input.domain_id
|
||||||
|
&& node.human_ids.contains(&input.human_id)
|
||||||
|
&& node.persona_ids.contains(&input.persona_id)
|
||||||
|
&& node.capabilities.contains(&input.capability_id)
|
||||||
|
}) {
|
||||||
|
let path = root.join("health").join(format!("{}.json", node.node_id));
|
||||||
|
let Ok(health) = read_json::<SignedNodeHealth>(&path, "NODE_HEALTH") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if health.registry_version != registry.version
|
||||||
|
|| health.status != HealthStatus::Ready
|
||||||
|
|| health.observed_at_unix_ms > now.saturating_add(MAX_CLOCK_SKEW_MS)
|
||||||
|
|| health.expires_at_unix_ms < now
|
||||||
|
|| !health.capabilities.contains(&input.capability_id)
|
||||||
|
|| verify_signature(
|
||||||
|
&node.protocol_public_key,
|
||||||
|
&health.signature,
|
||||||
|
canonical_health_bytes(&health).as_bytes(),
|
||||||
|
"NODE_HEALTH",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eligible.push((node, health));
|
||||||
|
}
|
||||||
|
eligible.sort_by(
|
||||||
|
|(left, _), (right, _)| match left.priority.cmp(&right.priority) {
|
||||||
|
Ordering::Equal => left.node_id.cmp(&right.node_id),
|
||||||
|
ordering => ordering,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (node, health) = eligible
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or("HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE")?;
|
||||||
|
let receipt_id = sha256_hex(
|
||||||
|
format!(
|
||||||
|
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||||
|
registry.registry_id,
|
||||||
|
registry.version,
|
||||||
|
node.node_id,
|
||||||
|
input.human_id,
|
||||||
|
input.persona_id,
|
||||||
|
input.domain_id,
|
||||||
|
input.capability_id,
|
||||||
|
health.sequence
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
Ok(CapabilityRouteReceipt {
|
||||||
|
schema: ROUTE_SCHEMA,
|
||||||
|
state: "ROUTE_SELECTED",
|
||||||
|
registry_id: registry.registry_id,
|
||||||
|
registry_version: registry.version,
|
||||||
|
node_id: node.node_id.clone(),
|
||||||
|
domain_id: node.domain_id.clone(),
|
||||||
|
endpoint: node.endpoint.clone(),
|
||||||
|
capability_id: input.capability_id,
|
||||||
|
human_id: input.human_id,
|
||||||
|
persona_id: input.persona_id,
|
||||||
|
health_sequence: health.sequence,
|
||||||
|
health_expires_at_unix_ms: health.expires_at_unix_ms,
|
||||||
|
write_authority_granted: false,
|
||||||
|
receipt_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_registry(registry: &DynamicNodeRegistry) -> Result<(), String> {
|
||||||
|
if registry.schema != REGISTRY_SCHEMA
|
||||||
|
|| registry.version == 0
|
||||||
|
|| registry.nodes.is_empty()
|
||||||
|
|| registry.nodes.len() > MAX_NODES
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_REGISTRY_INVALID".into());
|
||||||
|
}
|
||||||
|
validate_identifier(®istry.registry_id, "REGISTRY")?;
|
||||||
|
validate_identifier(®istry.signer_id, "SIGNER")?;
|
||||||
|
let mut node_ids = BTreeSet::new();
|
||||||
|
for node in ®istry.nodes {
|
||||||
|
validate_identifier(&node.node_id, "NODE")?;
|
||||||
|
validate_identifier(&node.domain_id, "DOMAIN")?;
|
||||||
|
validate_identifiers(&node.human_ids, "HUMAN")?;
|
||||||
|
validate_identifiers(&node.persona_ids, "PERSONA")?;
|
||||||
|
validate_capabilities(&node.capabilities)?;
|
||||||
|
if !node_ids.insert(node.node_id.as_str()) {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_REGISTRY_DUPLICATE_NODE".into());
|
||||||
|
}
|
||||||
|
let endpoint = tauri::Url::parse(&node.endpoint)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_NODE_ENDPOINT_INVALID: {error}"))?;
|
||||||
|
if endpoint.scheme() != "https"
|
||||||
|
|| endpoint.host_str().is_none()
|
||||||
|
|| !endpoint.username().is_empty()
|
||||||
|
|| endpoint.password().is_some()
|
||||||
|
|| endpoint.query().is_some()
|
||||||
|
|| endpoint.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_NODE_ENDPOINT_MUST_USE_HTTPS".into());
|
||||||
|
}
|
||||||
|
decode_exact(&node.protocol_public_key, 32, "NODE_PROTOCOL_PUBLIC_KEY")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_capabilities(values: &[String]) -> Result<(), String> {
|
||||||
|
validate_identifiers(values, "CAPABILITY")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifiers(values: &[String], kind: &str) -> Result<(), String> {
|
||||||
|
if values.is_empty() || values.len() > MAX_BINDINGS_PER_NODE {
|
||||||
|
return Err(format!("HOLOLAKE_{kind}_BINDING_REQUIRED"));
|
||||||
|
}
|
||||||
|
let mut unique = BTreeSet::new();
|
||||||
|
for value in values {
|
||||||
|
validate_identifier(value, kind)?;
|
||||||
|
if !unique.insert(value.as_str()) {
|
||||||
|
return Err(format!("HOLOLAKE_{kind}_BINDING_DUPLICATE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.len() > MAX_IDENTIFIER_BYTES
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||||
|
{
|
||||||
|
return Err(format!("HOLOLAKE_{kind}_ID_INVALID"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_registry_bytes(registry: &DynamicNodeRegistry) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
registry.schema.clone(),
|
||||||
|
registry.registry_id.clone(),
|
||||||
|
registry.version.to_string(),
|
||||||
|
registry.signer_id.clone(),
|
||||||
|
];
|
||||||
|
for node in ®istry.nodes {
|
||||||
|
lines.extend([
|
||||||
|
node.node_id.clone(),
|
||||||
|
node.domain_id.clone(),
|
||||||
|
node.endpoint.clone(),
|
||||||
|
node.protocol_public_key.clone(),
|
||||||
|
node.human_ids.join(","),
|
||||||
|
node.persona_ids.join(","),
|
||||||
|
node.capabilities.join(","),
|
||||||
|
node.priority.to_string(),
|
||||||
|
node.enabled.to_string(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_health_bytes(health: &SignedNodeHealth) -> String {
|
||||||
|
[
|
||||||
|
health.schema.clone(),
|
||||||
|
health.registry_version.to_string(),
|
||||||
|
health.node_id.clone(),
|
||||||
|
health.observed_at_unix_ms.to_string(),
|
||||||
|
health.expires_at_unix_ms.to_string(),
|
||||||
|
health.sequence.to_string(),
|
||||||
|
match health.status {
|
||||||
|
HealthStatus::Ready => "READY",
|
||||||
|
HealthStatus::Degraded => "DEGRADED",
|
||||||
|
HealthStatus::Offline => "OFFLINE",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
health.capabilities.join(","),
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_signature(
|
||||||
|
public_key: &str,
|
||||||
|
signature: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
kind: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let public_key = decode_exact(public_key, 32, &format!("{kind}_PUBLIC_KEY"))?;
|
||||||
|
let signature = decode_exact(signature, 64, &format!("{kind}_SIGNATURE"))?;
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(bytes, &signature)
|
||||||
|
.map_err(|_| format!("HOLOLAKE_{kind}_SIGNATURE_INVALID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_registry_key(raw: &str, signer_id: &str) -> Result<String, String> {
|
||||||
|
let trust: DynamicRoutingTrust = serde_json::from_str(raw)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID: {error}"))?;
|
||||||
|
if trust.schema != "hololake.dynamic-routing-trust/v1" {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_SCHEMA_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
match trust.state {
|
||||||
|
RoutingTrustState::UnprovisionedFailClosed => {
|
||||||
|
if !trust.signers.is_empty() {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID".into());
|
||||||
|
}
|
||||||
|
Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED".into())
|
||||||
|
}
|
||||||
|
RoutingTrustState::Provisioned => {
|
||||||
|
if trust.signers.is_empty() {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_INVALID".into());
|
||||||
|
}
|
||||||
|
let mut ids = BTreeSet::new();
|
||||||
|
for signer in trust.signers {
|
||||||
|
validate_identifier(&signer.signer_id, "SIGNER")?;
|
||||||
|
decode_exact(&signer.public_key, 32, "REGISTRY_PUBLIC_KEY")?;
|
||||||
|
if !ids.insert(signer.signer_id.clone()) {
|
||||||
|
return Err("HOLOLAKE_DYNAMIC_ROUTING_TRUST_DUPLICATE_SIGNER".into());
|
||||||
|
}
|
||||||
|
if signer.signer_id == signer_id {
|
||||||
|
return Ok(signer.public_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("HOLOLAKE_DYNAMIC_ROUTING_SIGNER_NOT_TRUSTED".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_exact(value: &str, expected: usize, kind: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let decoded = BASE64
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|_| format!("HOLOLAKE_{kind}_INVALID"))?;
|
||||||
|
if decoded.len() != expected {
|
||||||
|
return Err(format!("HOLOLAKE_{kind}_INVALID"));
|
||||||
|
}
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_atomic<T: Serialize>(path: &Path, value: &T, kind: &str) -> Result<(), String> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or("HOLOLAKE_DYNAMIC_ROUTING_PATH_INVALID")?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
let temporary = parent.join(format!(".{}.tmp", Uuid::new_v4()));
|
||||||
|
let bytes = serde_json::to_vec_pretty(value)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_{kind}_INVALID: {error}"))?;
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&temporary)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))?;
|
||||||
|
file.write_all(&bytes)
|
||||||
|
.and_then(|_| file.sync_all())
|
||||||
|
.map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))?;
|
||||||
|
fs::rename(&temporary, path).map_err(|error| format!("HOLOLAKE_{kind}_WRITE_FAILED: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path, kind: &str) -> Result<T, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|error| {
|
||||||
|
if error.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
format!("HOLOLAKE_{kind}_NOT_FOUND")
|
||||||
|
} else {
|
||||||
|
format!("HOLOLAKE_{kind}_UNREADABLE: {error}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
serde_json::from_slice(&bytes).map_err(|error| format!("HOLOLAKE_{kind}_INVALID: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_file(path: &Path, kind: &str) -> Result<std::fs::File, String> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or("HOLOLAKE_DYNAMIC_ROUTING_PATH_INVALID")?;
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_DYNAMIC_ROUTING_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_{kind}_LOCK_FAILED: {error}"))?;
|
||||||
|
file.lock_exclusive()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_{kind}_LOCK_FAILED: {error}"))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(value: &[u8]) -> String {
|
||||||
|
ring::digest::digest(&ring::digest::SHA256, value)
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ring::rand::SystemRandom;
|
||||||
|
use ring::signature::{Ed25519KeyPair, KeyPair};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn key_pair() -> Ed25519KeyPair {
|
||||||
|
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
|
||||||
|
Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(node_id: &str, key: &Ed25519KeyPair, priority: u32) -> DynamicNodeRecord {
|
||||||
|
DynamicNodeRecord {
|
||||||
|
node_id: node_id.into(),
|
||||||
|
domain_id: "DOM-FIFTH-0001".into(),
|
||||||
|
endpoint: format!("https://{node_id}.example.test/hololake"),
|
||||||
|
protocol_public_key: BASE64.encode(key.public_key().as_ref()),
|
||||||
|
human_ids: vec!["human-BS-0001".into()],
|
||||||
|
persona_ids: vec!["ICE-P-ZY001".into()],
|
||||||
|
capabilities: vec!["GH-PNCC-READ".into()],
|
||||||
|
priority,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry(nodes: Vec<DynamicNodeRecord>, signer: &Ed25519KeyPair) -> DynamicNodeRegistry {
|
||||||
|
let mut registry = DynamicNodeRegistry {
|
||||||
|
schema: REGISTRY_SCHEMA.into(),
|
||||||
|
registry_id: "registry-1".into(),
|
||||||
|
version: 1,
|
||||||
|
nodes,
|
||||||
|
signer_id: "jd-controller-release-registry".into(),
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
registry.signature = BASE64.encode(
|
||||||
|
signer
|
||||||
|
.sign(canonical_registry_bytes(®istry).as_bytes())
|
||||||
|
.as_ref(),
|
||||||
|
);
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health(node_id: &str, signer: &Ed25519KeyPair, now: u64) -> SignedNodeHealth {
|
||||||
|
let mut health = SignedNodeHealth {
|
||||||
|
schema: HEALTH_SCHEMA.into(),
|
||||||
|
registry_version: 1,
|
||||||
|
node_id: node_id.into(),
|
||||||
|
observed_at_unix_ms: now,
|
||||||
|
expires_at_unix_ms: now + 60_000,
|
||||||
|
sequence: 1,
|
||||||
|
status: HealthStatus::Ready,
|
||||||
|
capabilities: vec!["GH-PNCC-READ".into()],
|
||||||
|
signature: String::new(),
|
||||||
|
};
|
||||||
|
health.signature = BASE64.encode(
|
||||||
|
signer
|
||||||
|
.sign(canonical_health_bytes(&health).as_bytes())
|
||||||
|
.as_ref(),
|
||||||
|
);
|
||||||
|
health
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input() -> ResolveCapabilityRouteInput {
|
||||||
|
ResolveCapabilityRouteInput {
|
||||||
|
human_id: "human-BS-0001".into(),
|
||||||
|
persona_id: "ICE-P-ZY001".into(),
|
||||||
|
domain_id: "DOM-FIFTH-0001".into(),
|
||||||
|
capability_id: "GH-PNCC-READ".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installs_a_signed_dynamic_registry_and_selects_by_priority() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let registry_signer = key_pair();
|
||||||
|
let node_a = key_pair();
|
||||||
|
let node_b = key_pair();
|
||||||
|
let registry = registry(
|
||||||
|
vec![node("node-b", &node_b, 20), node("node-a", &node_a, 10)],
|
||||||
|
®istry_signer,
|
||||||
|
);
|
||||||
|
install_registry_at(
|
||||||
|
temp.path(),
|
||||||
|
registry,
|
||||||
|
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let now = 1_000_000;
|
||||||
|
record_health_at(temp.path(), health("node-a", &node_a, now)).unwrap();
|
||||||
|
record_health_at(temp.path(), health("node-b", &node_b, now)).unwrap();
|
||||||
|
let route = resolve_at(temp.path(), input(), now + 1).unwrap();
|
||||||
|
assert_eq!(route.node_id, "node-a");
|
||||||
|
assert!(!route.write_authority_granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsigned_registry_and_replayed_health() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let registry_signer = key_pair();
|
||||||
|
let node_key = key_pair();
|
||||||
|
let mut invalid = registry(vec![node("node-a", &node_key, 10)], ®istry_signer);
|
||||||
|
invalid.signature = BASE64.encode([0_u8; 64]);
|
||||||
|
assert!(install_registry_at(
|
||||||
|
temp.path(),
|
||||||
|
invalid,
|
||||||
|
&BASE64.encode(registry_signer.public_key().as_ref())
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
install_registry_at(
|
||||||
|
temp.path(),
|
||||||
|
registry(vec![node("node-a", &node_key, 10)], ®istry_signer),
|
||||||
|
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let current = health("node-a", &node_key, 1_000_000);
|
||||||
|
record_health_at(temp.path(), current.clone()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
record_health_at(temp.path(), current).unwrap_err(),
|
||||||
|
"HOLOLAKE_NODE_HEALTH_REPLAYED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_unsigned_or_wrong_binding_nodes_are_not_routable() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let registry_signer = key_pair();
|
||||||
|
let node_key = key_pair();
|
||||||
|
install_registry_at(
|
||||||
|
temp.path(),
|
||||||
|
registry(vec![node("node-a", &node_key, 10)], ®istry_signer),
|
||||||
|
&BASE64.encode(registry_signer.public_key().as_ref()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
record_health_at(temp.path(), health("node-a", &node_key, 1_000_000)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolve_at(temp.path(), input(), 2_000_000).unwrap_err(),
|
||||||
|
"HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE"
|
||||||
|
);
|
||||||
|
let mut wrong_persona = input();
|
||||||
|
wrong_persona.persona_id = "ICE-P-OTHER".into();
|
||||||
|
assert_eq!(
|
||||||
|
resolve_at(temp.path(), wrong_persona, 1_000_001).unwrap_err(),
|
||||||
|
"HOLOLAKE_NO_HEALTHY_CAPABILITY_ROUTE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_unprovisioned_trust_fails_closed() {
|
||||||
|
assert_eq!(
|
||||||
|
trusted_registry_key(EMBEDDED_ROUTING_TRUST, "any-signer").unwrap_err(),
|
||||||
|
"HOLOLAKE_DYNAMIC_ROUTING_TRUST_UNPROVISIONED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod direct_local_broker;
|
mod direct_local_broker;
|
||||||
mod direct_local_session;
|
mod direct_local_session;
|
||||||
|
mod dynamic_capability_routing;
|
||||||
mod local_development_bridge;
|
mod local_development_bridge;
|
||||||
mod release_trust;
|
mod release_trust;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue