Serve other machines over TLS, trusted through the instance key (#20) b576d05cf862
The daemon makes a certificate of its own and vouches for it with a signed instance.tls event; GET /api/v1/instance lets a client check that before trusting anything; clients pin the certificate. A uid-bound agent is refused over TCP and recorded as guard.impersonation. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ce638315e1a7
- When
- · 2026-11-03 13:06 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b576d05cf862371c8144d0963302ddc5de769bcf4557ab4d6b7ea7ae4c7684fc- Depends on
- 037c45d8093c 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 39f5c913f617 571572f5fb2c 8dc6df8c4d4e 98a16830a3d3 d17cbc7069ce da84e98f104e e071e91a14ef
- Depended on by
- 862f0db224aa c7161ec1a607 98a0fb9f0948 eca5af92279b 77df591d5a2f
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 71 atoms, 3 tree operations, 16 files
Browse the tree at this change
added crates/spectra-mcp/src/instance.rs +79 −0
| 1 | + | //! The instance's word for its TLS certificate (spec §8.6): the |
|
| 2 | + | //! `instance.tls` event the daemon signs with the instance key, and the |
|
| 3 | + | //! check a client makes of it. |
|
| 4 | + | ||
| 5 | + | use anyhow::{bail, Context, Result}; |
|
| 6 | + | use oxy_core::identity::Identity; |
|
| 7 | + | use serde_json::{json, Value as Json}; |
|
| 8 | + | use spectra_core::event::Event; |
|
| 9 | + | use spectra_core::keys::KeyPair; |
|
| 10 | + | use spectra_core::ledger::Ledger; |
|
| 11 | + | use spectra_core::proof::Proof; |
|
| 12 | + | ||
| 13 | + | /// The latest `instance.tls` event, if any. |
|
| 14 | + | pub async fn latest_tls(ledger: &Ledger) -> Result<Option<Event>> { |
|
| 15 | + | let mut last = None; |
|
| 16 | + | let mut from = 1; |
|
| 17 | + | loop { |
|
| 18 | + | let rows = ledger.events_of_kinds(from, &["instance.tls"], 1000).await?; |
|
| 19 | + | match rows.last() { |
|
| 20 | + | Some(e) => { |
|
| 21 | + | from = e.seq + 1; |
|
| 22 | + | last = Some(e.clone()); |
|
| 23 | + | } |
|
| 24 | + | None => return Ok(last), |
|
| 25 | + | } |
|
| 26 | + | } |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /// Record the certificate's pin, signed by the instance key, unless the |
|
| 30 | + | /// latest record already names it. |
|
| 31 | + | pub async fn record_tls(ledger: &Ledger, instance: &KeyPair, spki: &[u8; 32], names: &[String]) -> Result<bool> { |
|
| 32 | + | let hex_spki = hex::encode(spki); |
|
| 33 | + | if let Some(e) = latest_tls(ledger).await? { |
|
| 34 | + | if e.json().get("spki_sha256").and_then(Json::as_str) == Some(hex_spki.as_str()) { |
|
| 35 | + | return Ok(false); |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | let payload = json!({ "spki_sha256": hex_spki, "names": names }); |
|
| 39 | + | ledger.append_one(spectra_core::principals::signed_event(instance, "system", "instance.tls", payload)).await?; |
|
| 40 | + | Ok(true) |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | /// What `GET /api/v1/instance` told a client, checked. |
|
| 44 | + | #[derive(Clone, Debug)] |
|
| 45 | + | pub struct Checked { |
|
| 46 | + | pub name: String, |
|
| 47 | + | pub instance: Identity, |
|
| 48 | + | pub owner: Option<String>, |
|
| 49 | + | /// The certificate the instance key vouches for. |
|
| 50 | + | pub spki_sha256: [u8; 32], |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | /// Check `/api/v1/instance`'s answer: the identity is whole, and the |
|
| 54 | + | /// instance key signed an `instance.tls` naming the certificate `seen`. |
|
| 55 | + | pub fn check(answer: &Json, seen: &[u8; 32]) -> Result<Checked> { |
|
| 56 | + | let ip = answer.get("instance").context("no instance in the answer")?; |
|
| 57 | + | let bytes = ip.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).context("no instance identity")?; |
|
| 58 | + | let instance = Identity::decode(&bytes).map_err(|e| anyhow::anyhow!("malformed instance identity: {e}"))?; |
|
| 59 | + | let tls = answer.get("tls").filter(|t| !t.is_null()).context("the server has not signed a TLS certificate")?; |
|
| 60 | + | let payload = tls.get("payload").context("no TLS payload")?; |
|
| 61 | + | let proof = tls.get("proof").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).context("no TLS proof")?; |
|
| 62 | + | let proof = Proof::decode(&proof).map_err(|e| anyhow::anyhow!(e))?; |
|
| 63 | + | if proof.key() != instance.hash().0 || proof.method != "cli" { |
|
| 64 | + | bail!("the certificate is not vouched for by the instance key"); |
|
| 65 | + | } |
|
| 66 | + | proof |
|
| 67 | + | .verify(&instance, Some(&spectra_core::event::canonical_json(payload))) |
|
| 68 | + | .map_err(|e| anyhow::anyhow!("the instance's signature on its certificate: {e}"))?; |
|
| 69 | + | let named = payload.get("spki_sha256").and_then(Json::as_str).context("no pin in the TLS record")?; |
|
| 70 | + | if named != hex::encode(seen) { |
|
| 71 | + | bail!("the server presented a certificate its instance key did not sign"); |
|
| 72 | + | } |
|
| 73 | + | Ok(Checked { |
|
| 74 | + | name: answer.get("name").and_then(Json::as_str).unwrap_or("").to_string(), |
|
| 75 | + | instance, |
|
| 76 | + | owner: answer.get("owner").and_then(Json::as_str).map(String::from), |
|
| 77 | + | spki_sha256: *seen, |
|
| 78 | + | }) |
|
| 79 | + | } |
added crates/spectra-mcp/src/tls.rs +264 −0
| 1 | + | //! TLS for client and server (spec §8.6). The server makes a certificate |
|
| 2 | + | //! of its own; the instance key vouches for it with an `instance.tls` |
|
| 3 | + | //! event; a client trusts that one certificate, pinned by the SHA-256 of |
|
| 4 | + | //! its SubjectPublicKeyInfo, and never a certificate authority. |
|
| 5 | + | ||
| 6 | + | use anyhow::{bail, Context, Result}; |
|
| 7 | + | use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; |
|
| 8 | + | use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; |
|
| 9 | + | use rustls::{DigitallySignedStruct, SignatureScheme}; |
|
| 10 | + | use std::net::SocketAddr; |
|
| 11 | + | use std::path::{Path, PathBuf}; |
|
| 12 | + | use std::sync::{Arc, Mutex}; |
|
| 13 | + | use std::time::Duration; |
|
| 14 | + | use tokio::net::{TcpListener, TcpStream}; |
|
| 15 | + | ||
| 16 | + | /// The crypto provider: ring, as OxygenHub's. |
|
| 17 | + | pub fn provider() -> Arc<rustls::crypto::CryptoProvider> { |
|
| 18 | + | Arc::new(rustls::crypto::ring::default_provider()) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// SHA-256 of a certificate's SubjectPublicKeyInfo: what a client pins. |
|
| 22 | + | pub fn spki_sha256(cert_der: &[u8]) -> Result<[u8; 32]> { |
|
| 23 | + | let (_, cert) = x509_parser::parse_x509_certificate(cert_der).map_err(|e| anyhow::anyhow!("not an X.509 certificate: {e}"))?; |
|
| 24 | + | let d = ring::digest::digest(&ring::digest::SHA256, cert.public_key().raw); |
|
| 25 | + | Ok(d.as_ref().try_into().expect("SHA-256 is 32 bytes")) |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | /// Where a server's certificate comes from (`[server] tls`). |
|
| 29 | + | #[derive(Clone, Debug)] |
|
| 30 | + | pub enum CertSource { |
|
| 31 | + | /// Made on first start in `<data>/keys/tls.{key,crt}`, naming these names. |
|
| 32 | + | Own { names: Vec<String> }, |
|
| 33 | + | /// Files from elsewhere (PEM). |
|
| 34 | + | Files { cert: PathBuf, key: PathBuf }, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | /// A server's TLS: its rustls configuration and the certificate's pin. |
|
| 38 | + | pub struct ServerTls { |
|
| 39 | + | pub config: Arc<rustls::ServerConfig>, |
|
| 40 | + | pub spki_sha256: [u8; 32], |
|
| 41 | + | pub names: Vec<String>, |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl ServerTls { |
|
| 45 | + | pub fn load(data: &Path, source: &CertSource) -> Result<ServerTls> { |
|
| 46 | + | let (cert_pem, key_pem, names) = match source { |
|
| 47 | + | CertSource::Own { names } => { |
|
| 48 | + | let keys = data.join("keys"); |
|
| 49 | + | let (crt, key) = (keys.join("tls.crt"), keys.join("tls.key")); |
|
| 50 | + | if !crt.exists() || !key.exists() { |
|
| 51 | + | make_own(&crt, &key, names)?; |
|
| 52 | + | } |
|
| 53 | + | (std::fs::read_to_string(&crt)?, std::fs::read_to_string(&key)?, names.clone()) |
|
| 54 | + | } |
|
| 55 | + | CertSource::Files { cert, key } => ( |
|
| 56 | + | std::fs::read_to_string(cert).with_context(|| format!("reading {}", cert.display()))?, |
|
| 57 | + | std::fs::read_to_string(key).with_context(|| format!("reading {}", key.display()))?, |
|
| 58 | + | vec![], |
|
| 59 | + | ), |
|
| 60 | + | }; |
|
| 61 | + | let chain: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_pem.as_bytes()).collect::<Result<_, _>>()?; |
|
| 62 | + | let leaf = chain.first().context("no certificate in the PEM")?; |
|
| 63 | + | let spki = spki_sha256(leaf)?; |
|
| 64 | + | let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_pem.as_bytes())?.context("no private key in the PEM")?; |
|
| 65 | + | let mut config = rustls::ServerConfig::builder_with_provider(provider()) |
|
| 66 | + | .with_safe_default_protocol_versions()? |
|
| 67 | + | .with_no_client_auth() |
|
| 68 | + | .with_single_cert(chain, key)?; |
|
| 69 | + | config.alpn_protocols = vec![b"http/1.1".to_vec()]; |
|
| 70 | + | Ok(ServerTls { config: Arc::new(config), spki_sha256: spki, names }) |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | fn make_own(crt: &Path, key: &Path, names: &[String]) -> Result<()> { |
|
| 75 | + | if names.is_empty() { |
|
| 76 | + | bail!("a certificate needs at least one name ([server] names)"); |
|
| 77 | + | } |
|
| 78 | + | let ck = rcgen::generate_simple_self_signed(names.to_vec())?; |
|
| 79 | + | if let Some(dir) = crt.parent() { |
|
| 80 | + | std::fs::create_dir_all(dir)?; |
|
| 81 | + | } |
|
| 82 | + | write_private(key, ck.signing_key.serialize_pem().as_bytes())?; |
|
| 83 | + | std::fs::write(crt, ck.cert.pem())?; |
|
| 84 | + | Ok(()) |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { |
|
| 88 | + | #[cfg(unix)] |
|
| 89 | + | { |
|
| 90 | + | use std::io::Write; |
|
| 91 | + | use std::os::unix::fs::OpenOptionsExt; |
|
| 92 | + | let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?; |
|
| 93 | + | f.write_all(bytes)?; |
|
| 94 | + | Ok(()) |
|
| 95 | + | } |
|
| 96 | + | #[cfg(not(unix))] |
|
| 97 | + | { |
|
| 98 | + | std::fs::write(path, bytes)?; |
|
| 99 | + | Ok(()) |
|
| 100 | + | } |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// A TCP listener that hands axum connections once their TLS handshake is |
|
| 104 | + | /// done; handshakes run concurrently, so a slow one holds up nobody. |
|
| 105 | + | pub struct TlsListener { |
|
| 106 | + | rx: tokio::sync::mpsc::Receiver<(tokio_rustls::server::TlsStream<TcpStream>, SocketAddr)>, |
|
| 107 | + | addr: SocketAddr, |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | impl TlsListener { |
|
| 111 | + | pub fn new(tcp: TcpListener, config: Arc<rustls::ServerConfig>) -> Result<TlsListener> { |
|
| 112 | + | let addr = tcp.local_addr()?; |
|
| 113 | + | let acceptor = tokio_rustls::TlsAcceptor::from(config); |
|
| 114 | + | let (tx, rx) = tokio::sync::mpsc::channel(64); |
|
| 115 | + | tokio::spawn(async move { |
|
| 116 | + | loop { |
|
| 117 | + | let (stream, peer) = match tcp.accept().await { |
|
| 118 | + | Ok(x) => x, |
|
| 119 | + | Err(e) => { |
|
| 120 | + | tracing::warn!("accept: {e}"); |
|
| 121 | + | tokio::time::sleep(Duration::from_millis(50)).await; |
|
| 122 | + | continue; |
|
| 123 | + | } |
|
| 124 | + | }; |
|
| 125 | + | let (acceptor, out) = (acceptor.clone(), tx.clone()); |
|
| 126 | + | tokio::spawn(async move { |
|
| 127 | + | match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(stream)).await { |
|
| 128 | + | Ok(Ok(tls)) => { |
|
| 129 | + | let _ = out.send((tls, peer)).await; |
|
| 130 | + | } |
|
| 131 | + | Ok(Err(e)) => tracing::debug!(%peer, "TLS handshake failed: {e}"), |
|
| 132 | + | Err(_) => tracing::debug!(%peer, "TLS handshake timed out"), |
|
| 133 | + | } |
|
| 134 | + | }); |
|
| 135 | + | if tx.is_closed() { |
|
| 136 | + | return; |
|
| 137 | + | } |
|
| 138 | + | } |
|
| 139 | + | }); |
|
| 140 | + | Ok(TlsListener { rx, addr }) |
|
| 141 | + | } |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | impl axum::serve::Listener for TlsListener { |
|
| 145 | + | type Io = tokio_rustls::server::TlsStream<TcpStream>; |
|
| 146 | + | type Addr = SocketAddr; |
|
| 147 | + | ||
| 148 | + | async fn accept(&mut self) -> (Self::Io, Self::Addr) { |
|
| 149 | + | match self.rx.recv().await { |
|
| 150 | + | Some(x) => x, |
|
| 151 | + | // The accept loop never ends while we hold the receiver. |
|
| 152 | + | None => std::future::pending().await, |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | fn local_addr(&self) -> std::io::Result<Self::Addr> { |
|
| 157 | + | Ok(self.addr) |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | /// A server certificate verifier that accepts exactly one public key, or, |
|
| 162 | + | /// with no pin, any key, remembering it for the caller to check |
|
| 163 | + | /// (`spectra connect`). The handshake's signature is always checked, so |
|
| 164 | + | /// the server must hold the certificate's private key. |
|
| 165 | + | #[derive(Debug)] |
|
| 166 | + | pub struct Pinned { |
|
| 167 | + | pin: Option<[u8; 32]>, |
|
| 168 | + | seen: Mutex<Option<(Vec<u8>, [u8; 32])>>, |
|
| 169 | + | algs: rustls::crypto::WebPkiSupportedAlgorithms, |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | impl Pinned { |
|
| 173 | + | pub fn new(pin: Option<[u8; 32]>) -> Arc<Pinned> { |
|
| 174 | + | Arc::new(Pinned { pin, seen: Mutex::new(None), algs: provider().signature_verification_algorithms }) |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | /// The certificate (DER) and its pin from the last handshake. |
|
| 178 | + | pub fn seen(&self) -> Option<(Vec<u8>, [u8; 32])> { |
|
| 179 | + | self.seen.lock().unwrap().clone() |
|
| 180 | + | } |
|
| 181 | + | ||
| 182 | + | pub fn client_config(self: &Arc<Self>) -> Result<Arc<rustls::ClientConfig>> { |
|
| 183 | + | let mut c = rustls::ClientConfig::builder_with_provider(provider()) |
|
| 184 | + | .with_safe_default_protocol_versions()? |
|
| 185 | + | .dangerous() |
|
| 186 | + | .with_custom_certificate_verifier(self.clone()) |
|
| 187 | + | .with_no_client_auth(); |
|
| 188 | + | c.alpn_protocols = vec![b"http/1.1".to_vec()]; |
|
| 189 | + | Ok(Arc::new(c)) |
|
| 190 | + | } |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | impl ServerCertVerifier for Pinned { |
|
| 194 | + | fn verify_server_cert( |
|
| 195 | + | &self, |
|
| 196 | + | end_entity: &CertificateDer<'_>, |
|
| 197 | + | _intermediates: &[CertificateDer<'_>], |
|
| 198 | + | _server_name: &ServerName<'_>, |
|
| 199 | + | _ocsp: &[u8], |
|
| 200 | + | _now: UnixTime, |
|
| 201 | + | ) -> Result<ServerCertVerified, rustls::Error> { |
|
| 202 | + | let spki = spki_sha256(end_entity).map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?; |
|
| 203 | + | *self.seen.lock().unwrap() = Some((end_entity.to_vec(), spki)); |
|
| 204 | + | match self.pin { |
|
| 205 | + | Some(pin) if pin != spki => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure)), |
|
| 206 | + | _ => Ok(ServerCertVerified::assertion()), |
|
| 207 | + | } |
|
| 208 | + | } |
|
| 209 | + | ||
| 210 | + | fn verify_tls12_signature( |
|
| 211 | + | &self, |
|
| 212 | + | message: &[u8], |
|
| 213 | + | cert: &CertificateDer<'_>, |
|
| 214 | + | dss: &DigitallySignedStruct, |
|
| 215 | + | ) -> Result<HandshakeSignatureValid, rustls::Error> { |
|
| 216 | + | rustls::crypto::verify_tls12_signature(message, cert, dss, &self.algs) |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | fn verify_tls13_signature( |
|
| 220 | + | &self, |
|
| 221 | + | message: &[u8], |
|
| 222 | + | cert: &CertificateDer<'_>, |
|
| 223 | + | dss: &DigitallySignedStruct, |
|
| 224 | + | ) -> Result<HandshakeSignatureValid, rustls::Error> { |
|
| 225 | + | rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algs) |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | fn supported_verify_schemes(&self) -> Vec<SignatureScheme> { |
|
| 229 | + | self.algs.supported_schemes() |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | ||
| 233 | + | /// Connect to `host:port` over TLS with `config`. |
|
| 234 | + | pub async fn connect(addr: &str, config: Arc<rustls::ClientConfig>) -> Result<tokio_rustls::client::TlsStream<TcpStream>> { |
|
| 235 | + | let tcp = tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(addr)) |
|
| 236 | + | .await |
|
| 237 | + | .with_context(|| format!("connecting to {addr}: timed out"))? |
|
| 238 | + | .with_context(|| format!("connecting to {addr}"))?; |
|
| 239 | + | tcp.set_nodelay(true).ok(); |
|
| 240 | + | let host = addr.rsplit_once(':').map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']')).unwrap_or(addr); |
|
| 241 | + | let name = ServerName::try_from(host.to_string()).unwrap_or_else(|_| ServerName::try_from("spectra").unwrap()); |
|
| 242 | + | let tls = tokio_rustls::TlsConnector::from(config).connect(name, tcp).await.with_context(|| format!("TLS with {addr}"))?; |
|
| 243 | + | Ok(tls) |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | #[cfg(test)] |
|
| 247 | + | mod tests { |
|
| 248 | + | use super::*; |
|
| 249 | + | ||
| 250 | + | #[test] |
|
| 251 | + | fn own_certificate_is_made_once_and_pinned() { |
|
| 252 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 253 | + | let src = CertSource::Own { names: vec!["localhost".into()] }; |
|
| 254 | + | let a = ServerTls::load(dir.path(), &src).unwrap(); |
|
| 255 | + | let b = ServerTls::load(dir.path(), &src).unwrap(); |
|
| 256 | + | assert_eq!(a.spki_sha256, b.spki_sha256, "the certificate is kept between starts"); |
|
| 257 | + | #[cfg(unix)] |
|
| 258 | + | { |
|
| 259 | + | use std::os::unix::fs::PermissionsExt; |
|
| 260 | + | let mode = std::fs::metadata(dir.path().join("keys/tls.key")).unwrap().permissions().mode(); |
|
| 261 | + | assert_eq!(mode & 0o777, 0o600); |
|
| 262 | + | } |
|
| 263 | + | } |
|
| 264 | + | } |
added tests/tls.rs +98 −0
| 1 | + | //! The daemon over TLS (spec §8.6, spectra#20): a certificate of its own, |
|
| 2 | + | //! vouched for by the instance key; a client that trusts that, and only |
|
| 3 | + | //! that; signed MCP calls over the network; no uid-bound agent over TCP. |
|
| 4 | + | ||
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{add_agent, run, setup, text, Serving}; |
|
| 8 | + | use serde_json::json; |
|
| 9 | + | use spectra_core::keys::KeyPair; |
|
| 10 | + | use spectra_mcp::client::{rpc_result, Client, Endpoint}; |
|
| 11 | + | use spectra_mcp::tls::Pinned; |
|
| 12 | + | ||
| 13 | + | fn unpinned(port: u16) -> (Client, std::sync::Arc<Pinned>) { |
|
| 14 | + | let v = Pinned::new(None); |
|
| 15 | + | let e = Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: v.client_config().unwrap() }; |
|
| 16 | + | (Client::at(e, None), v) |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[tokio::test] |
|
| 20 | + | async fn a_client_trusts_the_certificate_the_instance_key_signed() { |
|
| 21 | + | let dir = setup(); |
|
| 22 | + | let d = dir.path(); |
|
| 23 | + | let agent = add_agent(d, "claude-oxygen", &[]); |
|
| 24 | + | let (srv, port) = Serving::start_tls(d); |
|
| 25 | + | ||
| 26 | + | // Unauthenticated, before trusting anything: who is this? |
|
| 27 | + | let (c, seen) = unpinned(port); |
|
| 28 | + | let r = c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap(); |
|
| 29 | + | assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body)); |
|
| 30 | + | let answer = r.json().unwrap(); |
|
| 31 | + | let (_, pin) = seen.seen().expect("the handshake showed a certificate"); |
|
| 32 | + | let checked = spectra_mcp::instance::check(&answer, &pin).unwrap(); |
|
| 33 | + | let instance = KeyPair::instance(&d.join("data"), "spectra").unwrap(); |
|
| 34 | + | assert_eq!(checked.instance.hash(), instance.key, "the instance key vouches for the certificate"); |
|
| 35 | + | let owner = KeyPair::load_home(&d.join("owner")).unwrap(); |
|
| 36 | + | assert_eq!(checked.owner.as_deref(), Some(owner.key.to_hex().as_str())); |
|
| 37 | + | ||
| 38 | + | // A certificate the instance did not sign is not accepted. |
|
| 39 | + | assert!(spectra_mcp::instance::check(&answer, &[7; 32]).is_err()); |
|
| 40 | + | ||
| 41 | + | // Pinned, an agent's signed MCP call works over the network. |
|
| 42 | + | let c = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), pin).unwrap(), Some(agent)); |
|
| 43 | + | let call = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "agents", "arguments": {}, |
|
| 44 | + | "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }); |
|
| 45 | + | let r = c.mcp("2026-07-28", &call).await.unwrap(); |
|
| 46 | + | let v = rpc_result(&r).unwrap(); |
|
| 47 | + | assert_eq!(v["result"]["isError"], false, "{v}"); |
|
| 48 | + | assert!(v["result"]["content"][0]["text"].as_str().unwrap().contains("claude-oxygen · Andy McCall's agent")); |
|
| 49 | + | ||
| 50 | + | // Any other certificate is refused at the handshake. |
|
| 51 | + | let wrong = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), [0; 32]).unwrap(), None); |
|
| 52 | + | assert!(wrong.send("GET", "/api/v1/instance", &[], vec![]).await.is_err()); |
|
| 53 | + | srv.stop(); |
|
| 54 | + | ||
| 55 | + | // The certificate is kept, and recorded once, across restarts; verify |
|
| 56 | + | // checks the instance's signature on it. |
|
| 57 | + | let (srv, port2) = Serving::start_tls(d); |
|
| 58 | + | let (c, seen) = unpinned(port2); |
|
| 59 | + | c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap(); |
|
| 60 | + | assert_eq!(seen.seen().unwrap().1, pin); |
|
| 61 | + | srv.stop(); |
|
| 62 | + | let o = run(d, &["admin", "verify"]); |
|
| 63 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | #[tokio::test] |
|
| 67 | + | async fn an_agent_bound_to_a_uid_is_refused_over_the_network() { |
|
| 68 | + | let dir = setup(); |
|
| 69 | + | let d = dir.path(); |
|
| 70 | + | let uid = unsafe { libc::getuid() }.to_string(); |
|
| 71 | + | let bound = add_agent(d, "bound", &["--uid", &uid]); |
|
| 72 | + | let (srv, port) = Serving::start_tls(d); |
|
| 73 | + | let (probe, seen) = unpinned(port); |
|
| 74 | + | probe.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap(); |
|
| 75 | + | let pin = seen.seen().unwrap().1; |
|
| 76 | + | let c = Client::at(Endpoint::tls(&format!("https://127.0.0.1:{port}"), pin).unwrap(), Some(bound.clone())); |
|
| 77 | + | let r = c.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap(); |
|
| 78 | + | assert_eq!(r.status, 401); |
|
| 79 | + | assert!(String::from_utf8_lossy(&r.body).contains("must come over the Unix socket"), "{}", String::from_utf8_lossy(&r.body)); |
|
| 80 | + | // Over the socket, from its own uid, it is welcome. |
|
| 81 | + | let local = Client::new(&srv.socket, Some(bound)); |
|
| 82 | + | let r = local.mcp("2026-07-28", &json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })).await.unwrap(); |
|
| 83 | + | assert_eq!(r.status, 200); |
|
| 84 | + | srv.stop(); |
|
| 85 | + | let o = run(d, &["admin", "verify"]); |
|
| 86 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 87 | + | let log = spectra_core::ledger::Ledger::open_read_only(&d.join("data/spectra.db")).await.unwrap(); |
|
| 88 | + | let g = log.events_of_kinds(1, &["guard.impersonation"], 10).await.unwrap(); |
|
| 89 | + | assert_eq!(g.len(), 1, "recorded as an impersonation attempt"); |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | #[test] |
|
| 93 | + | fn plain_http_beyond_loopback_is_refused() { |
|
| 94 | + | let dir = setup(); |
|
| 95 | + | let o = run(dir.path(), &["serve", "--bind", "0.0.0.0:0", "--tls", "off"]); |
|
| 96 | + | assert!(!o.status.success()); |
|
| 97 | + | assert!(text(&o).contains("over TLS only"), "{}", text(&o)); |
|
| 98 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check. |
|
| 5 | 6 | - **Added: the README's quick start, a systemd user unit (`packaging/spectra.service`) and `tests/bench.rs`** for the targets of spec §24 (brief 2.7 ms, say 3.1 ms, wait wakes 4.2 ms after a mention, with 1,000 messages, in release). |
|
| 6 | 7 | - **Added: the human at a terminal (#12).** `spectra say [-c CHANNEL] [--reply-to ID] BODY`, `spectra inbox [--follow]`, `spectra search QUERY [--channel] [--author] [--since 7d]` and `spectra api METHOD PATH [-d JSON]`, each signing with the owner's own key: through the daemon when it is serving, else straight into the ledger. Every listing takes `--json` (one document); `say` and `inbox --follow` make a terminal chat with the agents. |
|
| 7 | 8 | - **Added: the JSON API and its TypeScript types (#11).** `/api/v1` has health, version, brief, agents, projects, channels, a channel's messages (read and post), threads, the inbox and ack, search, and the event log, which follows live as SSE (`?follow=1`). Requests are signed, or a read carries the owner's Radar session; refusals are RFC 7807 problems; lists page with `?after=`; reads carry an ETag. `crates/spectra-api` holds the types, and `make ts` writes `ts/index.d.ts` for the editor extensions. |
|
| … | 12 unchanged lines |
modified Cargo.lock +259 −14
| … | 139 unchanged lines |
||
| 140 | 140 | ] |
|
| 141 | 141 | ||
| 142 | 142 | [[package]] |
|
| 143 | + | name = "asn1-rs" |
|
| 144 | + | version = "0.7.2" |
|
| 145 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 146 | + | checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" |
|
| 147 | + | dependencies = [ |
|
| 148 | + | "asn1-rs-derive", |
|
| 149 | + | "asn1-rs-impl", |
|
| 150 | + | "displaydoc", |
|
| 151 | + | "nom", |
|
| 152 | + | "num-traits", |
|
| 153 | + | "rusticata-macros", |
|
| 154 | + | "thiserror 2.0.21", |
|
| 155 | + | "time", |
|
| 156 | + | ] |
|
| 157 | + | ||
| 158 | + | [[package]] |
|
| 159 | + | name = "asn1-rs-derive" |
|
| 160 | + | version = "0.6.0" |
|
| 161 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 162 | + | checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" |
|
| 163 | + | dependencies = [ |
|
| 164 | + | "proc-macro2", |
|
| 165 | + | "quote", |
|
| 166 | + | "syn 2.0.119", |
|
| 167 | + | "synstructure 0.13.2", |
|
| 168 | + | ] |
|
| 169 | + | ||
| 170 | + | [[package]] |
|
| 171 | + | name = "asn1-rs-impl" |
|
| 172 | + | version = "0.2.0" |
|
| 173 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 174 | + | checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" |
|
| 175 | + | dependencies = [ |
|
| 176 | + | "proc-macro2", |
|
| 177 | + | "quote", |
|
| 178 | + | "syn 2.0.119", |
|
| 179 | + | ] |
|
| 180 | + | ||
| 181 | + | [[package]] |
|
| 143 | 182 | name = "atoi" |
|
| 144 | 183 | version = "2.0.0" |
|
| 145 | 184 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 85 unchanged lines |
||
| 231 | 270 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
|
| 232 | 271 | ||
| 233 | 272 | [[package]] |
|
| 273 | + | name = "base64" |
|
| 274 | + | version = "0.23.1" |
|
| 275 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 276 | + | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" |
|
| 277 | + | ||
| 278 | + | [[package]] |
|
| 234 | 279 | name = "base64ct" |
|
| 235 | 280 | version = "1.8.3" |
|
| 236 | 281 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 14 unchanged lines |
||
| 251 | 296 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 252 | 297 | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" |
|
| 253 | 298 | dependencies = [ |
|
| 254 | - | "bit-vec", |
|
| 299 | + | "bit-vec 0.8.0", |
|
| 255 | 300 | ] |
|
| 256 | 301 | ||
| 257 | 302 | [[package]] |
|
| … | 3 unchanged lines |
||
| 261 | 306 | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" |
|
| 262 | 307 | ||
| 263 | 308 | [[package]] |
|
| 309 | + | name = "bit-vec" |
|
| 310 | + | version = "0.9.1" |
|
| 311 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 312 | + | checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" |
|
| 313 | + | dependencies = [ |
|
| 314 | + | "serde", |
|
| 315 | + | ] |
|
| 316 | + | ||
| 317 | + | [[package]] |
|
| 264 | 318 | name = "bitflags" |
|
| 265 | 319 | version = "2.13.2" |
|
| 266 | 320 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 306 unchanged lines |
||
| 573 | 627 | "quote", |
|
| 574 | 628 | "syn 2.0.119", |
|
| 575 | 629 | ] |
|
| 630 | + | ||
| 631 | + | [[package]] |
|
| 632 | + | name = "data-encoding" |
|
| 633 | + | version = "2.11.1" |
|
| 634 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 635 | + | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" |
|
| 576 | 636 | ||
| 577 | 637 | [[package]] |
|
| 578 | 638 | name = "der" |
|
| … | 14 unchanged lines |
||
| 593 | 653 | dependencies = [ |
|
| 594 | 654 | "const-oid 0.10.2", |
|
| 595 | 655 | "zeroize", |
|
| 656 | + | ] |
|
| 657 | + | ||
| 658 | + | [[package]] |
|
| 659 | + | name = "der-parser" |
|
| 660 | + | version = "10.0.0" |
|
| 661 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 662 | + | checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" |
|
| 663 | + | dependencies = [ |
|
| 664 | + | "asn1-rs", |
|
| 665 | + | "displaydoc", |
|
| 666 | + | "nom", |
|
| 667 | + | "num-bigint", |
|
| 668 | + | "num-traits", |
|
| 669 | + | "rusticata-macros", |
|
| 596 | 670 | ] |
|
| 671 | + | ||
| 672 | + | [[package]] |
|
| 673 | + | name = "deranged" |
|
| 674 | + | version = "0.5.8" |
|
| 675 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 676 | + | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" |
|
| 597 | 677 | ||
| 598 | 678 | [[package]] |
|
| 599 | 679 | name = "digest" |
|
| … | 106 unchanged lines |
||
| 706 | 786 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
|
| 707 | 787 | dependencies = [ |
|
| 708 | 788 | "libc", |
|
| 709 | - | "windows-sys 0.61.2", |
|
| 789 | + | "windows-sys 0.59.0", |
|
| 710 | 790 | ] |
|
| 711 | 791 | ||
| 712 | 792 | [[package]] |
|
| … | 730 unchanged lines |
||
| 1443 | 1523 | version = "0.3.17" |
|
| 1444 | 1524 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1445 | 1525 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" |
|
| 1526 | + | ||
| 1527 | + | [[package]] |
|
| 1528 | + | name = "minimal-lexical" |
|
| 1529 | + | version = "0.2.1" |
|
| 1530 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1531 | + | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" |
|
| 1446 | 1532 | ||
| 1447 | 1533 | [[package]] |
|
| 1448 | 1534 | name = "miniz_oxide" |
|
| … | 50 unchanged lines |
||
| 1499 | 1585 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" |
|
| 1500 | 1586 | ||
| 1501 | 1587 | [[package]] |
|
| 1588 | + | name = "nom" |
|
| 1589 | + | version = "7.1.3" |
|
| 1590 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1591 | + | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" |
|
| 1592 | + | dependencies = [ |
|
| 1593 | + | "memchr", |
|
| 1594 | + | "minimal-lexical", |
|
| 1595 | + | ] |
|
| 1596 | + | ||
| 1597 | + | [[package]] |
|
| 1502 | 1598 | name = "nu-ansi-term" |
|
| 1503 | 1599 | version = "0.50.3" |
|
| 1504 | 1600 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1505 | 1601 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" |
|
| 1506 | 1602 | dependencies = [ |
|
| 1507 | - | "windows-sys 0.61.2", |
|
| 1603 | + | "windows-sys 0.59.0", |
|
| 1508 | 1604 | ] |
|
| 1509 | 1605 | ||
| 1510 | 1606 | [[package]] |
|
| 1607 | + | name = "num-bigint" |
|
| 1608 | + | version = "0.4.8" |
|
| 1609 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1610 | + | checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" |
|
| 1611 | + | dependencies = [ |
|
| 1612 | + | "num-integer", |
|
| 1613 | + | "num-traits", |
|
| 1614 | + | ] |
|
| 1615 | + | ||
| 1616 | + | [[package]] |
|
| 1511 | 1617 | name = "num-bigint-dig" |
|
| 1512 | 1618 | version = "0.8.6" |
|
| 1513 | 1619 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 10 unchanged lines |
||
| 1524 | 1630 | ] |
|
| 1525 | 1631 | ||
| 1526 | 1632 | [[package]] |
|
| 1633 | + | name = "num-conv" |
|
| 1634 | + | version = "0.2.2" |
|
| 1635 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1636 | + | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" |
|
| 1637 | + | ||
| 1638 | + | [[package]] |
|
| 1527 | 1639 | name = "num-integer" |
|
| 1528 | 1640 | version = "0.1.47" |
|
| 1529 | 1641 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 27 unchanged lines |
||
| 1557 | 1669 | version = "0.4.0" |
|
| 1558 | 1670 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1559 | 1671 | checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" |
|
| 1672 | + | ||
| 1673 | + | [[package]] |
|
| 1674 | + | name = "oid-registry" |
|
| 1675 | + | version = "0.8.1" |
|
| 1676 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1677 | + | checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" |
|
| 1678 | + | dependencies = [ |
|
| 1679 | + | "asn1-rs", |
|
| 1680 | + | ] |
|
| 1560 | 1681 | ||
| 1561 | 1682 | [[package]] |
|
| 1562 | 1683 | name = "once_cell" |
|
| … | 61 unchanged lines |
||
| 1624 | 1745 | "redox_syscall 0.5.18", |
|
| 1625 | 1746 | "smallvec", |
|
| 1626 | 1747 | "windows-link", |
|
| 1748 | + | ] |
|
| 1749 | + | ||
| 1750 | + | [[package]] |
|
| 1751 | + | name = "pem" |
|
| 1752 | + | version = "4.0.0" |
|
| 1753 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1754 | + | checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" |
|
| 1755 | + | dependencies = [ |
|
| 1756 | + | "base64 0.23.1", |
|
| 1757 | + | "serde_core", |
|
| 1627 | 1758 | ] |
|
| 1628 | 1759 | ||
| 1629 | 1760 | [[package]] |
|
| … | 113 unchanged lines |
||
| 1743 | 1874 | dependencies = [ |
|
| 1744 | 1875 | "zerovec", |
|
| 1745 | 1876 | ] |
|
| 1877 | + | ||
| 1878 | + | [[package]] |
|
| 1879 | + | name = "powerfmt" |
|
| 1880 | + | version = "0.2.1" |
|
| 1881 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1882 | + | checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" |
|
| 1746 | 1883 | ||
| 1747 | 1884 | [[package]] |
|
| 1748 | 1885 | name = "ppv-lite86" |
|
| … | 26 unchanged lines |
||
| 1775 | 1912 | checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" |
|
| 1776 | 1913 | dependencies = [ |
|
| 1777 | 1914 | "bit-set", |
|
| 1778 | - | "bit-vec", |
|
| 1915 | + | "bit-vec 0.8.0", |
|
| 1779 | 1916 | "bitflags", |
|
| 1780 | 1917 | "num-traits", |
|
| 1781 | 1918 | "rand 0.9.5", |
|
| … | 165 unchanged lines |
||
| 1947 | 2084 | dependencies = [ |
|
| 1948 | 2085 | "crossbeam-deque", |
|
| 1949 | 2086 | "crossbeam-utils", |
|
| 2087 | + | ] |
|
| 2088 | + | ||
| 2089 | + | [[package]] |
|
| 2090 | + | name = "rcgen" |
|
| 2091 | + | version = "0.14.10" |
|
| 2092 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2093 | + | checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" |
|
| 2094 | + | dependencies = [ |
|
| 2095 | + | "pem", |
|
| 2096 | + | "ring", |
|
| 2097 | + | "rustls-pki-types", |
|
| 2098 | + | "time", |
|
| 2099 | + | "x509-parser", |
|
| 2100 | + | "yasna", |
|
| 1950 | 2101 | ] |
|
| 1951 | 2102 | ||
| 1952 | 2103 | [[package]] |
|
| … | 101 unchanged lines |
||
| 2054 | 2205 | ] |
|
| 2055 | 2206 | ||
| 2056 | 2207 | [[package]] |
|
| 2208 | + | name = "rusticata-macros" |
|
| 2209 | + | version = "4.1.0" |
|
| 2210 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2211 | + | checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" |
|
| 2212 | + | dependencies = [ |
|
| 2213 | + | "nom", |
|
| 2214 | + | ] |
|
| 2215 | + | ||
| 2216 | + | [[package]] |
|
| 2057 | 2217 | name = "rustix" |
|
| 2058 | 2218 | version = "1.1.5" |
|
| 2059 | 2219 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 3 unchanged lines |
||
| 2063 | 2223 | "errno", |
|
| 2064 | 2224 | "libc", |
|
| 2065 | 2225 | "linux-raw-sys", |
|
| 2066 | - | "windows-sys 0.61.2", |
|
| 2226 | + | "windows-sys 0.59.0", |
|
| 2067 | 2227 | ] |
|
| 2068 | 2228 | ||
| 2069 | 2229 | [[package]] |
|
| … | 395 unchanged lines |
||
| 2465 | 2625 | "hyper-util", |
|
| 2466 | 2626 | "oxy-core", |
|
| 2467 | 2627 | "rand 0.8.8", |
|
| 2628 | + | "rcgen", |
|
| 2629 | + | "ring", |
|
| 2630 | + | "rustls", |
|
| 2631 | + | "rustls-pemfile", |
|
| 2468 | 2632 | "serde", |
|
| 2469 | 2633 | "serde_json", |
|
| 2470 | 2634 | "spectra-api", |
|
| … | 1 unchanged lines |
||
| 2472 | 2636 | "sqlx", |
|
| 2473 | 2637 | "tempfile", |
|
| 2474 | 2638 | "tokio", |
|
| 2639 | + | "tokio-rustls", |
|
| 2475 | 2640 | "tokio-stream", |
|
| 2476 | 2641 | "tracing", |
|
| 2642 | + | "x509-parser", |
|
| 2477 | 2643 | ] |
|
| 2478 | 2644 | ||
| 2479 | 2645 | [[package]] |
|
| … | 50 unchanged lines |
||
| 2530 | 2696 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2531 | 2697 | checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" |
|
| 2532 | 2698 | dependencies = [ |
|
| 2533 | - | "base64", |
|
| 2699 | + | "base64 0.22.1", |
|
| 2534 | 2700 | "bytes", |
|
| 2535 | 2701 | "crc", |
|
| 2536 | 2702 | "crossbeam-queue", |
|
| … | 64 unchanged lines |
||
| 2601 | 2767 | checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" |
|
| 2602 | 2768 | dependencies = [ |
|
| 2603 | 2769 | "atoi", |
|
| 2604 | - | "base64", |
|
| 2770 | + | "base64 0.22.1", |
|
| 2605 | 2771 | "bitflags", |
|
| 2606 | 2772 | "byteorder", |
|
| 2607 | 2773 | "bytes", |
|
| … | 34 unchanged lines |
||
| 2642 | 2808 | checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" |
|
| 2643 | 2809 | dependencies = [ |
|
| 2644 | 2810 | "atoi", |
|
| 2645 | - | "base64", |
|
| 2811 | + | "base64 0.22.1", |
|
| 2646 | 2812 | "bitflags", |
|
| 2647 | 2813 | "byteorder", |
|
| 2648 | 2814 | "crc", |
|
| … | 130 unchanged lines |
||
| 2779 | 2945 | ||
| 2780 | 2946 | [[package]] |
|
| 2781 | 2947 | name = "synstructure" |
|
| 2948 | + | version = "0.13.2" |
|
| 2949 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2950 | + | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" |
|
| 2951 | + | dependencies = [ |
|
| 2952 | + | "proc-macro2", |
|
| 2953 | + | "quote", |
|
| 2954 | + | "syn 2.0.119", |
|
| 2955 | + | ] |
|
| 2956 | + | ||
| 2957 | + | [[package]] |
|
| 2958 | + | name = "synstructure" |
|
| 2782 | 2959 | version = "0.14.0" |
|
| 2783 | 2960 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2784 | 2961 | checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" |
|
| … | 10 unchanged lines |
||
| 2795 | 2972 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" |
|
| 2796 | 2973 | dependencies = [ |
|
| 2797 | 2974 | "fastrand", |
|
| 2798 | - | "getrandom 0.4.3", |
|
| 2975 | + | "getrandom 0.3.4", |
|
| 2799 | 2976 | "once_cell", |
|
| 2800 | 2977 | "rustix", |
|
| 2801 | - | "windows-sys 0.61.2", |
|
| 2978 | + | "windows-sys 0.59.0", |
|
| 2802 | 2979 | ] |
|
| 2803 | 2980 | ||
| 2804 | 2981 | [[package]] |
|
| … | 52 unchanged lines |
||
| 2857 | 3034 | checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" |
|
| 2858 | 3035 | dependencies = [ |
|
| 2859 | 3036 | "cfg-if", |
|
| 3037 | + | ] |
|
| 3038 | + | ||
| 3039 | + | [[package]] |
|
| 3040 | + | name = "time" |
|
| 3041 | + | version = "0.3.55" |
|
| 3042 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3043 | + | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" |
|
| 3044 | + | dependencies = [ |
|
| 3045 | + | "deranged", |
|
| 3046 | + | "num-conv", |
|
| 3047 | + | "powerfmt", |
|
| 3048 | + | "serde_core", |
|
| 3049 | + | "time-core", |
|
| 3050 | + | "time-macros", |
|
| 3051 | + | ] |
|
| 3052 | + | ||
| 3053 | + | [[package]] |
|
| 3054 | + | name = "time-core" |
|
| 3055 | + | version = "0.1.9" |
|
| 3056 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3057 | + | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" |
|
| 3058 | + | ||
| 3059 | + | [[package]] |
|
| 3060 | + | name = "time-macros" |
|
| 3061 | + | version = "0.2.32" |
|
| 3062 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3063 | + | checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" |
|
| 3064 | + | dependencies = [ |
|
| 3065 | + | "num-conv", |
|
| 3066 | + | "time-core", |
|
| 2860 | 3067 | ] |
|
| 2861 | 3068 | ||
| 2862 | 3069 | [[package]] |
|
| … | 40 unchanged lines |
||
| 2903 | 3110 | ] |
|
| 2904 | 3111 | ||
| 2905 | 3112 | [[package]] |
|
| 3113 | + | name = "tokio-rustls" |
|
| 3114 | + | version = "0.26.5" |
|
| 3115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3116 | + | checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" |
|
| 3117 | + | dependencies = [ |
|
| 3118 | + | "rustls", |
|
| 3119 | + | "tokio", |
|
| 3120 | + | ] |
|
| 3121 | + | ||
| 3122 | + | [[package]] |
|
| 2906 | 3123 | name = "tokio-stream" |
|
| 2907 | 3124 | version = "0.1.19" |
|
| 2908 | 3125 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 218 unchanged lines |
||
| 3127 | 3344 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3128 | 3345 | checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" |
|
| 3129 | 3346 | dependencies = [ |
|
| 3130 | - | "base64", |
|
| 3347 | + | "base64 0.22.1", |
|
| 3131 | 3348 | "flate2", |
|
| 3132 | 3349 | "log", |
|
| 3133 | 3350 | "once_cell", |
|
| … | 195 unchanged lines |
||
| 3329 | 3546 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3330 | 3547 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" |
|
| 3331 | 3548 | dependencies = [ |
|
| 3332 | - | "windows-sys 0.61.2", |
|
| 3549 | + | "windows-sys 0.48.0", |
|
| 3333 | 3550 | ] |
|
| 3334 | 3551 | ||
| 3335 | 3552 | [[package]] |
|
| … | 179 unchanged lines |
||
| 3515 | 3732 | version = "0.6.4" |
|
| 3516 | 3733 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3517 | 3734 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" |
|
| 3735 | + | ||
| 3736 | + | [[package]] |
|
| 3737 | + | name = "x509-parser" |
|
| 3738 | + | version = "0.18.1" |
|
| 3739 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3740 | + | checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" |
|
| 3741 | + | dependencies = [ |
|
| 3742 | + | "asn1-rs", |
|
| 3743 | + | "data-encoding", |
|
| 3744 | + | "der-parser", |
|
| 3745 | + | "lazy_static", |
|
| 3746 | + | "nom", |
|
| 3747 | + | "oid-registry", |
|
| 3748 | + | "ring", |
|
| 3749 | + | "rusticata-macros", |
|
| 3750 | + | "thiserror 2.0.21", |
|
| 3751 | + | "time", |
|
| 3752 | + | ] |
|
| 3753 | + | ||
| 3754 | + | [[package]] |
|
| 3755 | + | name = "yasna" |
|
| 3756 | + | version = "0.6.0" |
|
| 3757 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3758 | + | checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" |
|
| 3759 | + | dependencies = [ |
|
| 3760 | + | "bit-vec 0.9.1", |
|
| 3761 | + | "time", |
|
| 3762 | + | ] |
|
| 3518 | 3763 | ||
| 3519 | 3764 | [[package]] |
|
| 3520 | 3765 | name = "yoke" |
|
| … | 15 unchanged lines |
||
| 3536 | 3781 | "proc-macro2", |
|
| 3537 | 3782 | "quote", |
|
| 3538 | 3783 | "syn 3.0.6", |
|
| 3539 | - | "synstructure", |
|
| 3784 | + | "synstructure 0.14.0", |
|
| 3540 | 3785 | ] |
|
| 3541 | 3786 | ||
| 3542 | 3787 | [[package]] |
|
| … | 34 unchanged lines |
||
| 3577 | 3822 | "proc-macro2", |
|
| 3578 | 3823 | "quote", |
|
| 3579 | 3824 | "syn 3.0.6", |
|
| 3580 | - | "synstructure", |
|
| 3825 | + | "synstructure 0.14.0", |
|
| 3581 | 3826 | ] |
|
| 3582 | 3827 | ||
| 3583 | 3828 | [[package]] |
|
| … | 77 unchanged lines |
modified crates/spectra-core/src/authn.rs +5 −0
| … | 27 unchanged lines |
||
| 28 | 28 | /// The key is bound to another Unix user (logged as `guard.impersonation`). |
|
| 29 | 29 | #[error("agent {agent}'s key is bound to uid {bound}, used from uid {from}")] |
|
| 30 | 30 | WrongUid { agent: String, bound: u32, from: u32 }, |
|
| 31 | + | /// The key is bound to a Unix user but came over TCP, where there is no |
|
| 32 | + | /// peer uid to check (spec §8.6; logged as `guard.impersonation`). |
|
| 33 | + | #[error("agent {agent}'s key is bound to uid {bound} and must come over the Unix socket, not the network")] |
|
| 34 | + | BoundOverNetwork { agent: String, bound: u32 }, |
|
| 31 | 35 | } |
|
| 32 | 36 | ||
| 33 | 37 | /// The outcome: who, and the `auth` for their events. |
|
| … | 38 unchanged lines |
||
| 72 | 76 | if let Principal::Agent { name, uid, learn_uid: learn, .. } = &principal { |
|
| 73 | 77 | match (uid, peer_uid) { |
|
| 74 | 78 | (Some(bound), Some(from)) if *bound != from => return Err(Refusal::WrongUid { agent: name.clone(), bound: *bound, from }), |
|
| 79 | + | (Some(bound), None) => return Err(Refusal::BoundOverNetwork { agent: name.clone(), bound: *bound }), |
|
| 75 | 80 | (None, Some(from)) if *learn => learn_uid = Some(from), |
|
| 76 | 81 | _ => {} |
|
| 77 | 82 | } |
|
| … | 15 unchanged lines |
modified crates/spectra-core/src/daemon.rs +9 −0
| … | 113 unchanged lines |
||
| 114 | 114 | let _ = self.ledger.append_one(e).await; |
|
| 115 | 115 | tracing::warn!(agent, bound, from, "a key used from the wrong Unix user"); |
|
| 116 | 116 | } |
|
| 117 | + | Err(Refusal::BoundOverNetwork { agent, bound }) => { |
|
| 118 | + | let e = NewEvent::system( |
|
| 119 | + | "guard.impersonation", |
|
| 120 | + | serde_json::json!({ "agent": agent, "bound_uid": bound, "from_uid": null, "over": "tcp", "method": method }), |
|
| 121 | + | ) |
|
| 122 | + | .subject(agent.clone()); |
|
| 123 | + | let _ = self.ledger.append_one(e).await; |
|
| 124 | + | tracing::warn!(agent, bound, "a uid-bound key used over the network"); |
|
| 125 | + | } |
|
| 117 | 126 | Ok(Caller { principal: Principal::Agent { name, .. }, learn_uid: Some(uid), .. }) => { |
|
| 118 | 127 | let e = NewEvent::system("agent.uid_bound", serde_json::json!({ "name": name, "uid": uid })).subject(name.clone()); |
|
| 119 | 128 | let _ = self.ledger.append_one(e).await; |
|
| … | 141 unchanged lines |
modified crates/spectra-mcp/Cargo.toml +7 −0
| … | 22 unchanged lines |
||
| 23 | 23 | hyper = { version = "1", features = ["client", "http1"] } |
|
| 24 | 24 | hyper-util = { version = "0.1", features = ["tokio"] } |
|
| 25 | 25 | http-body-util = "0.1" |
|
| 26 | + | # TLS between machines (spec §8.6), as OxygenHub has it: rustls on ring. |
|
| 27 | + | rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } |
|
| 28 | + | tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } |
|
| 29 | + | rustls-pemfile = "2" |
|
| 30 | + | rcgen = "0.14" |
|
| 31 | + | x509-parser = "0.18" |
|
| 32 | + | ring = "0.17" |
|
| 26 | 33 | ||
| 27 | 34 | [dev-dependencies] |
|
| 28 | 35 | tempfile.workspace = true |
modified crates/spectra-mcp/src/client.rs +65 −8
| … | 25 unchanged lines |
||
| 26 | 26 | } |
|
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | + | /// Where the daemon is: its Unix socket, or a server over TLS with a |
|
| 30 | + | /// pinned certificate (spec §8.6). |
|
| 31 | + | #[derive(Clone)] |
|
| 32 | + | pub enum Endpoint { |
|
| 33 | + | Unix(PathBuf), |
|
| 34 | + | Tls { addr: String, config: std::sync::Arc<rustls::ClientConfig> }, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | impl Endpoint { |
|
| 38 | + | /// `https://HOST:PORT` accepting exactly the certificate pinned by `pin`. |
|
| 39 | + | pub fn tls(url: &str, pin: [u8; 32]) -> Result<Endpoint> { |
|
| 40 | + | Ok(Endpoint::Tls { addr: host_port(url)?, config: crate::tls::Pinned::new(Some(pin)).client_config()? }) |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | pub fn describe(&self) -> String { |
|
| 44 | + | match self { |
|
| 45 | + | Endpoint::Unix(p) => p.display().to_string(), |
|
| 46 | + | Endpoint::Tls { addr, .. } => format!("https://{addr}"), |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// `host:port` of an `https://` URL. |
|
| 52 | + | pub fn host_port(url: &str) -> Result<String> { |
|
| 53 | + | let rest = url.strip_prefix("https://").with_context(|| format!("{url} is not an https:// URL"))?; |
|
| 54 | + | let hp = rest.split('/').next().unwrap_or(""); |
|
| 55 | + | if hp.is_empty() { |
|
| 56 | + | anyhow::bail!("{url} names no host"); |
|
| 57 | + | } |
|
| 58 | + | // The port defaults to Spectra's. |
|
| 59 | + | let has_port = if hp.starts_with('[') { hp.contains("]:") } else { hp.contains(':') }; |
|
| 60 | + | Ok(if has_port { hp.to_string() } else { format!("{hp}:7350") }) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | trait Io: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {} |
|
| 64 | + | impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> Io for T {} |
|
| 65 | + | ||
| 29 | 66 | pub struct Client { |
|
| 30 | - | socket: PathBuf, |
|
| 67 | + | endpoint: Endpoint, |
|
| 31 | 68 | signer: Option<KeyPair>, |
|
| 32 | 69 | } |
|
| 33 | 70 | ||
| 34 | 71 | impl Client { |
|
| 35 | 72 | pub fn new(socket: &Path, signer: Option<KeyPair>) -> Client { |
|
| 36 | - | Client { socket: socket.to_path_buf(), signer } |
|
| 73 | + | Client { endpoint: Endpoint::Unix(socket.to_path_buf()), signer } |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | pub fn at(endpoint: Endpoint, signer: Option<KeyPair>) -> Client { |
|
| 77 | + | Client { endpoint, signer } |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | pub fn endpoint(&self) -> &Endpoint { |
|
| 81 | + | &self.endpoint |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | async fn io(&self) -> Result<Box<dyn Io>> { |
|
| 85 | + | Ok(match &self.endpoint { |
|
| 86 | + | Endpoint::Unix(socket) => Box::new( |
|
| 87 | + | tokio::net::UnixStream::connect(socket) |
|
| 88 | + | .await |
|
| 89 | + | .with_context(|| format!("connecting to {} (is `spectra serve` running?)", socket.display()))?, |
|
| 90 | + | ), |
|
| 91 | + | Endpoint::Tls { addr, config } => Box::new(crate::tls::connect(addr, config.clone()).await?), |
|
| 92 | + | }) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | /// Whether the daemon answers at the endpoint. |
|
| 96 | + | pub async fn reachable(&self) -> bool { |
|
| 97 | + | self.io().await.is_ok() |
|
| 37 | 98 | } |
|
| 38 | 99 | ||
| 39 | 100 | /// Whether a daemon answers on the socket. |
|
| … | 3 unchanged lines |
||
| 43 | 104 | ||
| 44 | 105 | /// Send `method path` with `body` and extra headers, signed. |
|
| 45 | 106 | pub async fn send(&self, method: &str, path: &str, headers: &[(&str, String)], body: Vec<u8>) -> Result<Response> { |
|
| 46 | - | let stream = tokio::net::UnixStream::connect(&self.socket) |
|
| 47 | - | .await |
|
| 48 | - | .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?; |
|
| 107 | + | let stream = self.io().await?; |
|
| 49 | 108 | let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?; |
|
| 50 | 109 | tokio::spawn(async move { |
|
| 51 | 110 | let _ = conn.await; |
|
| … | 39 unchanged lines |
||
| 91 | 150 | /// Send a signed GET and hand each line of the streamed body to `line` |
|
| 92 | 151 | /// as it arrives (an SSE stream, say); stop when it returns false. |
|
| 93 | 152 | pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> { |
|
| 94 | - | let stream = tokio::net::UnixStream::connect(&self.socket) |
|
| 95 | - | .await |
|
| 96 | - | .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?; |
|
| 153 | + | let stream = self.io().await?; |
|
| 97 | 154 | let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?; |
|
| 98 | 155 | tokio::spawn(async move { |
|
| 99 | 156 | let _ = conn.await; |
|
| … | 54 unchanged lines |
modified crates/spectra-mcp/src/http.rs +31 −0
| … | 39 unchanged lines |
||
| 40 | 40 | } |
|
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | + | impl Connected<axum::serve::IncomingStream<'_, crate::tls::TlsListener>> for PeerInfo { |
|
| 44 | + | fn connect_info(_: axum::serve::IncomingStream<'_, crate::tls::TlsListener>) -> Self { |
|
| 45 | + | PeerInfo(None) |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 43 | 49 | /// The app state: the daemon, and the origins a browser may send from. |
|
| 44 | 50 | #[derive(Clone)] |
|
| 45 | 51 | pub struct AppState { |
|
| … | 8 unchanged lines |
||
| 54 | 60 | Router::new() |
|
| 55 | 61 | .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed)) |
|
| 56 | 62 | .route("/api/v1/ledger/events", post(owner_events)) |
|
| 63 | + | .route("/api/v1/instance", axum::routing::get(instance)) |
|
| 57 | 64 | .with_state(state) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// `GET /api/v1/instance`, unauthenticated (spec §8.6): who this Spectra |
|
| 68 | + | /// is, whose it is, and the instance key's word for its TLS certificate, so |
|
| 69 | + | /// a client can decide whether to trust it before it signs anything. |
|
| 70 | + | async fn instance(State(state): State<AppState>) -> Response { |
|
| 71 | + | let (instance, owner) = { |
|
| 72 | + | let p = state.daemon.principals.read().await; |
|
| 73 | + | (p.instance().cloned(), p.owner().map(|o| o.hash().to_hex())) |
|
| 74 | + | }; |
|
| 75 | + | let Some(instance) = instance else { |
|
| 76 | + | return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response(); |
|
| 77 | + | }; |
|
| 78 | + | let tls = match crate::instance::latest_tls(&state.daemon.ledger).await { |
|
| 79 | + | Ok(Some(e)) => json!({ "payload": e.json(), "proof": e.auth.proof.as_ref().map(hex::encode) }), |
|
| 80 | + | _ => Json::Null, |
|
| 81 | + | }; |
|
| 82 | + | axum::Json(json!({ |
|
| 83 | + | "name": instance.name, |
|
| 84 | + | "instance": spectra_core::principals::identity_payload(&instance), |
|
| 85 | + | "owner": owner, |
|
| 86 | + | "tls": tls, |
|
| 87 | + | })) |
|
| 88 | + | .into_response() |
|
| 58 | 89 | } |
|
| 59 | 90 | ||
| 60 | 91 | async fn not_allowed() -> StatusCode { |
|
| … | 146 unchanged lines |
modified crates/spectra-mcp/src/lib.rs +2 −0
| … | 4 unchanged lines |
||
| 5 | 5 | pub mod api; |
|
| 6 | 6 | pub mod client; |
|
| 7 | 7 | pub mod http; |
|
| 8 | + | pub mod instance; |
|
| 8 | 9 | pub mod protocol; |
|
| 9 | 10 | pub mod session; |
|
| 11 | + | pub mod tls; |
|
| 10 | 12 | pub mod tools; |
|
| 11 | 13 | ||
| 12 | 14 | /// The MCP revisions Tuner speaks, newest first (spec §15.1). |
|
| … | 9 unchanged lines |
modified docs/plan.md +1 −1
| … | 29 unchanged lines |
||
| 30 | 30 | ||
| 31 | 31 | ## Sprint 2: client and server |
|
| 32 | 32 | ||
| 33 | - | - [ ] #20 Server over TLS: a certificate signed by the instance key, /api/v1/instance, no uid over TCP. |
|
| 33 | + | - [x] #20 Server over TLS — done 2026-11-03: own certificate in keys/tls.*, vouched for by a signed instance.tls event; GET /api/v1/instance; pinned client transport; a uid-bound agent refused over TCP; tests/tls.rs. |
|
| 34 | 34 | - [ ] #22 Remote setup: spectra init --owner KEYID and the owner's claim from the client. |
|
| 35 | 35 | - [ ] #21 spectra connect and the TLS client: trust through the owner's delegation, pinned certificate, shims over the network. |
|
| 36 | 36 | - [ ] #23 The CLI without a ledger file: every command through the JSON API. |
|
| … | 20 unchanged lines |
modified docs/spec.md +1 −1
| … | 337 unchanged lines |
||
| 338 | 338 | ||
| 339 | 339 | Spectra may run on one machine and be used from others: the daemon on a server, and the owner, the CLI, the shims and the agents on a workstation. Nothing about identity changes. Every request is still signed where the key lives (§8.5); keys never leave the machine they were made on; the server holds only its instance key. |
|
| 340 | 340 | ||
| 341 | - | **The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key signs the certificate's binding, `"spectra1 tls " || SHA-256(SubjectPublicKeyInfo)`, through the challenge signing of §8.5, and the daemon records it as an `instance.tls` event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, the certificate's SubjectPublicKeyInfo hash and the binding signature. |
|
| 341 | + | **The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key vouches for the certificate with an `instance.tls` event, signed over its payload as CLI events are (§9.5), naming the SHA-256 of the certificate's SubjectPublicKeyInfo and its names; the daemon appends one whenever the certificate differs from the last recorded, and `spectra admin verify` checks it like any signed event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, and the latest `instance.tls` payload with its proof. |
|
| 342 | 342 | ||
| 343 | 343 | **Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity (their `OXY_HOME`) delegates to that instance key now. Until the owner has delegated, it saves the instance's public identity and prints the `oxy identity delegate add` that would; it never asks the owner to trust a fingerprint by eye. On success it writes the client configuration, `$XDG_CONFIG_HOME/spectra/client.toml`: |
|
| 344 | 344 | ||
| … | 683 unchanged lines |
modified src/cli/mod.rs +11 −5
| … | 56 unchanged lines |
||
| 57 | 57 | }, |
|
| 58 | 58 | /// Run the daemon: the ledger, Tuner on the Unix socket and a loopback port |
|
| 59 | 59 | Serve { |
|
| 60 | - | /// The loopback address and port for browsers and HTTP clients |
|
| 61 | - | #[arg(long, default_value = "127.0.0.1:7350")] |
|
| 62 | - | bind: String, |
|
| 60 | + | /// The address and port for browsers and HTTP clients (default: |
|
| 61 | + | /// `[server] bind`, else 127.0.0.1:7350); beyond loopback, other |
|
| 62 | + | /// machines connect over TLS (spec §8.6) |
|
| 63 | + | #[arg(long)] |
|
| 64 | + | bind: Option<String>, |
|
| 65 | + | /// TLS on the TCP port: auto (beyond loopback only), on, or off |
|
| 66 | + | /// (default: `[server] tls`, else auto) |
|
| 67 | + | #[arg(long)] |
|
| 68 | + | tls: Option<String>, |
|
| 63 | 69 | /// Allow a non-loopback address in plain HTTP |
|
| 64 | 70 | #[arg(long)] |
|
| 65 | 71 | insecure: bool, |
|
| … | 290 unchanged lines |
||
| 356 | 362 | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 357 | 363 | } |
|
| 358 | 364 | }), |
|
| 359 | - | Command::Serve { bind, insecure, open } => { |
|
| 365 | + | Command::Serve { bind, tls, insecure, open } => { |
|
| 360 | 366 | init_logging(); |
|
| 361 | - | runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open)) |
|
| 367 | + | runtime()?.block_on(serve::serve(&ctx, bind, tls, insecure, open)) |
|
| 362 | 368 | } |
|
| 363 | 369 | Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)), |
|
| 364 | 370 | Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)), |
|
| … | 49 unchanged lines |
modified src/cli/serve.rs +115 −9
| 1 | 1 | //! `spectra serve`: the daemon (spec §7.1, §21.1). It holds the ledger, |
|
| 2 | - | //! serves Tuner (and, as they land, the API and Radar) on the Unix socket |
|
| 3 | - | //! and on a loopback port, and writes a checkpoint when it stops. |
|
| 2 | + | //! serves Tuner, the API and Radar on the Unix socket and on a TCP port, |
|
| 3 | + | //! over TLS beyond loopback (§8.6), and writes a checkpoint when it stops. |
|
| 4 | 4 | ||
| 5 | 5 | use super::{ui, Ctx}; |
|
| 6 | 6 | use anyhow::{bail, Context, Result}; |
|
| … | 3 unchanged lines |
||
| 10 | 10 | use std::net::SocketAddr; |
|
| 11 | 11 | use std::sync::Arc; |
|
| 12 | 12 | ||
| 13 | - | pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool, open: bool) -> Result<i32> { |
|
| 14 | - | let addr: SocketAddr = bind.parse().with_context(|| format!("--bind {bind} is not an address and port"))?; |
|
| 15 | - | if !addr.ip().is_loopback() && !insecure { |
|
| 16 | - | bail!("Spectra serves loopback only unless it has TLS (spec §21.1); pass --insecure to bind {addr} in plain HTTP anyway"); |
|
| 13 | + | /// `[server]` of spectra.toml, with the command line's overrides. |
|
| 14 | + | struct ServerCfg { |
|
| 15 | + | bind: String, |
|
| 16 | + | tls: String, |
|
| 17 | + | files: Option<(std::path::PathBuf, std::path::PathBuf)>, |
|
| 18 | + | names: Vec<String>, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | fn server_cfg(ctx: &Ctx, bind: Option<String>, tls: Option<String>) -> Result<ServerCfg> { |
|
| 22 | + | let text = std::fs::read_to_string(ctx.data.join("spectra.toml")).unwrap_or_default(); |
|
| 23 | + | let t: toml::Table = text.parse().with_context(|| format!("reading {}", ctx.data.join("spectra.toml").display()))?; |
|
| 24 | + | let server = t.get("server").and_then(|s| s.as_table()).cloned().unwrap_or_default(); |
|
| 25 | + | let (mut mode, mut files) = (String::from("auto"), None); |
|
| 26 | + | match server.get("tls") { |
|
| 27 | + | Some(toml::Value::String(m)) => mode = m.clone(), |
|
| 28 | + | Some(toml::Value::Table(f)) => { |
|
| 29 | + | let path = |k: &str| f.get(k).and_then(|v| v.as_str()).map(std::path::PathBuf::from); |
|
| 30 | + | files = Some((path("cert").context("[server] tls needs cert")?, path("key").context("[server] tls needs key")?)); |
|
| 31 | + | mode = "on".into(); |
|
| 32 | + | } |
|
| 33 | + | _ => {} |
|
| 34 | + | } |
|
| 35 | + | if let Some(m) = tls { |
|
| 36 | + | mode = m; |
|
| 37 | + | } |
|
| 38 | + | if !matches!(mode.as_str(), "auto" | "on" | "off") { |
|
| 39 | + | bail!("TLS is auto, on or off, not {mode}"); |
|
| 40 | + | } |
|
| 41 | + | let names = match server.get("names").and_then(|n| n.as_array()) { |
|
| 42 | + | Some(a) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(), |
|
| 43 | + | None => default_names(), |
|
| 44 | + | }; |
|
| 45 | + | let bind = bind.or_else(|| server.get("bind").and_then(|b| b.as_str()).map(String::from)).unwrap_or_else(|| "127.0.0.1:7350".into()); |
|
| 46 | + | Ok(ServerCfg { bind, tls: mode, files, names }) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// The host name, its fully qualified name, and localhost. |
|
| 50 | + | fn default_names() -> Vec<String> { |
|
| 51 | + | let mut names = Vec::new(); |
|
| 52 | + | let short = std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string()).filter(|h| !h.is_empty()); |
|
| 53 | + | let fqdn = std::process::Command::new("hostname") |
|
| 54 | + | .arg("--fqdn") |
|
| 55 | + | .output() |
|
| 56 | + | .ok() |
|
| 57 | + | .filter(|o| o.status.success()) |
|
| 58 | + | .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) |
|
| 59 | + | .filter(|h| !h.is_empty()); |
|
| 60 | + | for n in [short, fqdn, Some("localhost".to_string())].into_iter().flatten() { |
|
| 61 | + | if !names.contains(&n) { |
|
| 62 | + | names.push(n); |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | names |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | type Served = std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send>>; |
|
| 69 | + | ||
| 70 | + | pub async fn serve(ctx: &Ctx, bind: Option<String>, tls: Option<String>, insecure: bool, open: bool) -> Result<i32> { |
|
| 71 | + | let cfg = server_cfg(ctx, bind, tls)?; |
|
| 72 | + | let addr: SocketAddr = cfg.bind.parse().with_context(|| format!("{} is not an address and port", cfg.bind))?; |
|
| 73 | + | let use_tls = match cfg.tls.as_str() { |
|
| 74 | + | "on" => true, |
|
| 75 | + | "off" => false, |
|
| 76 | + | _ => !addr.ip().is_loopback(), |
|
| 77 | + | }; |
|
| 78 | + | if !addr.ip().is_loopback() && !use_tls && !insecure { |
|
| 79 | + | bail!("Spectra serves other machines over TLS only (spec §21.1); drop --tls off, or pass --insecure to bind {addr} in plain HTTP anyway"); |
|
| 17 | 80 | } |
|
| 18 | 81 | let socket = ctx.socket(); |
|
| 19 | 82 | if socket.exists() { |
|
| … | 11 unchanged lines |
||
| 31 | 94 | } |
|
| 32 | 95 | let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?; |
|
| 33 | 96 | let port = tcp.local_addr()?.port(); |
|
| 34 | - | let origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")]; |
|
| 97 | + | let mut origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")]; |
|
| 98 | + | let tls = if use_tls { |
|
| 99 | + | let source = match &cfg.files { |
|
| 100 | + | Some((cert, key)) => spectra_mcp::tls::CertSource::Files { cert: cert.clone(), key: key.clone() }, |
|
| 101 | + | None => spectra_mcp::tls::CertSource::Own { names: cfg.names.clone() }, |
|
| 102 | + | }; |
|
| 103 | + | let t = spectra_mcp::tls::ServerTls::load(&ctx.data, &source)?; |
|
| 104 | + | let instance = spectra_core::keys::KeyPair::instance(&ctx.data, "spectra")?; |
|
| 105 | + | spectra_mcp::instance::record_tls(&daemon.ledger, &instance, &t.spki_sha256, &t.names).await?; |
|
| 106 | + | for n in &cfg.names { |
|
| 107 | + | origins.push(format!("https://{n}:{port}")); |
|
| 108 | + | } |
|
| 109 | + | origins.push(format!("https://{addr}")); |
|
| 110 | + | Some(t) |
|
| 111 | + | } else { |
|
| 112 | + | None |
|
| 113 | + | }; |
|
| 35 | 114 | let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins), sessions: Default::default() }; |
|
| 36 | 115 | let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::router(state.clone())).merge(radar::router(state)); |
|
| 37 | 116 | ||
| … | 4 unchanged lines |
||
| 42 | 121 | .unwrap_or(0); |
|
| 43 | 122 | println!("{} {}", ui::done("Serving"), ui::subject("Spectra")); |
|
| 44 | 123 | println!("{}", ui::kv("socket", ui::path(socket.display()))); |
|
| 45 | - | println!("{}", ui::kv("http", format!("http://127.0.0.1:{port}"))); |
|
| 124 | + | match &tls { |
|
| 125 | + | Some(t) => { |
|
| 126 | + | let host = if addr.ip().is_unspecified() { |
|
| 127 | + | cfg.names.first().cloned().unwrap_or_else(|| "localhost".into()) |
|
| 128 | + | } else { |
|
| 129 | + | addr.ip().to_string() |
|
| 130 | + | }; |
|
| 131 | + | println!("{}", ui::kv("https", format!("https://{host}:{port}"))); |
|
| 132 | + | println!( |
|
| 133 | + | "{}", |
|
| 134 | + | ui::kv( |
|
| 135 | + | "certificate", |
|
| 136 | + | format!("{}{}", ui::hash(hex::encode(&t.spki_sha256[..8])), ui::dim(" (signed by the instance key)")) |
|
| 137 | + | ) |
|
| 138 | + | ); |
|
| 139 | + | } |
|
| 140 | + | None => println!("{}", ui::kv("http", format!("http://{}:{port}", addr.ip()))), |
|
| 141 | + | } |
|
| 46 | 142 | println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep()))); |
|
| 47 | 143 | println!(" {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME")); |
|
| 48 | 144 | println!(" {} {}", ui::note(), ui::dim("open Radar with: spectra open")); |
|
| … | 14 unchanged lines |
||
| 63 | 159 | }; |
|
| 64 | 160 | let unix_srv = |
|
| 65 | 161 | axum::serve(unix, app.clone().into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx.clone())); |
|
| 66 | - | let tcp_srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx)); |
|
| 162 | + | let tcp_srv: Served = match &tls { |
|
| 163 | + | Some(t) => { |
|
| 164 | + | let listener = spectra_mcp::tls::TlsListener::new(tcp, t.config.clone())?; |
|
| 165 | + | let srv = axum::serve(listener, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx)); |
|
| 166 | + | Box::pin(async move { srv.await }) |
|
| 167 | + | } |
|
| 168 | + | None => { |
|
| 169 | + | let srv = axum::serve(tcp, app.into_make_service_with_connect_info::<PeerInfo>()).with_graceful_shutdown(stop(stop_rx)); |
|
| 170 | + | Box::pin(async move { srv.await }) |
|
| 171 | + | } |
|
| 172 | + | }; |
|
| 67 | 173 | let signal = async move { |
|
| 68 | 174 | shutdown_signal().await; |
|
| 69 | 175 | let _ = stop_tx.send(true); |
|
| … | 39 unchanged lines |
modified tests/common/mod.rs +19 −0
| … | 73 unchanged lines |
||
| 74 | 74 | child.wait().ok(); |
|
| 75 | 75 | panic!("spectra serve did not start"); |
|
| 76 | 76 | } |
|
| 77 | + | /// Serve with TLS on a loopback port of its own; returns the port. |
|
| 78 | + | pub fn start_tls(d: &Path) -> (Serving, u16) { |
|
| 79 | + | use std::io::BufRead; |
|
| 80 | + | let mut child = |
|
| 81 | + | hermetic(d).args(["serve", "--bind", "127.0.0.1:0", "--tls", "on"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); |
|
| 82 | + | let out = child.stdout.take().unwrap(); |
|
| 83 | + | let (tx, rx) = std::sync::mpsc::channel(); |
|
| 84 | + | std::thread::spawn(move || { |
|
| 85 | + | for line in std::io::BufReader::new(out).lines().map_while(Result::ok) { |
|
| 86 | + | if let Some(url) = line.split_whitespace().find(|w| w.starts_with("https://")) { |
|
| 87 | + | let port: u16 = url.rsplit(':').next().unwrap().parse().unwrap(); |
|
| 88 | + | let _ = tx.send(port); |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | }); |
|
| 92 | + | let port = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("spectra serve printed its https address"); |
|
| 93 | + | (Serving { child, socket: d.join("data/spectra.sock") }, port) |
|
| 94 | + | } |
|
| 95 | + | ||
| 77 | 96 | pub fn stop(mut self) { |
|
| 78 | 97 | unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; |
|
| 79 | 98 | let status = self.child.wait().unwrap(); |
|
| … | 14 unchanged lines |