Add generation-bound TLS control face

Create ephemeral trust anchors, publish an exact-profile Unix control socket, and persist generation-authorized mTLS session registration through the Domain Store.
This commit is contained in:
ms2k 2026-07-15 17:54:46 +09:00
commit c3a76ec8c4
10 changed files with 2351 additions and 17 deletions

218
Cargo.lock generated
View file

@ -32,6 +32,45 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -80,6 +119,15 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@ -350,6 +398,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -360,6 +414,26 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@ -1068,12 +1142,20 @@ dependencies = [
name = "lazaret-host-coordinator" name = "lazaret-host-coordinator"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"ed25519-dalek",
"getrandom 0.4.3",
"lazaret-core", "lazaret-core",
"lazaret-linux", "lazaret-linux",
"lazaret-state-anchor", "lazaret-state-anchor",
"lazaret-storage-catalog", "lazaret-storage-catalog",
"rcgen",
"redb", "redb",
"rustix",
"rustls",
"serde", "serde",
"sha2 0.11.0",
"time",
"x509-parser",
] ]
[[package]] [[package]]
@ -1187,6 +1269,12 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -1208,6 +1296,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -1278,6 +1401,15 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "olpc-cjson" name = "olpc-cjson"
version = "0.1.4" version = "0.1.4"
@ -1350,6 +1482,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -1466,6 +1604,19 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "rcgen"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
"aws-lc-rs",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]] [[package]]
name = "redb" name = "redb"
version = "4.1.0" version = "4.1.0"
@ -1574,6 +1725,15 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -2003,6 +2163,36 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "time"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@ -2553,6 +2743,24 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"aws-lc-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"rusticata-macros",
"thiserror",
"time",
]
[[package]] [[package]]
name = "xattr" name = "xattr"
version = "1.6.1" version = "1.6.1"
@ -2563,6 +2771,16 @@ dependencies = [
"rustix", "rustix",
] ]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"

View file

@ -23,17 +23,22 @@ license = "Apache-2.0"
anyhow = "=1.0.103" anyhow = "=1.0.103"
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] } ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
flate2 = "=1.1.9" flate2 = "=1.1.9"
getrandom = "=0.4.3"
oci-client = { version = "=0.17.0", default-features = false, features = ["rustls-tls"] } oci-client = { version = "=0.17.0", default-features = false, features = ["rustls-tls"] }
oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] } oci-spec = { version = "=0.10.0", default-features = false, features = ["runtime"] }
postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] } postcard = { version = "=1.1.3", default-features = false, features = ["use-std"] }
rcgen = { version = "=0.14.8", default-features = false, features = ["aws_lc_rs", "x509-parser"] }
redb = "=4.1.0" redb = "=4.1.0"
rustls = { version = "=0.23.42", default-features = false, features = ["aws_lc_rs", "std"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.150" serde_json = "=1.0.150"
rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread", "time"] } rustix = { version = "=1.1.4", features = ["event", "fs", "net", "pipe", "process", "thread", "time"] }
sha2 = "=0.11.0" sha2 = "=0.11.0"
tar = "=0.4.46" tar = "=0.4.46"
time = "=0.3.53"
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] } tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] } toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
x509-parser = "=0.18.1"
zstd = "=0.13.3" zstd = "=0.13.3"
[workspace.lints.rust] [workspace.lints.rust]

View file

@ -197,6 +197,44 @@ pub struct ProtocolPublicationIdentity {
pub coordinator_tls_certificate_sha256: CertificateDigest, pub coordinator_tls_certificate_sha256: CertificateDigest,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ClientEnrollmentRequest {
pub protocol_version: ProtocolVersion,
pub generation_identity: GenerationIdentity,
pub cli_id: ClientId,
pub certificate_signing_request_der: Vec<u8>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ClientEnrollmentResponse {
pub protocol_version: ProtocolVersion,
pub credential: TlsCredential,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SessionRegistrationRequest {
pub protocol_version: ProtocolVersion,
pub session_binding: SessionBinding,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SessionRegistrationResponse {
pub protocol_version: ProtocolVersion,
pub session_capability: SessionRequestCapability,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ControlRequest {
Enroll(ClientEnrollmentRequest),
Register(SessionRegistrationRequest),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ControlResponse {
Enrolled(ClientEnrollmentResponse),
Registered(SessionRegistrationResponse),
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ProtocolCredentialRole { pub enum ProtocolCredentialRole {
SessionCoordinator, SessionCoordinator,

View file

@ -1,20 +1,21 @@
use super::{ use super::{
AddressFamily, AddressFamilyDisposition, ArtifactLease, ArtifactState, AddressFamily, AddressFamilyDisposition, ArtifactLease, ArtifactState, ClientEnrollmentRequest,
ConfigurationFingerprintRecordV1, DefaultAction, DeniedDestinationObservation, ClientEnrollmentResponse, ConfigurationFingerprintRecordV1, ControlRequest, ControlResponse,
DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, DnsObservation, Domain, DefaultAction, DeniedDestinationObservation, DerivedNetworkPolicy, DeveloperDecision,
DomainStore, EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity, DeveloperDecisionKind, DnsObservation, Domain, DomainStore, EnvironmentReference,
GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, HostProcessIdentity, EscalationOutcome, Generation, GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity,
HostProcessOwnershipSlot, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult, HelperOwnership, HostProcessIdentity, HostProcessOwnershipSlot, Mailbox, MailboxCancellation,
MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity, NetworkObservation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk, MountIdentity,
NetworkOwnership, NetworkReachabilityProbe, NetworkReadinessAttestation, NamespaceIdentity, NetworkObservation, NetworkOwnership, NetworkReachabilityProbe,
NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, ParticipantOwnership, NetworkReadinessAttestation, NetworkReadinessEvidence, NetworkReadinessIdentity, Participant,
ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership, ParticipantOwnership, ParticipantState, PermittedDestinationObservation,
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction,
PrepareOperation, PrepareStageState, PreparedArtifact, PreparedLowerOwnership, PolicyDelivery, PrefixRule, PrepareOperation, PrepareStageState, PreparedArtifact,
ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, ProtocolCredential, PreparedLowerOwnership, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion,
ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity,
ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle, PublicationOwnership, ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream,
SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome, SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit,
SessionExitOutcome, SessionRegistrationRequest, SessionRegistrationResponse,
SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage,
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal, StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
@ -76,6 +77,12 @@ schema_types!(
"TrustManifest" => TrustManifest, "TrustManifest" => TrustManifest,
"TlsCredential" => TlsCredential, "TlsCredential" => TlsCredential,
"ProtocolPublicationIdentity" => ProtocolPublicationIdentity, "ProtocolPublicationIdentity" => ProtocolPublicationIdentity,
"ClientEnrollmentRequest" => ClientEnrollmentRequest,
"ClientEnrollmentResponse" => ClientEnrollmentResponse,
"SessionRegistrationRequest" => SessionRegistrationRequest,
"SessionRegistrationResponse" => SessionRegistrationResponse,
"ControlRequest" => ControlRequest,
"ControlResponse" => ControlResponse,
"SandboxCredentialBundle" => SandboxCredentialBundle, "SandboxCredentialBundle" => SandboxCredentialBundle,
"TlsRole" => TlsRole, "TlsRole" => TlsRole,
"ProtocolCredential" => ProtocolCredential, "ProtocolCredential" => ProtocolCredential,

View file

@ -190,6 +190,30 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}, },
&mut seen, &mut seen,
); );
let enrollment_request = ClientEnrollmentRequest {
protocol_version: ProtocolVersion::V1,
generation_identity: generation_identity(),
cli_id: ClientId::new(opaque(13)),
certificate_signing_request_der: vec![56],
};
let enrollment_response = ClientEnrollmentResponse {
protocol_version: ProtocolVersion::V1,
credential: tls_credential.clone(),
};
let registration_request = SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
};
let registration_response = SessionRegistrationResponse {
protocol_version: ProtocolVersion::V1,
session_capability: session_capability(),
};
verify_shape(enrollment_request.clone(), &mut seen);
verify_shape(enrollment_response.clone(), &mut seen);
verify_shape(registration_request.clone(), &mut seen);
verify_shape(registration_response.clone(), &mut seen);
verify_shape(ControlRequest::Enroll(enrollment_request), &mut seen);
verify_shape(ControlResponse::Enrolled(enrollment_response), &mut seen);
verify_shape( verify_shape(
SandboxCredentialBundle::new( SandboxCredentialBundle::new(
generation_identity(), generation_identity(),
@ -841,3 +865,30 @@ fn unknown_enums_and_protocol_versions_are_rejected() {
Err(CodecError::MalformedPayload(_)) Err(CodecError::MalformedPayload(_))
)); ));
} }
#[test]
fn control_wire_unknown_protocol_versions_are_rejected() {
assert_unknown_protocol_version(&ClientEnrollmentRequest {
protocol_version: ProtocolVersion::V1,
generation_identity: generation_identity(),
cli_id: ClientId::new(opaque(4)),
certificate_signing_request_der: vec![5],
});
assert_unknown_protocol_version(&ClientEnrollmentResponse {
protocol_version: ProtocolVersion::V1,
credential: TlsCredential::client_cli(
generation_identity(),
ClientId::new(opaque(4)),
vec![5],
CertificateDigest::new(opaque(6)),
),
});
assert_unknown_protocol_version(&SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: session_binding(),
});
assert_unknown_protocol_version(&SessionRegistrationResponse {
protocol_version: ProtocolVersion::V1,
session_capability: session_capability(),
});
}

View file

@ -10,14 +10,22 @@ name = "lazaret-host-coordinator"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
ed25519-dalek.workspace = true
getrandom.workspace = true
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" } lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" } lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" } lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" } lazaret-storage-catalog = { version = "=0.0.0", path = "../lazaret-storage-catalog" }
rcgen.workspace = true
redb.workspace = true redb.workspace = true
rustix.workspace = true
rustls.workspace = true
serde.workspace = true
sha2.workspace = true
time.workspace = true
[dev-dependencies] [dev-dependencies]
serde.workspace = true x509-parser.workspace = true
[lints] [lints]
workspace = true workspace = true

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
#![doc = "Host-side coordination authority for one Lazaret Domain."] #![doc = "Host-side coordination authority for one Lazaret Domain."]
pub mod control;
pub mod domain_store; pub mod domain_store;
pub mod state_anchor; pub mod state_anchor;

View file

@ -0,0 +1,719 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread::{self, JoinHandle};
use ed25519_dalek::SigningKey;
use lazaret_core::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
NetworkSidecarId, SandboxId, SessionId,
};
use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, Domain, DurableDecision, Generation,
GenerationIdentity, ProtocolVersion, SessionBinding, SessionPhase, SessionRegistrationRequest,
SessionRegistrationResponse, TlsCredential,
};
use lazaret_host_coordinator::control::{
CONTROL_ALPN, ControlError, PendingClientEnrollment, PublishedControlFace, TrustAnchor,
};
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
use lazaret_linux::filesystem::try_exclusive_flock;
use rustix::process::getuid;
use x509_parser::extensions::GeneralName;
use x509_parser::parse_x509_certificate;
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new(label: &str) -> Self {
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"lazaret-control-{label}-{}-{sequence}",
std::process::id()
));
std::fs::create_dir(&path).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
fn generation_identity(generation: u64) -> GenerationIdentity {
GenerationIdentity {
agent_profile: "trusted".into(),
domain_id: DomainId::new(bytes(1)),
generation_id: GenerationId::new(generation),
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
coordinator_id: CoordinatorId::new(bytes(u8::try_from(generation + 10).unwrap())),
sandbox_id: SandboxId::new(bytes(u8::try_from(generation + 20).unwrap())),
}
}
fn domain() -> Domain {
Domain {
domain_id: DomainId::new(bytes(1)),
profile_name: "trusted".into(),
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
}
}
fn generation(identity: &GenerationIdentity, decision: DurableDecision) -> Generation {
Generation {
domain_id: identity.domain_id,
generation_id: identity.generation_id,
configuration_fingerprint: identity.configuration_fingerprint,
coordinator_id: identity.coordinator_id,
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
sandbox_id: identity.sandbox_id,
durable_decision: decision,
}
}
fn open_store(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> Arc<HostCoordinatorDomainStore> {
let lock_path = directory.path("domain.lock");
let lock = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(lock_path)
.unwrap();
let lock = try_exclusive_flock(lock).unwrap();
let store = Arc::new(
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap(),
);
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::None,
))])
.unwrap();
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::Preparing,
))])
.unwrap();
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity,
DurableDecision::Published,
))])
.unwrap();
store
}
fn open_preparing_store(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> Arc<HostCoordinatorDomainStore> {
let lock = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(directory.path("domain.lock"))
.unwrap();
let store = Arc::new(
HostCoordinatorDomainStore::open(
try_exclusive_flock(lock).unwrap(),
&directory.path("domain.redb"),
&domain(),
)
.unwrap(),
);
for decision in [DurableDecision::None, DurableDecision::Preparing] {
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
identity, decision,
))])
.unwrap();
}
store
}
fn publish(
directory: &TestDirectory,
identity: &GenerationIdentity,
) -> (Arc<PublishedControlFace>, Arc<HostCoordinatorDomainStore>) {
let store = open_store(directory, identity);
let face = PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
Arc::clone(&store),
)
.unwrap();
(Arc::new(face), store)
}
fn server(face: &Arc<PublishedControlFace>) -> JoinHandle<Result<(), ControlError>> {
let face = Arc::clone(face);
thread::spawn(move || face.serve_next())
}
struct EnrolledClient {
pending: PendingClientEnrollment,
credential: TlsCredential,
server_certificate: Vec<u8>,
}
fn enroll(face: &Arc<PublishedControlFace>, cli_id: ClientId) -> EnrolledClient {
let pending = PendingClientEnrollment::create(
face.publication_identity().generation_identity.clone(),
cli_id,
)
.unwrap();
let serving = server(face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(pending.request().clone()),
)
.unwrap();
let response: ControlResponse = PublishedControlFace::receive(&mut stream).unwrap();
assert_eq!(
stream.conn.protocol_version(),
Some(rustls::ProtocolVersion::TLSv1_3)
);
assert_eq!(stream.conn.alpn_protocol(), Some(CONTROL_ALPN));
let server_certificate = stream.conn.peer_certificates().unwrap()[0].to_vec();
drop(stream);
serving.join().unwrap().unwrap();
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) = response else {
panic!("unexpected enrollment response")
};
EnrolledClient {
pending,
credential,
server_certificate,
}
}
fn assert_certificate_profiles(face: &PublishedControlFace, client: &EnrolledClient) {
let (_, ca) =
parse_x509_certificate(&face.publication_identity().tls_ca_certificate_der).unwrap();
assert!(ca.is_ca());
assert!(ca.key_usage().unwrap().unwrap().value.key_cert_sign());
assert_eq!(
ca.signature_algorithm.algorithm.to_id_string(),
"1.3.101.112"
);
let (_, server_certificate) = parse_x509_certificate(&client.server_certificate).unwrap();
let server_usage = server_certificate.extended_key_usage().unwrap().unwrap();
assert!(server_usage.value.server_auth);
assert!(!server_usage.value.client_auth);
let server_names = server_certificate
.subject_alternative_name()
.unwrap()
.unwrap();
assert!(
server_names
.value
.general_names
.iter()
.any(|name| { matches!(name, GeneralName::DNSName("coordinator.lazaret.invalid")) })
);
assert!(
server_certificate.validity().not_after.timestamp()
- server_certificate.validity().not_before.timestamp()
<= 3_660
);
let (_, client_certificate) =
parse_x509_certificate(&client.credential.certificate_der).unwrap();
let client_usage = client_certificate.extended_key_usage().unwrap().unwrap();
assert!(client_usage.value.client_auth);
assert!(!client_usage.value.server_auth);
assert_eq!(
client_certificate
.signature_algorithm
.algorithm
.to_id_string(),
"1.3.101.112"
);
assert!(
client_certificate.validity().not_after.timestamp()
- client_certificate.validity().not_before.timestamp()
<= 3_660
);
}
fn registration_binding(
identity: &GenerationIdentity,
cli_id: ClientId,
session: u8,
anchor: u8,
) -> SessionBinding {
SessionBinding {
generation_identity: identity.clone(),
cli_id,
session_id: SessionId::new(bytes(session)),
session_anchor_public_key: SigningKey::from_bytes(&bytes(anchor))
.verifying_key()
.to_bytes()
.to_vec(),
}
}
fn open_registration(
face: &Arc<PublishedControlFace>,
client: &EnrolledClient,
binding: SessionBinding,
) -> (
rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
JoinHandle<Result<(), ControlError>>,
SessionRegistrationResponse,
) {
let serving = server(face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding,
}),
)
.unwrap();
let response: ControlResponse = PublishedControlFace::receive(&mut stream).unwrap();
let ControlResponse::Registered(response) = response else {
panic!("unexpected registration response")
};
(stream, serving, response)
}
fn refused_registration(
face: &Arc<PublishedControlFace>,
client: &EnrolledClient,
binding: SessionBinding,
) -> ControlError {
let serving = server(face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding,
}),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
serving.join().unwrap().unwrap_err()
}
#[test]
fn real_uds_tls_enrollment_and_registration_preserve_exact_profiles_and_session_state() {
let directory = TestDirectory::new("complete");
let identity = generation_identity(7);
let (face, store) = publish(&directory, &identity);
let socket_metadata = std::fs::symlink_metadata(face.socket_path()).unwrap();
let identity_metadata = std::fs::symlink_metadata(face.identity_path()).unwrap();
assert_eq!(socket_metadata.uid(), getuid().as_raw());
assert_eq!(socket_metadata.mode() & 0o7777, 0o600);
assert_eq!(identity_metadata.uid(), getuid().as_raw());
assert_eq!(identity_metadata.mode() & 0o7777, 0o600);
assert_eq!(face.socket_path().file_name().unwrap(), "control.sock");
assert_eq!(
face.identity_path().file_name().unwrap(),
"protocol-identity.postcard"
);
let client = enroll(&face, ClientId::new(bytes(30)));
assert_certificate_profiles(&face, &client);
let mut wrong_leaf_pin = face.publication_identity().clone();
wrong_leaf_pin.coordinator_tls_certificate_sha256 =
lazaret_core::identity::CertificateDigest::new(bytes(99));
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(PublishedControlFace::connect(face.socket_path(), config, &wrong_leaf_pin).is_err());
assert!(serving.join().unwrap().is_err());
let serving = server(&face);
let config = PublishedControlFace::client_config(
face.publication_identity(),
Some((&client.credential, client.pending.private_key())),
)
.unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(client.pending.request().clone()),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidRequest
));
let binding = registration_binding(&identity, ClientId::new(bytes(30)), 31, 32);
let (stream, serving, response) = open_registration(&face, &client, binding.clone());
assert_eq!(response.session_capability.session_binding, binding);
assert_eq!(face.registry().connected_sessions().unwrap().len(), 1);
assert_eq!(
store.snapshot().unwrap().sessions[0].session_phase,
SessionPhase::Attached
);
drop(stream);
serving.join().unwrap().unwrap();
assert!(face.registry().connected_sessions().unwrap().is_empty());
assert_eq!(
store.snapshot().unwrap().sessions[0].session_phase,
SessionPhase::Closed
);
}
#[test]
fn duplicate_session_cli_and_anchor_identities_are_refused_while_registered() {
let directory = TestDirectory::new("duplicates");
let identity = generation_identity(7);
let (face, _) = publish(&directory, &identity);
let first = enroll(&face, ClientId::new(bytes(40)));
let second = enroll(&face, ClientId::new(bytes(41)));
let mismatched_cli = registration_binding(&identity, ClientId::new(bytes(41)), 39, 40);
assert!(matches!(
refused_registration(&face, &first, mismatched_cli),
ControlError::InvalidCertificateProfile
));
let first_binding = registration_binding(&identity, ClientId::new(bytes(40)), 42, 43);
let (first_stream, first_server, _) = open_registration(&face, &first, first_binding.clone());
let duplicate_session = registration_binding(&identity, ClientId::new(bytes(41)), 42, 44);
assert!(matches!(
refused_registration(&face, &second, duplicate_session),
ControlError::DuplicateIdentity
));
let duplicate_cli = registration_binding(&identity, ClientId::new(bytes(40)), 45, 46);
assert!(matches!(
refused_registration(&face, &first, duplicate_cli),
ControlError::DuplicateIdentity
));
let duplicate_anchor = SessionBinding {
generation_identity: identity,
cli_id: ClientId::new(bytes(41)),
session_id: SessionId::new(bytes(47)),
session_anchor_public_key: first_binding.session_anchor_public_key,
};
assert!(matches!(
refused_registration(&face, &second, duplicate_anchor),
ControlError::DuplicateIdentity
));
drop(first_stream);
first_server.join().unwrap().unwrap();
}
#[test]
fn anonymous_connections_are_enrollment_only_bounded_and_exact_alpn_is_required() {
let directory = TestDirectory::new("anonymous");
let identity = generation_identity(7);
let (face, _) = publish(&directory, &identity);
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: registration_binding(&identity, ClientId::new(bytes(48)), 49, 50),
}),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidRequest
));
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
stream.write_all(&8_193_u32.to_be_bytes()).unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::OversizeRequest {
length: 8_193,
maximum: 8_192
}
));
let mut tampered = PendingClientEnrollment::create(identity.clone(), ClientId::new(bytes(51)))
.unwrap()
.request()
.clone();
*tampered.certificate_signing_request_der.last_mut().unwrap() ^= 1;
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(&mut stream, &ControlRequest::Enroll(tampered)).unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::Certificate(_)
));
let serving = server(&face);
let mut config =
PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
config.alpn_protocols = vec![b"not-lazaret".to_vec()];
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(serving.join().unwrap().is_err());
}
#[test]
fn stale_generation_and_publication_identity_are_refused() {
let directory = TestDirectory::new("stale");
let identity = generation_identity(7);
let (face, store) = publish(&directory, &identity);
let stale =
PendingClientEnrollment::create(generation_identity(6), ClientId::new(bytes(60))).unwrap();
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
let mut stream =
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.unwrap();
PublishedControlFace::send(
&mut stream,
&ControlRequest::Enroll(stale.request().clone()),
)
.unwrap();
assert!(PublishedControlFace::receive::<ControlResponse>(&mut stream).is_err());
drop(stream);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::StaleGeneration
));
let later = generation_identity(8);
for decision in [
DurableDecision::None,
DurableDecision::Preparing,
DurableDecision::Published,
] {
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
&later, decision,
))])
.unwrap();
}
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::StaleGeneration
));
}
#[test]
fn host_coordinator_key_loss_never_reuses_old_tls_authority() {
let directory = TestDirectory::new("key-loss");
let identity = generation_identity(7);
let store = open_store(&directory, &identity);
let old_face = Arc::new(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
Arc::clone(&store),
)
.unwrap(),
);
let old_client = enroll(&old_face, ClientId::new(bytes(70)));
let old_publication = old_face.publication_identity().clone();
drop(old_face);
let new_face = Arc::new(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity).unwrap(),
store,
)
.unwrap(),
);
assert_ne!(
new_face.publication_identity().tls_ca_certificate_der,
old_publication.tls_ca_certificate_der
);
let serving = server(&new_face);
let old_config = PublishedControlFace::client_config(
&old_publication,
Some((&old_client.credential, old_client.pending.private_key())),
)
.unwrap();
assert!(
PublishedControlFace::connect(new_face.socket_path(), old_config, &old_publication,)
.is_err()
);
assert!(serving.join().unwrap().is_err());
}
#[test]
fn publication_refuses_wrong_root_path_mode_and_stale_identity_file() {
let mode_directory = TestDirectory::new("mode");
std::fs::set_permissions(&mode_directory.0, std::fs::Permissions::from_mode(0o755)).unwrap();
let identity = generation_identity(7);
let store = open_store(&mode_directory, &identity);
assert!(matches!(
PublishedControlFace::publish(
&mode_directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
store,
),
Err(ControlError::InvalidPath(_))
));
let symlink_parent = TestDirectory::new("symlink-parent");
let target = symlink_parent.path("target");
std::fs::create_dir(&target).unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o700)).unwrap();
let link = symlink_parent.path("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let store_directory = TestDirectory::new("symlink-store");
let store = open_store(&store_directory, &identity);
assert!(matches!(
PublishedControlFace::publish(
&link,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
store,
),
Err(ControlError::InvalidPath(_))
));
let stale_directory = TestDirectory::new("stale-file");
let (face, _) = publish(&stale_directory, &identity);
let mut identity_file = OpenOptions::new()
.write(true)
.truncate(true)
.open(face.identity_path())
.unwrap();
identity_file.write_all(b"stale publication").unwrap();
identity_file.sync_all().unwrap();
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity(),)
.is_err()
);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::InvalidPublication(_)
));
}
#[test]
fn publication_refuses_preexisting_fixed_control_path() {
let directory = TestDirectory::new("existing");
let identity = generation_identity(7);
let store = open_store(&directory, &identity);
File::create(directory.path("control.sock")).unwrap();
assert!(matches!(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity).unwrap(),
store,
),
Err(ControlError::InvalidPublication(_))
));
}
#[test]
fn prepared_control_face_admits_only_after_generation_publication() {
let directory = TestDirectory::new("publication-gate");
let identity = generation_identity(7);
let store = open_preparing_store(&directory, &identity);
let face = Arc::new(
PublishedControlFace::publish(
&directory.0,
getuid().as_raw(),
TrustAnchor::create(identity.clone()).unwrap(),
Arc::clone(&store),
)
.unwrap(),
);
let serving = server(&face);
let config = PublishedControlFace::client_config(face.publication_identity(), None).unwrap();
assert!(
PublishedControlFace::connect(face.socket_path(), config, face.publication_identity())
.is_err()
);
assert!(matches!(
serving.join().unwrap().unwrap_err(),
ControlError::StaleGeneration
));
store
.apply(&[DomainStoreMutation::PutGeneration(generation(
&identity,
DurableDecision::Published,
))])
.unwrap();
let client = enroll(&face, ClientId::new(bytes(90)));
assert_eq!(client.credential.generation_identity, identity);
}

View file

@ -671,6 +671,46 @@ classDiagram
coordinator_tls_certificate_sha256 coordinator_tls_certificate_sha256
} }
class ClientEnrollmentRequest {
<<serialized>>
protocol_version
generation_identity
cli_id
certificate_signing_request_der
}
class ClientEnrollmentResponse {
<<serialized>>
protocol_version
credential
}
class SessionRegistrationRequest {
<<serialized>>
protocol_version
session_binding
}
class SessionRegistrationResponse {
<<serialized>>
protocol_version
session_capability
}
class ControlRequest {
<<serialized>>
<<union>>
Enroll
Register
}
class ControlResponse {
<<serialized>>
<<union>>
Enrolled
Registered
}
class SandboxCredentialBundle { class SandboxCredentialBundle {
<<serialized>> <<serialized>>
protocol_version protocol_version
@ -730,6 +770,14 @@ classDiagram
ProtocolPublicationIdentity --> GenerationIdentity ProtocolPublicationIdentity --> GenerationIdentity
ProtocolPublicationIdentity --> TrustManifest ProtocolPublicationIdentity --> TrustManifest
ProtocolPublicationIdentity --> TlsCredential : coordinator TLS leaf ProtocolPublicationIdentity --> TlsCredential : coordinator TLS leaf
ClientEnrollmentRequest --> GenerationIdentity
ClientEnrollmentResponse --> TlsCredential
SessionRegistrationRequest --> SessionBinding
SessionRegistrationResponse --> SessionRequestCapability
ControlRequest --> ClientEnrollmentRequest : Enroll
ControlRequest --> SessionRegistrationRequest : Register
ControlResponse --> ClientEnrollmentResponse : Enrolled
ControlResponse --> SessionRegistrationResponse : Registered
SandboxCredentialBundle --> GenerationIdentity SandboxCredentialBundle --> GenerationIdentity
SandboxCredentialBundle --> TrustManifest SandboxCredentialBundle --> TrustManifest
SandboxCredentialBundle --> ProtocolCredential : coordinator verifier SandboxCredentialBundle --> ProtocolCredential : coordinator verifier