diff --git a/core/crates/solstone-core-convey-http/src/envelope.rs b/core/crates/solstone-core-convey-http/src/envelope.rs index fc7b61408..573b40aea 100644 --- a/core/crates/solstone-core-convey-http/src/envelope.rs +++ b/core/crates/solstone-core-convey-http/src/envelope.rs @@ -38,7 +38,7 @@ pub fn error_envelope( pub async fn not_found_fallback( Extension(basis): Extension, ) -> (StatusCode, Json) { - require_access(basis); + require_access(&basis); error_envelope( "not_found", "Not Found", diff --git a/core/crates/solstone-core-convey-http/src/gate.rs b/core/crates/solstone-core-convey-http/src/gate.rs index 3a6ca26c7..98c91a966 100644 --- a/core/crates/solstone-core-convey-http/src/gate.rs +++ b/core/crates/solstone-core-convey-http/src/gate.rs @@ -4,7 +4,7 @@ use crate::identity::AccessBasis; /// Admit one of the two closed, accept-time access bases. -pub fn require_access(basis: AccessBasis) -> bool { +pub fn require_access(basis: &AccessBasis) -> bool { match basis { AccessBasis::Localhost | AccessBasis::LinkedDevice { .. } => true, } @@ -13,15 +13,20 @@ pub fn require_access(basis: AccessBasis) -> bool { #[cfg(test)] mod tests { use super::require_access; - use crate::identity::{AccessBasis, Carrier}; + use crate::identity::{AccessBasis, Carrier, LinkedDeviceDid}; + + const VALID_DID: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; #[test] fn carrier_is_observability_only_at_the_access_gate() { - assert!(require_access(AccessBasis::LinkedDevice { + assert!(require_access(&AccessBasis::LinkedDevice { carrier: Carrier::Direct, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), })); - assert!(require_access(AccessBasis::LinkedDevice { + assert!(require_access(&AccessBasis::LinkedDevice { carrier: Carrier::ViaSpl, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), })); } } diff --git a/core/crates/solstone-core-convey-http/src/identity.rs b/core/crates/solstone-core-convey-http/src/identity.rs index b919cc7c5..f7480a034 100644 --- a/core/crates/solstone-core-convey-http/src/identity.rs +++ b/core/crates/solstone-core-convey-http/src/identity.rs @@ -1,6 +1,48 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (c) 2026 sol pbc +use std::fmt; + +/// A validated certificate-derived identifier for a linked device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LinkedDeviceDid(String); + +impl LinkedDeviceDid { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A linked-device identifier was not a lowercase SHA-256 certificate digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidLinkedDeviceDid; + +impl fmt::Display for InvalidLinkedDeviceDid { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid linked-device identifier") + } +} + +impl std::error::Error for InvalidLinkedDeviceDid {} + +impl TryFrom<&str> for LinkedDeviceDid { + type Error = InvalidLinkedDeviceDid; + + fn try_from(value: &str) -> Result { + let Some(digest) = value.strip_prefix("sha256:") else { + return Err(InvalidLinkedDeviceDid); + }; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(InvalidLinkedDeviceDid); + } + Ok(Self(value.to_owned())) + } +} + /// The accept-time transport that carried a linked-device connection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Carrier { @@ -9,15 +51,21 @@ pub enum Carrier { } /// The only bases on which the HTTP substrate admits a connection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AccessBasis { Localhost, - LinkedDevice { carrier: Carrier }, + LinkedDevice { + carrier: Carrier, + did: LinkedDeviceDid, + }, } #[cfg(test)] mod tests { - use super::{AccessBasis, Carrier}; + use super::{AccessBasis, Carrier, LinkedDeviceDid}; + + const VALID_DID: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; #[test] fn access_basis_has_exactly_two_variants() { @@ -26,7 +74,7 @@ mod tests { fn assert_access_basis_is_exhaustive(basis: AccessBasis) { match basis { AccessBasis::Localhost => {} - AccessBasis::LinkedDevice { carrier: _ } => {} + AccessBasis::LinkedDevice { carrier: _, did: _ } => {} } } @@ -41,8 +89,28 @@ mod tests { assert_access_basis_is_exhaustive(AccessBasis::Localhost); assert_access_basis_is_exhaustive(AccessBasis::LinkedDevice { carrier: Carrier::Direct, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), }); assert_carrier_is_exhaustive(Carrier::Direct); assert_carrier_is_exhaustive(Carrier::ViaSpl); } + + #[test] + fn linked_device_did_validates_the_canonical_digest_shape() { + let did = LinkedDeviceDid::try_from(VALID_DID).unwrap(); + + assert_eq!(did.as_str(), VALID_DID); + let cases = [ + "", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + "sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ]; + + for value in cases { + assert!(LinkedDeviceDid::try_from(value).is_err(), "{value:?}"); + } + } } diff --git a/core/crates/solstone-core-convey-http/src/listener.rs b/core/crates/solstone-core-convey-http/src/listener.rs index 268c23d3d..f56250f24 100644 --- a/core/crates/solstone-core-convey-http/src/listener.rs +++ b/core/crates/solstone-core-convey-http/src/listener.rs @@ -47,7 +47,10 @@ mod tests { use super::bind_loopback; use crate::envelope::probe_router; - use crate::identity::{AccessBasis, Carrier}; + use crate::identity::{AccessBasis, Carrier, LinkedDeviceDid}; + + const VALID_DID: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; use crate::serve::{mux_builder, serve_connection, tcp_builder}; #[tokio::test] @@ -108,14 +111,16 @@ mod tests { let direct = duplex_probe(AccessBasis::LinkedDevice { carrier: Carrier::Direct, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), }) .await; - assert!(direct.contains("LinkedDevice { carrier: Direct }")); + assert!(direct.contains("LinkedDevice { carrier: Direct, did: LinkedDeviceDid")); let via_spl = duplex_probe(AccessBasis::LinkedDevice { carrier: Carrier::ViaSpl, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), }) .await; - assert!(via_spl.contains("LinkedDevice { carrier: ViaSpl }")); + assert!(via_spl.contains("LinkedDevice { carrier: ViaSpl, did: LinkedDeviceDid")); } } diff --git a/core/crates/solstone-core-convey-http/src/serve.rs b/core/crates/solstone-core-convey-http/src/serve.rs index df5a447b4..7e700b198 100644 --- a/core/crates/solstone-core-convey-http/src/serve.rs +++ b/core/crates/solstone-core-convey-http/src/serve.rs @@ -64,7 +64,10 @@ mod tests { use super::{REQUEST_BODY_LIMIT, mux_builder, serve_connection, tcp_builder}; use crate::envelope::probe_router; - use crate::identity::{AccessBasis, Carrier}; + use crate::identity::{AccessBasis, Carrier, LinkedDeviceDid}; + + const VALID_DID: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const REQUEST: &str = "GET /missing HTTP/1.1\r\nHost: localhost\r\n\r\n"; const OVER_LIMIT_HEADER_COUNT: usize = 34; @@ -137,6 +140,7 @@ mod tests { let response = response_until_closed( AccessBasis::LinkedDevice { carrier: Carrier::ViaSpl, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), }, mux_builder, "GET /Localhost?basis=Localhost HTTP/1.1\r\nHost: localhost\r\nX-Access-Basis: Localhost\r\nConnection: close\r\n\r\n".to_owned(), @@ -144,7 +148,7 @@ mod tests { ) .await; - assert!(response.contains("LinkedDevice { carrier: ViaSpl }")); + assert!(response.contains("LinkedDevice { carrier: ViaSpl, did: LinkedDeviceDid")); assert!(!response.contains("\"detail\":\"Localhost\"")); } diff --git a/core/crates/solstone-core-sol-link/Cargo.toml b/core/crates/solstone-core-sol-link/Cargo.toml index 05354612f..926ba85d6 100644 --- a/core/crates/solstone-core-sol-link/Cargo.toml +++ b/core/crates/solstone-core-sol-link/Cargo.toml @@ -35,11 +35,11 @@ tokio = { workspace = true, features = [ "io-util", "sync", ] } +tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } x509-parser = "0.16.0" [dev-dependencies] solstone-core-journal-io = { workspace = true, features = ["test-hooks"] } -tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } tower = { workspace = true } [lints] diff --git a/core/crates/solstone-core-sol-link/src/acceptor.rs b/core/crates/solstone-core-sol-link/src/acceptor.rs new file mode 100644 index 000000000..2eaa8976e --- /dev/null +++ b/core/crates/solstone-core-sol-link/src/acceptor.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +//! Device-door TLS acceptance and HTTP identity injection. + +use std::io; +use std::path::Path; +use std::time::Duration; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use solstone_core_convey_http::identity::{AccessBasis, Carrier, LinkedDeviceDid}; +use solstone_core_convey_http::serve::{serve_connection, tcp_builder}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +use crate::door::{ + DeviceDoorConfigError, build_device_door_server_config, spawn_authorization_refresh, +}; +use crate::http::router; +use crate::ledger::AuthorizationLedger; + +// Scope transcription of spl-rust v0.5.0 .proto-ref/session.md ยง7: authorized_clients.json is mtime-polled at 0.5s; revocation propagates within one second of the file edit. +pub const DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL: Duration = Duration::from_millis(500); + +/// Build a direct device-door TLS acceptor and start its authorization refresh task. +pub fn build_device_door_acceptor( + ledger: AuthorizationLedger, + server_cert_chain: Vec>, + server_key: PrivateKeyDer<'static>, + client_ca: CertificateDer<'static>, +) -> Result<(TlsAcceptor, JoinHandle<()>), DeviceDoorConfigError> { + let (authorization, task) = + spawn_authorization_refresh(ledger, DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL); + let config = match build_device_door_server_config( + server_cert_chain, + server_key, + client_ca, + authorization, + ) { + Ok(config) => config, + Err(error) => { + task.abort(); + return Err(error); + } + }; + Ok((TlsAcceptor::from(config), task)) +} + +/// Accept one direct device-door TLS connection and serve it with its own identity. +pub async fn serve_device_door_connection( + io: I, + acceptor: TlsAcceptor, + journal_root: &Path, +) -> io::Result<()> +where + I: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let stream = acceptor.accept(io).await?; + let (_, connection) = stream.get_ref(); + let certificates = connection + .peer_certificates() + .ok_or_else(|| io::Error::other("device-door connection has no peer certificate"))?; + let leaf = certificates.first().ok_or_else(|| { + io::Error::other("device-door connection has an empty peer certificate chain") + })?; + let value = format!("sha256:{}", spl_core::ca::sha256_hex(leaf.as_ref())); + let did = LinkedDeviceDid::try_from(value.as_str()) + .map_err(|_| io::Error::other("device-door peer certificate identifier is invalid"))?; + let builder = tcp_builder(); + + serve_connection( + stream, + router(journal_root), + AccessBasis::LinkedDevice { + carrier: Carrier::Direct, + did, + }, + &builder, + ) + .await + .map_err(io::Error::other) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL; + + #[test] + fn authorization_refresh_interval_is_positive_and_within_the_spec_bound() { + assert!(DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL > Duration::ZERO); + assert!(DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL <= Duration::from_millis(500)); + } +} diff --git a/core/crates/solstone-core-sol-link/src/door.rs b/core/crates/solstone-core-sol-link/src/door.rs index 62063ba13..c4678ab5d 100644 --- a/core/crates/solstone-core-sol-link/src/door.rs +++ b/core/crates/solstone-core-sol-link/src/door.rs @@ -265,11 +265,10 @@ mod tests { use x509_parser::pem::parse_x509_pem; use super::*; - use crate::ledger::{ClientEntry, ClientRole}; - - const FIXED_CERTIFICATE_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBqTCCAU+gAwIBAgIUKZ4GlQ+jaITZjYye0LTx71Oqx/kwCgYIKoZIzj0EAwIw\nKjEoMCYGA1UEAwwfc29sc3RvbmUgZml4ZWQgZG9vciBsb29rdXAgdGVzdDAeFw0y\nNjA4MDQyMjMyNDFaFw0zNjA4MDEyMjMyNDFaMCoxKDAmBgNVBAMMH3NvbHN0b25l\nIGZpeGVkIGRvb3IgbG9va3VwIHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAAQLWc/O7vh+eaolXyLl4UttktPMSL8L53AtLdpZnRxmQC0eA73pSSSHXyUricim\ncdS9bsJS5CKw4vsk+W8Oh8rGo1MwUTAdBgNVHQ4EFgQUrMksIzdtNTRky8Sk8RLe\nM0kYEQMwHwYDVR0jBBgwFoAUrMksIzdtNTRky8Sk8RLeM0kYEQMwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAVugzqjG4CX0sUgtnU3Xuo4gh9XK1P\nKJnZhZwLOZPNdgIhAMNXOb63RcTM0DDHjfwiz6hLCvQ10aPUkW8izj8nv36W\n-----END CERTIFICATE-----\n"; - const FIXED_CERTIFICATE_SHA256: &str = - "fbce31e7e99dbb0361851f0a27fe1909df27dc85ec268a9326c719dc8351d83e"; + use crate::{ + ledger::{ClientEntry, ClientRole}, + test_support::{FIXED_CERTIFICATE_PEM, FIXED_CERTIFICATE_SHA256}, + }; #[test] fn lookup_key_uses_fixed_leaf_der_digest() { diff --git a/core/crates/solstone-core-sol-link/src/http.rs b/core/crates/solstone-core-sol-link/src/http.rs index 5053cd1fd..b9dbc5200 100644 --- a/core/crates/solstone-core-sol-link/src/http.rs +++ b/core/crates/solstone-core-sol-link/src/http.rs @@ -236,7 +236,7 @@ async fn init_state( Extension(basis): Extension, State(state): State, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } let config = match materialize_config(&state.journal_root) { @@ -264,7 +264,7 @@ async fn init_state( } async fn init_local_capability(Extension(basis): Extension) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } Json(LocalCapabilityResponse { @@ -278,7 +278,7 @@ async fn init( Extension(basis): Extension, State(state): State, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } let config = match materialize_config(&state.journal_root) { @@ -299,7 +299,7 @@ async fn init_mark( Extension(basis): Extension, State(state): State, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } match establish::load_committed(&state.journal_root) { @@ -329,7 +329,7 @@ async fn init_mark_regenerate( Extension(basis): Extension, State(state): State, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } match establish::load_committed(&state.journal_root) { @@ -358,7 +358,7 @@ async fn init_mark_lock( Extension(basis): Extension, State(state): State, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } match establish::lock_in(&state.journal_root, None) { @@ -386,7 +386,7 @@ async fn init_finalize( State(state): State, body: Bytes, ) -> Response { - if !is_local(basis) { + if !is_local(&basis) { return init_local_only(); } match establish::load_committed(&state.journal_root) { @@ -497,7 +497,7 @@ async fn init_finalize( .into_response() } -fn is_local(basis: AccessBasis) -> bool { +fn is_local(basis: &AccessBasis) -> bool { require_access(basis) && matches!(basis, AccessBasis::Localhost) } diff --git a/core/crates/solstone-core-sol-link/src/http_tests.rs b/core/crates/solstone-core-sol-link/src/http_tests.rs index 27560a163..3e3b99565 100644 --- a/core/crates/solstone-core-sol-link/src/http_tests.rs +++ b/core/crates/solstone-core-sol-link/src/http_tests.rs @@ -8,7 +8,7 @@ use axum::Extension; use axum::body::{Body, to_bytes}; use axum::http::{Method, Request, StatusCode}; use serde_json::{Value, json}; -use solstone_core_convey_http::identity::{AccessBasis, Carrier}; +use solstone_core_convey_http::identity::{AccessBasis, Carrier, LinkedDeviceDid}; use tower::ServiceExt; use crate::establish; @@ -16,6 +16,8 @@ use crate::http::router; use crate::ledger::{AuthorizationLedger, ClientEntry, ClientRole}; use crate::mark::mark_from_jid; +const VALID_DID: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + #[tokio::test] async fn init_routes_reject_linked_devices_and_serve_localhost() { let temporary = TempDir::new(); @@ -34,6 +36,7 @@ async fn init_routes_reject_linked_devices_and_serve_localhost() { temporary.path(), AccessBasis::LinkedDevice { carrier: Carrier::Direct, + did: LinkedDeviceDid::try_from(VALID_DID).unwrap(), }, method.clone(), path, diff --git a/core/crates/solstone-core-sol-link/src/lib.rs b/core/crates/solstone-core-sol-link/src/lib.rs index 7e486f297..c3a68149e 100644 --- a/core/crates/solstone-core-sol-link/src/lib.rs +++ b/core/crates/solstone-core-sol-link/src/lib.rs @@ -12,6 +12,7 @@ use solstone_core_sol_client::seam::{ use spl_transport::credential::Credential; use spl_transport::{RelayControlEndpoint, RelayError, TransportError, tls}; +pub mod acceptor; pub mod ca; mod direct_seam; pub mod door; @@ -22,6 +23,10 @@ pub mod mark; mod pairing_entry; mod serve; +pub use acceptor::{ + DEVICE_DOOR_AUTHORIZATION_REFRESH_INTERVAL, build_device_door_acceptor, + serve_device_door_connection, +}; pub use door::{ DeviceDoorAuthorization, DeviceDoorConfigError, DeviceDoorVerifier, build_device_door_server_config, refresh_once, spawn_authorization_refresh, @@ -36,6 +41,10 @@ pub mod test_support { PKCS_ECDSA_P256_SHA256, }; + pub const FIXED_CERTIFICATE_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBqTCCAU+gAwIBAgIUKZ4GlQ+jaITZjYye0LTx71Oqx/kwCgYIKoZIzj0EAwIw\nKjEoMCYGA1UEAwwfc29sc3RvbmUgZml4ZWQgZG9vciBsb29rdXAgdGVzdDAeFw0y\nNjA4MDQyMjMyNDFaFw0zNjA4MDEyMjMyNDFaMCoxKDAmBgNVBAMMH3NvbHN0b25l\nIGZpeGVkIGRvb3IgbG9va3VwIHRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAAQLWc/O7vh+eaolXyLl4UttktPMSL8L53AtLdpZnRxmQC0eA73pSSSHXyUricim\ncdS9bsJS5CKw4vsk+W8Oh8rGo1MwUTAdBgNVHQ4EFgQUrMksIzdtNTRky8Sk8RLe\nM0kYEQMwHwYDVR0jBBgwFoAUrMksIzdtNTRky8Sk8RLeM0kYEQMwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiAVugzqjG4CX0sUgtnU3Xuo4gh9XK1P\nKJnZhZwLOZPNdgIhAMNXOb63RcTM0DDHjfwiz6hLCvQ10aPUkW8izj8nv36W\n-----END CERTIFICATE-----\n"; + pub const FIXED_CERTIFICATE_SHA256: &str = + "fbce31e7e99dbb0361851f0a27fe1909df27dc85ec268a9326c719dc8351d83e"; + pub struct TestCa { certificate: Certificate, key: KeyPair, diff --git a/core/crates/solstone-core-sol-link/tests/device_door_identity.rs b/core/crates/solstone-core-sol-link/tests/device_door_identity.rs new file mode 100644 index 000000000..42dcead61 --- /dev/null +++ b/core/crates/solstone-core-sol-link/tests/device_door_identity.rs @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rcgen::{ + CertificateParams, ExtendedKeyUsagePurpose, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; +use rustls::{ClientConfig, HandshakeKind, RootCertStore}; +use solstone_core_convey_http::identity::LinkedDeviceDid; +use solstone_core_sol_link::ledger::{AuthorizationLedger, ClientEntry, ClientRole}; +use solstone_core_sol_link::test_support::{ + FIXED_CERTIFICATE_PEM, FIXED_CERTIFICATE_SHA256, TestCa, +}; +use solstone_core_sol_link::{build_device_door_acceptor, serve_device_door_connection}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::task::JoinHandle; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use x509_parser::pem::parse_x509_pem; + +const REQUEST: &str = "GET /missing HTTP/1.1\r\nHost: door.test\r\nConnection: close\r\n\r\n"; +#[tokio::test] +async fn concurrent_authorized_devices_observe_their_own_distinct_dids() { + let temporary = TempDir::new(); + let fixture = Fixture::new(); + let first = issue_identity(&fixture.ca, ExtendedKeyUsagePurpose::ClientAuth); + let second = issue_identity(&fixture.ca, ExtendedKeyUsagePurpose::ClientAuth); + let (acceptor, refresh) = fixture.acceptor(temporary.path(), &[&first, &second]); + + let (first_response, second_response) = tokio::join!( + request( + acceptor.clone(), + temporary.path(), + fixture.client_config(&first), + REQUEST + ), + request( + acceptor, + temporary.path(), + fixture.client_config(&second), + REQUEST + ), + ); + + let first_did = did_for(&first.certificate); + let second_did = did_for(&second.certificate); + assert_ne!(first_did, second_did); + assert_response_identity(&first_response.0, &first_did); + assert_response_identity(&second_response.0, &second_did); + refresh.abort(); +} + +#[tokio::test] +async fn fixed_certificate_digest_is_the_cert_der_sha256_literal() { + let (_, pem) = parse_x509_pem(FIXED_CERTIFICATE_PEM.as_bytes()).unwrap(); + let certificate = CertificateDer::from(pem.contents); + let did = did_for(&certificate); + let expected = format!("sha256:{FIXED_CERTIFICATE_SHA256}"); + + assert_eq!(did, expected); + assert_eq!( + LinkedDeviceDid::try_from(did.as_str()).unwrap().as_str(), + expected + ); +} + +#[tokio::test] +async fn request_data_cannot_replace_the_tls_derived_did() { + let temporary = TempDir::new(); + let fixture = Fixture::new(); + let client = issue_identity(&fixture.ca, ExtendedKeyUsagePurpose::ClientAuth); + let (acceptor, refresh) = fixture.acceptor(temporary.path(), &[&client]); + let expected = did_for(&client.certificate); + let alternate = format!("sha256:{}", "b".repeat(64)); + let requests = [ + format!( + "GET /missing HTTP/1.1\r\nHost: door.test\r\nX-Device-Did: {alternate}\r\nConnection: close\r\n\r\n" + ), + format!("GET /{alternate} HTTP/1.1\r\nHost: door.test\r\nConnection: close\r\n\r\n"), + format!( + "GET /missing?did={alternate} HTTP/1.1\r\nHost: door.test\r\nConnection: close\r\n\r\n" + ), + format!( + "POST /missing HTTP/1.1\r\nHost: door.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{alternate}", + alternate.len() + ), + ]; + + for request_text in requests { + let response = request( + acceptor.clone(), + temporary.path(), + fixture.client_config(&client), + &request_text, + ) + .await; + assert_response_identity(&response.0, &expected); + assert!(!response.0.contains(&alternate)); + } + refresh.abort(); +} + +#[tokio::test] +async fn router_receives_one_complete_access_basis_identity() { + let temporary = TempDir::new(); + let fixture = Fixture::new(); + let client = issue_identity(&fixture.ca, ExtendedKeyUsagePurpose::ClientAuth); + let (acceptor, refresh) = fixture.acceptor(temporary.path(), &[&client]); + let expected = did_for(&client.certificate); + + let response = request( + acceptor, + temporary.path(), + fixture.client_config(&client), + REQUEST, + ) + .await; + + assert_response_identity(&response.0, &expected); + assert_eq!( + response.0.matches("LinkedDevice { carrier: Direct").count(), + 1 + ); + assert_eq!(response.0.matches(&expected).count(), 1); + refresh.abort(); +} + +#[tokio::test] +async fn resumed_tls_session_carries_the_original_connection_did() { + let temporary = TempDir::new(); + let fixture = Fixture::new(); + let client = issue_identity(&fixture.ca, ExtendedKeyUsagePurpose::ClientAuth); + let (acceptor, refresh) = fixture.acceptor(temporary.path(), &[&client]); + let client_config = fixture.client_config(&client); + + let first = request( + acceptor.clone(), + temporary.path(), + client_config.clone(), + REQUEST, + ) + .await; + let second = request(acceptor, temporary.path(), client_config, REQUEST).await; + + assert_ne!(first.1, HandshakeKind::Resumed); + assert_eq!(second.1, HandshakeKind::Resumed); + assert_eq!(response_did(&first.0), response_did(&second.0)); + refresh.abort(); +} + +struct Fixture { + ca: TestCa, + server: Identity, +} + +impl Fixture { + fn new() -> Self { + let ca = TestCa::new(); + let server = issue_identity(&ca, ExtendedKeyUsagePurpose::ServerAuth); + Self { ca, server } + } + + fn acceptor( + &self, + journal_root: &Path, + clients: &[&Identity], + ) -> (TlsAcceptor, JoinHandle<()>) { + let mut ledger = AuthorizationLedger::new(journal_root); + for (index, client) in clients.iter().enumerate() { + ledger + .add(ClientEntry::new( + did_for(&client.certificate), + format!("device-{index}"), + "2026-01-01T00:00:00Z", + "instance", + ClientRole::Roleless, + )) + .unwrap(); + } + build_device_door_acceptor( + ledger, + vec![self.server.certificate.clone()], + self.server.key.clone_key(), + CertificateDer::from(self.ca.certificate().der().to_vec()), + ) + .unwrap() + } + + fn client_config(&self, identity: &Identity) -> Arc { + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(self.ca.certificate().der().to_vec())) + .unwrap(); + Arc::new( + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_client_auth_cert(vec![identity.certificate.clone()], identity.key.clone_key()) + .unwrap(), + ) + } +} + +struct Identity { + certificate: CertificateDer<'static>, + key: PrivateKeyDer<'static>, +} + +fn issue_identity(ca: &TestCa, usage: ExtendedKeyUsagePurpose) -> Identity { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let mut params = CertificateParams::new(vec!["door.test".to_string()]).unwrap(); + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![usage]; + let certificate = params.signed_by(&key, ca.certificate(), ca.key()).unwrap(); + Identity { + certificate: CertificateDer::from(certificate.der().to_vec()), + key: PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())), + } +} + +async fn request( + acceptor: TlsAcceptor, + journal_root: &Path, + client_config: Arc, + request: &str, +) -> (String, HandshakeKind) { + let (server, client) = tokio::io::duplex(32 * 1024); + let journal_root = journal_root.to_path_buf(); + let task = tokio::spawn(async move { + serve_device_door_connection(server, acceptor, &journal_root) + .await + .unwrap(); + }); + let mut stream = TlsConnector::from(client_config) + .connect(ServerName::try_from("door.test").unwrap(), client) + .await + .unwrap(); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await.unwrap(); + let handshake_kind = stream.get_ref().1.handshake_kind().unwrap(); + task.await.unwrap(); + + (String::from_utf8(bytes).unwrap(), handshake_kind) +} + +fn did_for(certificate: &CertificateDer<'_>) -> String { + format!("sha256:{}", spl_core::ca::sha256_hex(certificate.as_ref())) +} + +fn response_did(response: &str) -> String { + let start = response.find("sha256:").unwrap(); + response[start..start + "sha256:".len() + 64].to_owned() +} + +fn assert_response_identity(response: &str, expected: &str) { + assert!(response.contains("LinkedDevice { carrier: Direct, did: LinkedDeviceDid")); + assert_eq!(response_did(response), expected); +} + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "sol-link-door-identity-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +}