Implement generation-bound protocol cryptography

This commit is contained in:
ms2k 2026-07-15 15:55:08 +09:00
commit af8669c165
11 changed files with 1882 additions and 9 deletions

183
Cargo.lock generated
View file

@ -74,12 +74,27 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.1" version = "0.12.1"
@ -132,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
@ -178,6 +193,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@ -221,6 +242,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.0" version = "0.3.0"
@ -239,6 +269,16 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.2.2" version = "0.2.2"
@ -248,6 +288,33 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -283,6 +350,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@ -314,15 +391,25 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.11.3" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.12.1",
"const-oid", "const-oid 0.10.2",
"crypto-common", "crypto-common 0.2.2",
] ]
[[package]] [[package]]
@ -342,6 +429,30 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "embedded-io" name = "embedded-io"
version = "0.4.0" version = "0.4.0"
@ -376,6 +487,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@ -483,6 +600,16 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -921,10 +1048,11 @@ dependencies = [
name = "lazaret-core" name = "lazaret-core"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"ed25519-dalek",
"postcard", "postcard",
"rustix", "rustix",
"serde", "serde",
"sha2", "sha2 0.11.0",
"static_assertions", "static_assertions",
"toml", "toml",
] ]
@ -975,7 +1103,7 @@ dependencies = [
"rustix", "rustix",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.11.0",
"tar", "tar",
"tempfile", "tempfile",
"tokio", "tokio",
@ -1098,7 +1226,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.11.0",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
@ -1174,6 +1302,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@ -1636,6 +1774,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.11.0" version = "0.11.0"
@ -1643,8 +1792,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.3.0",
"digest", "digest 0.11.3",
] ]
[[package]] [[package]]
@ -1716,6 +1865,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -2093,6 +2252,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View file

@ -20,6 +20,7 @@ license = "Apache-2.0"
[workspace.dependencies] [workspace.dependencies]
anyhow = "=1.0.103" anyhow = "=1.0.103"
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
flate2 = "=1.1.9" flate2 = "=1.1.9"
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"] }

View file

@ -6,6 +6,7 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
ed25519-dalek.workspace = true
postcard.workspace = true postcard.workspace = true
rustix.workspace = true rustix.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -3,6 +3,7 @@
pub mod codec; pub mod codec;
pub mod configuration; pub mod configuration;
pub mod identity; pub mod identity;
pub mod protocol;
pub mod records; pub mod records;
pub const PROTOCOL_NAME: &str = "lazaret"; pub const PROTOCOL_NAME: &str = "lazaret";

View file

@ -0,0 +1,9 @@
//! Bounded cryptographic protocol records and generation-scoped validation.
mod session;
mod signing;
mod window;
pub use session::*;
pub use signing::*;
pub use window::*;

View file

@ -0,0 +1,663 @@
use std::collections::BTreeSet;
use std::fmt;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::Serialize;
use crate::identity::{
CapabilityId, CertificateDigest, ProtocolCredentialId, RequestDigest, RequestId,
TrustManifestDigest,
};
use crate::records::{
DeveloperDecision, GenerationIdentity, MailboxCancellation, MailboxRequest, MailboxResult,
MailboxResultAck, MailboxResultChunk, ProtocolCredential, ProtocolCredentialRole,
ProtocolSubjectId, ProtocolVersion, SessionBinding, SessionRequestCapability, TlsCredential,
TlsRole, TlsSubjectId, TrustManifest,
};
use super::signing::{
ProtocolCodecError, ProtocolRecordKind, decode_signed, encode_payload, sha256, sign_payload,
trust_manifest_digest, verify_payload,
};
use super::window::MAX_UNACKNOWLEDGED_CHUNKS;
use super::{MAX_RESULT_CHUNK_BYTES, request_digest};
#[derive(Debug)]
pub enum ProtocolValidationError {
Codec(ProtocolCodecError),
InvalidPublicKey,
ManifestDigestMismatch,
CertificateDigestMismatch,
CredentialDigestMismatch,
CapabilityDigestMismatch,
GenerationMismatch,
RoleMismatch,
SubjectMismatch,
SessionBindingMismatch,
CredentialMismatch,
CapabilityMismatch,
RequestMismatch,
RequestReplay,
ChunkTooLarge { length: usize },
}
impl fmt::Display for ProtocolValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Codec(error) => error.fmt(formatter),
Self::InvalidPublicKey => formatter.write_str("protocol public key is invalid"),
Self::ManifestDigestMismatch => formatter.write_str("Trust Manifest digest mismatch"),
Self::CertificateDigestMismatch => {
formatter.write_str("TLS certificate digest mismatch")
}
Self::CredentialDigestMismatch => {
formatter.write_str("protocol credential digest mismatch")
}
Self::CapabilityDigestMismatch => {
formatter.write_str("Session Request Capability digest mismatch")
}
Self::GenerationMismatch => {
formatter.write_str("protocol generation identity mismatch")
}
Self::RoleMismatch => formatter.write_str("protocol credential role mismatch"),
Self::SubjectMismatch => formatter.write_str("protocol subject identity mismatch"),
Self::SessionBindingMismatch => formatter.write_str("session binding mismatch"),
Self::CredentialMismatch => formatter.write_str("protocol credential mismatch"),
Self::CapabilityMismatch => formatter.write_str("Session Request Capability mismatch"),
Self::RequestMismatch => formatter.write_str("escalation request correlation mismatch"),
Self::RequestReplay => {
formatter.write_str("escalation request ID was already admitted")
}
Self::ChunkTooLarge { length } => write!(
formatter,
"result chunk length {length} exceeds {MAX_RESULT_CHUNK_BYTES}"
),
}
}
}
impl std::error::Error for ProtocolValidationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Codec(error) => Some(error),
_ => None,
}
}
}
impl From<ProtocolCodecError> for ProtocolValidationError {
fn from(error: ProtocolCodecError) -> Self {
Self::Codec(error)
}
}
#[derive(Serialize)]
struct TrustManifestClaims<'a> {
protocol_version: ProtocolVersion,
generation_identity: &'a GenerationIdentity,
tls_ca_sha256: CertificateDigest,
protocol_issuer_public_key: &'a [u8],
}
#[derive(Serialize)]
struct ProtocolCredentialClaims<'a> {
protocol_version: ProtocolVersion,
generation_identity: &'a GenerationIdentity,
role: ProtocolCredentialRole,
subject_id: ProtocolSubjectId,
public_key: &'a [u8],
}
#[derive(Serialize)]
struct SessionCapabilityClaims<'a> {
protocol_version: ProtocolVersion,
session_binding: &'a SessionBinding,
coordinator_credential_id: ProtocolCredentialId,
sandbox_credential_id: ProtocolCredentialId,
}
/// Creates a self-verifying manifest whose digest must still arrive through a trusted path.
///
/// # Errors
///
/// Returns an error when a claim cannot be encoded.
pub fn issue_trust_manifest(
generation_identity: GenerationIdentity,
tls_ca_sha256: CertificateDigest,
protocol_issuer: &SigningKey,
) -> Result<(TrustManifest, TrustManifestDigest), ProtocolValidationError> {
let public_key = protocol_issuer.verifying_key().to_bytes().to_vec();
let claims = TrustManifestClaims {
protocol_version: ProtocolVersion::V1,
generation_identity: &generation_identity,
tls_ca_sha256,
protocol_issuer_public_key: &public_key,
};
let claims = encode_payload(&claims)?;
let signature = sign_payload(ProtocolRecordKind::TrustManifest, &claims, protocol_issuer)?;
let manifest = TrustManifest {
protocol_version: ProtocolVersion::V1,
generation_identity,
tls_ca_sha256,
protocol_issuer_public_key: public_key,
issuer_signature: signature.to_vec(),
};
let encoded = encode_payload(&manifest)?;
let digest = trust_manifest_digest(&encoded);
Ok((manifest, digest))
}
/// Validates a pinned Trust Manifest and returns its protocol issuer key.
///
/// # Errors
///
/// Returns an error for a stale tuple, digest mismatch, malformed key, or invalid signature.
pub fn verify_trust_manifest(
manifest: &TrustManifest,
trusted_digest: TrustManifestDigest,
expected_generation: &GenerationIdentity,
expected_tls_ca_sha256: CertificateDigest,
) -> Result<VerifyingKey, ProtocolValidationError> {
if &manifest.generation_identity != expected_generation {
return Err(ProtocolValidationError::GenerationMismatch);
}
if manifest.tls_ca_sha256 != expected_tls_ca_sha256 {
return Err(ProtocolValidationError::CertificateDigestMismatch);
}
let encoded = encode_payload(manifest)?;
if trust_manifest_digest(&encoded) != trusted_digest {
return Err(ProtocolValidationError::ManifestDigestMismatch);
}
let key_bytes: [u8; 32] = manifest
.protocol_issuer_public_key
.as_slice()
.try_into()
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
let key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
let claims = TrustManifestClaims {
protocol_version: manifest.protocol_version,
generation_identity: &manifest.generation_identity,
tls_ca_sha256: manifest.tls_ca_sha256,
protocol_issuer_public_key: &manifest.protocol_issuer_public_key,
};
verify_payload(
ProtocolRecordKind::TrustManifest,
&encode_payload(&claims)?,
&manifest.issuer_signature,
&key,
)?;
Ok(key)
}
/// Issues one generation- and role-bound protocol credential.
///
/// # Errors
///
/// Returns an error when a claim cannot be encoded.
pub fn issue_protocol_credential(
generation_identity: GenerationIdentity,
role: ProtocolCredentialRole,
subject_id: ProtocolSubjectId,
subject_key: &VerifyingKey,
protocol_issuer: &SigningKey,
) -> Result<ProtocolCredential, ProtocolValidationError> {
let public_key = subject_key.to_bytes().to_vec();
let claims = ProtocolCredentialClaims {
protocol_version: ProtocolVersion::V1,
generation_identity: &generation_identity,
role,
subject_id,
public_key: &public_key,
};
let claims = encode_payload(&claims)?;
let credential_id = ProtocolCredentialId::new(sha256(&claims));
let signature = sign_payload(
ProtocolRecordKind::ProtocolCredential,
&claims,
protocol_issuer,
)?;
let credential = match (role, subject_id) {
(
ProtocolCredentialRole::SessionCoordinator,
ProtocolSubjectId::SessionCoordinator(subject_id),
) => ProtocolCredential::session_coordinator(
credential_id,
generation_identity,
subject_id,
public_key,
signature.to_vec(),
),
(ProtocolCredentialRole::AgentSandbox, ProtocolSubjectId::AgentSandbox(subject_id)) => {
ProtocolCredential::agent_sandbox(
credential_id,
generation_identity,
subject_id,
public_key,
signature.to_vec(),
)
}
_ => return Err(ProtocolValidationError::RoleMismatch),
};
Ok(credential)
}
/// Validates one protocol credential against its issuer and exact authority tuple.
///
/// # Errors
///
/// Returns an error for key, role, subject, generation, digest, or signature mismatch.
pub fn verify_protocol_credential(
credential: &ProtocolCredential,
protocol_issuer: &VerifyingKey,
expected_generation: &GenerationIdentity,
expected_role: ProtocolCredentialRole,
expected_subject: ProtocolSubjectId,
) -> Result<VerifyingKey, ProtocolValidationError> {
if &credential.generation_identity != expected_generation {
return Err(ProtocolValidationError::GenerationMismatch);
}
if credential.role() != expected_role {
return Err(ProtocolValidationError::RoleMismatch);
}
if credential.subject_id() != expected_subject {
return Err(ProtocolValidationError::SubjectMismatch);
}
let claims = ProtocolCredentialClaims {
protocol_version: credential.protocol_version,
generation_identity: &credential.generation_identity,
role: credential.role(),
subject_id: credential.subject_id(),
public_key: &credential.public_key,
};
let claims = encode_payload(&claims)?;
if credential.credential_id != ProtocolCredentialId::new(sha256(&claims)) {
return Err(ProtocolValidationError::CredentialDigestMismatch);
}
verify_payload(
ProtocolRecordKind::ProtocolCredential,
&claims,
&credential.issuer_signature,
protocol_issuer,
)?;
let key_bytes: [u8; 32] = credential
.public_key
.as_slice()
.try_into()
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
VerifyingKey::from_bytes(&key_bytes).map_err(|_| ProtocolValidationError::InvalidPublicKey)
}
/// Issues one capability for an exact live session and credential pair.
///
/// # Errors
///
/// Returns an error when its claims cannot be encoded.
pub fn issue_session_capability(
session_binding: SessionBinding,
coordinator_credential_id: ProtocolCredentialId,
sandbox_credential_id: ProtocolCredentialId,
coordinator_key: &SigningKey,
) -> Result<SessionRequestCapability, ProtocolValidationError> {
let claims = SessionCapabilityClaims {
protocol_version: ProtocolVersion::V1,
session_binding: &session_binding,
coordinator_credential_id,
sandbox_credential_id,
};
let claims = encode_payload(&claims)?;
let capability_id = CapabilityId::new(sha256(&claims));
let signature = sign_payload(
ProtocolRecordKind::SessionRequestCapability,
&claims,
coordinator_key,
)?;
Ok(SessionRequestCapability {
protocol_version: ProtocolVersion::V1,
capability_id,
session_binding,
coordinator_credential_id,
sandbox_credential_id,
coordinator_signature: signature.to_vec(),
})
}
fn verify_session_capability(
capability: &SessionRequestCapability,
session_binding: &SessionBinding,
coordinator_credential_id: ProtocolCredentialId,
sandbox_credential_id: ProtocolCredentialId,
coordinator_key: &VerifyingKey,
) -> Result<(), ProtocolValidationError> {
if &capability.session_binding != session_binding {
return Err(ProtocolValidationError::SessionBindingMismatch);
}
if capability.coordinator_credential_id != coordinator_credential_id
|| capability.sandbox_credential_id != sandbox_credential_id
{
return Err(ProtocolValidationError::CredentialMismatch);
}
let claims = SessionCapabilityClaims {
protocol_version: capability.protocol_version,
session_binding: &capability.session_binding,
coordinator_credential_id: capability.coordinator_credential_id,
sandbox_credential_id: capability.sandbox_credential_id,
};
let claims = encode_payload(&claims)?;
if capability.capability_id != CapabilityId::new(sha256(&claims)) {
return Err(ProtocolValidationError::CapabilityDigestMismatch);
}
verify_payload(
ProtocolRecordKind::SessionRequestCapability,
&claims,
&capability.coordinator_signature,
coordinator_key,
)?;
Ok(())
}
/// Checks the certificate bytes and exact TLS identity tuple carried by a TLS credential.
///
/// # Errors
///
/// Returns an error for a stale tuple, role/subject mismatch, or certificate digest mismatch.
pub fn verify_tls_credential(
credential: &TlsCredential,
expected_generation: &GenerationIdentity,
expected_role: TlsRole,
expected_subject: TlsSubjectId,
) -> Result<(), ProtocolValidationError> {
if &credential.generation_identity != expected_generation {
return Err(ProtocolValidationError::GenerationMismatch);
}
if credential.role() != expected_role {
return Err(ProtocolValidationError::RoleMismatch);
}
if credential.subject_id() != expected_subject {
return Err(ProtocolValidationError::SubjectMismatch);
}
if credential.certificate_sha256 != CertificateDigest::new(sha256(&credential.certificate_der))
{
return Err(ProtocolValidationError::CertificateDigestMismatch);
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct VerifiedSession {
session_binding: SessionBinding,
capability: SessionRequestCapability,
sandbox_credential_id: ProtocolCredentialId,
coordinator_credential_id: ProtocolCredentialId,
sandbox_key: VerifyingKey,
coordinator_key: VerifyingKey,
session_anchor_key: VerifyingKey,
}
impl VerifiedSession {
/// Constructs an authority only after validating its manifest, credentials, and capability.
///
/// # Errors
///
/// Returns an error when any key or identity tuple fails validation.
pub fn new(
trusted_manifest_digest: TrustManifestDigest,
tls_ca_sha256: CertificateDigest,
manifest: &TrustManifest,
session_binding: SessionBinding,
coordinator_credential: &ProtocolCredential,
sandbox_credential: &ProtocolCredential,
capability: SessionRequestCapability,
) -> Result<Self, ProtocolValidationError> {
let generation = &session_binding.generation_identity;
let issuer =
verify_trust_manifest(manifest, trusted_manifest_digest, generation, tls_ca_sha256)?;
let coordinator_key = verify_protocol_credential(
coordinator_credential,
&issuer,
generation,
ProtocolCredentialRole::SessionCoordinator,
ProtocolSubjectId::SessionCoordinator(generation.coordinator_id),
)?;
let sandbox_key = verify_protocol_credential(
sandbox_credential,
&issuer,
generation,
ProtocolCredentialRole::AgentSandbox,
ProtocolSubjectId::AgentSandbox(generation.sandbox_id),
)?;
verify_session_capability(
&capability,
&session_binding,
coordinator_credential.credential_id,
sandbox_credential.credential_id,
&coordinator_key,
)?;
let anchor_bytes: [u8; 32] = session_binding
.session_anchor_public_key
.as_slice()
.try_into()
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
let session_anchor_key = VerifyingKey::from_bytes(&anchor_bytes)
.map_err(|_| ProtocolValidationError::InvalidPublicKey)?;
Ok(Self {
session_binding,
capability,
sandbox_credential_id: sandbox_credential.credential_id,
coordinator_credential_id: coordinator_credential.credential_id,
sandbox_key,
coordinator_key,
session_anchor_key,
})
}
fn verify_sandbox_tuple(
&self,
session_binding: &SessionBinding,
capability: &SessionRequestCapability,
credential_id: ProtocolCredentialId,
) -> Result<(), ProtocolValidationError> {
if session_binding != &self.session_binding {
return Err(ProtocolValidationError::SessionBindingMismatch);
}
if capability != &self.capability {
return Err(ProtocolValidationError::CapabilityMismatch);
}
if credential_id != self.sandbox_credential_id {
return Err(ProtocolValidationError::CredentialMismatch);
}
Ok(())
}
/// Verifies an exact signed request and returns its digest over accepted payload bytes.
///
/// # Errors
///
/// Returns an error for signature, encoding, or tuple mismatch.
pub fn verify_request(
&self,
encoded: &[u8],
) -> Result<(MailboxRequest, RequestDigest), ProtocolValidationError> {
let (request, payload_bytes) = decode_signed::<MailboxRequest>(encoded, &self.sandbox_key)?;
self.verify_sandbox_tuple(
request.session_binding(),
request.session_capability(),
request.sandbox_credential_id(),
)?;
Ok((request, request_digest(&payload_bytes)))
}
/// Verifies a signed cancellation for the exact request.
///
/// # Errors
///
/// Returns an error for signature, encoding, tuple, or request mismatch.
pub fn verify_cancellation(
&self,
encoded: &[u8],
request_id: RequestId,
) -> Result<MailboxCancellation, ProtocolValidationError> {
let (cancellation, _) = decode_signed::<MailboxCancellation>(encoded, &self.sandbox_key)?;
self.verify_sandbox_tuple(
&cancellation.session_binding,
&cancellation.session_capability,
cancellation.sandbox_credential_id,
)?;
if cancellation.request_id != request_id {
return Err(ProtocolValidationError::RequestMismatch);
}
Ok(cancellation)
}
/// Verifies a signed highest-contiguous acknowledgement for the exact request.
///
/// # Errors
///
/// Returns an error for signature, encoding, tuple, or request mismatch.
pub fn verify_result_ack(
&self,
encoded: &[u8],
request_id: RequestId,
digest: RequestDigest,
) -> Result<MailboxResultAck, ProtocolValidationError> {
let (ack, _) = decode_signed::<MailboxResultAck>(encoded, &self.sandbox_key)?;
self.verify_sandbox_tuple(
&ack.session_binding,
&ack.session_capability,
ack.sandbox_credential_id,
)?;
if ack.request_id != request_id || ack.request_sha256 != digest {
return Err(ProtocolValidationError::RequestMismatch);
}
Ok(ack)
}
/// Verifies one host-only Session Anchor decision for the exact request.
///
/// # Errors
///
/// Returns an error for signature, encoding, tuple, or request mismatch.
pub fn verify_developer_decision(
&self,
encoded: &[u8],
request_id: RequestId,
digest: RequestDigest,
) -> Result<DeveloperDecision, ProtocolValidationError> {
let (decision, _) = decode_signed::<DeveloperDecision>(encoded, &self.session_anchor_key)?;
if decision.session_binding != self.session_binding {
return Err(ProtocolValidationError::SessionBindingMismatch);
}
if decision.request_id != request_id || decision.request_sha256 != digest {
return Err(ProtocolValidationError::RequestMismatch);
}
Ok(decision)
}
/// Verifies one coordinator result chunk for the exact request.
///
/// # Errors
///
/// Returns an error for signature, encoding, tuple, request, or size mismatch.
pub fn verify_result_chunk(
&self,
encoded: &[u8],
request_id: RequestId,
digest: RequestDigest,
) -> Result<MailboxResultChunk, ProtocolValidationError> {
let (chunk, _) = decode_signed::<MailboxResultChunk>(encoded, &self.coordinator_key)?;
if chunk.bytes.len() > MAX_RESULT_CHUNK_BYTES {
return Err(ProtocolValidationError::ChunkTooLarge {
length: chunk.bytes.len(),
});
}
self.verify_coordinator_tuple(
&chunk.session_binding,
chunk.request_id,
chunk.request_sha256,
chunk.coordinator_credential_id,
request_id,
digest,
)?;
Ok(chunk)
}
/// Verifies one coordinator terminal result for the exact request.
///
/// # Errors
///
/// Returns an error for signature, encoding, tuple, or request mismatch.
pub fn verify_result(
&self,
encoded: &[u8],
request_id: RequestId,
digest: RequestDigest,
) -> Result<MailboxResult, ProtocolValidationError> {
let (result, _) = decode_signed::<MailboxResult>(encoded, &self.coordinator_key)?;
self.verify_coordinator_tuple(
&result.session_binding,
result.request_id,
result.request_sha256,
result.coordinator_credential_id,
request_id,
digest,
)?;
Ok(result)
}
fn verify_coordinator_tuple(
&self,
session_binding: &SessionBinding,
actual_request_id: RequestId,
actual_digest: RequestDigest,
credential_id: ProtocolCredentialId,
expected_request_id: RequestId,
expected_digest: RequestDigest,
) -> Result<(), ProtocolValidationError> {
if session_binding != &self.session_binding {
return Err(ProtocolValidationError::SessionBindingMismatch);
}
if credential_id != self.coordinator_credential_id {
return Err(ProtocolValidationError::CredentialMismatch);
}
if actual_request_id != expected_request_id || actual_digest != expected_digest {
return Err(ProtocolValidationError::RequestMismatch);
}
Ok(())
}
}
#[derive(Debug, Default)]
pub struct RequestReplayGuard {
admitted: BTreeSet<RequestId>,
}
impl RequestReplayGuard {
/// Verifies and records a request ID exactly once for this generation.
///
/// # Errors
///
/// Returns an error when validation fails or the request ID was previously admitted.
pub fn admit(
&mut self,
session: &VerifiedSession,
encoded: &[u8],
) -> Result<(MailboxRequest, RequestDigest), ProtocolValidationError> {
let (request, digest) = session.verify_request(encoded)?;
if !self.admitted.insert(request.request_id()) {
return Err(ProtocolValidationError::RequestReplay);
}
Ok((request, digest))
}
#[must_use]
pub fn len(&self) -> usize {
self.admitted.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.admitted.is_empty()
}
}
const _: () = assert!(MAX_UNACKNOWLEDGED_CHUNKS == 8);

View file

@ -0,0 +1,339 @@
use std::fmt;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::Serialize;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::identity::{RequestDigest, StreamDigest, TrustManifestDigest};
use crate::records::{
DeveloperDecision, MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck,
MailboxResultChunk, SignedProtocolRecord,
};
pub const MAX_SIGNED_RECORD_BYTES: usize = 65_536;
pub const MAX_RESULT_CHUNK_BYTES: usize = 49_152;
const SIGNATURE_BYTES: usize = 64;
const LENGTH_BYTES: usize = size_of::<u64>();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ProtocolRecordKind {
TrustManifest,
ProtocolCredential,
SessionRequestCapability,
MailboxRequest,
MailboxCancellation,
MailboxResultAck,
DeveloperDecision,
MailboxResult,
MailboxResultChunk,
}
impl ProtocolRecordKind {
#[must_use]
pub const fn signing_context(self) -> &'static [u8] {
match self {
Self::TrustManifest => b"lazaret/v1/trust-manifest",
Self::ProtocolCredential => b"lazaret/v1/protocol-credential",
Self::SessionRequestCapability => b"lazaret/v1/session-request-capability",
Self::MailboxRequest => b"lazaret/v1/mailbox-request",
Self::MailboxCancellation => b"lazaret/v1/mailbox-cancellation",
Self::MailboxResultAck => b"lazaret/v1/mailbox-result-ack",
Self::DeveloperDecision => b"lazaret/v1/developer-decision",
Self::MailboxResult => b"lazaret/v1/mailbox-result",
Self::MailboxResultChunk => b"lazaret/v1/mailbox-result-chunk",
}
}
}
pub trait ProtocolPayload: DeserializeOwned + Serialize {
const RECORD_KIND: ProtocolRecordKind;
}
macro_rules! protocol_payload {
($($type:ty => $kind:ident),+ $(,)?) => {
$(
impl ProtocolPayload for $type {
const RECORD_KIND: ProtocolRecordKind = ProtocolRecordKind::$kind;
}
)+
};
}
protocol_payload!(
MailboxRequest => MailboxRequest,
MailboxCancellation => MailboxCancellation,
MailboxResultAck => MailboxResultAck,
DeveloperDecision => DeveloperDecision,
MailboxResult => MailboxResult,
MailboxResultChunk => MailboxResultChunk,
);
#[derive(Debug)]
pub enum ProtocolCodecError {
RecordTooLarge { length: usize },
LengthOverflow,
Malformed(postcard::Error),
TrailingBytes,
NonMinimalEncoding,
InvalidSignatureLength { length: usize },
InvalidSignature,
}
impl fmt::Display for ProtocolCodecError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RecordTooLarge { length } => write!(
formatter,
"signed protocol record length {length} exceeds {MAX_SIGNED_RECORD_BYTES}"
),
Self::LengthOverflow => formatter.write_str("protocol payload length exceeds u64"),
Self::Malformed(error) => write!(formatter, "malformed protocol record: {error}"),
Self::TrailingBytes => formatter.write_str("protocol record has trailing bytes"),
Self::NonMinimalEncoding => {
formatter.write_str("protocol record does not use minimal Postcard encoding")
}
Self::InvalidSignatureLength { length } => {
write!(
formatter,
"protocol signature length is {length}, expected 64"
)
}
Self::InvalidSignature => formatter.write_str("protocol signature is invalid"),
}
}
}
impl std::error::Error for ProtocolCodecError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Malformed(error) => Some(error),
_ => None,
}
}
}
pub(crate) fn encode_payload<T: Serialize>(payload: &T) -> Result<Vec<u8>, ProtocolCodecError> {
postcard::to_stdvec(payload).map_err(ProtocolCodecError::Malformed)
}
pub(crate) fn decode_payload<T>(bytes: &[u8]) -> Result<T, ProtocolCodecError>
where
T: DeserializeOwned + Serialize,
{
let (value, remainder) =
postcard::take_from_bytes(bytes).map_err(ProtocolCodecError::Malformed)?;
if !remainder.is_empty() {
return Err(ProtocolCodecError::TrailingBytes);
}
if encode_payload(&value)? != bytes {
return Err(ProtocolCodecError::NonMinimalEncoding);
}
Ok(value)
}
fn signing_message(
kind: ProtocolRecordKind,
payload: &[u8],
) -> Result<Vec<u8>, ProtocolCodecError> {
let length = u64::try_from(payload.len()).map_err(|_| ProtocolCodecError::LengthOverflow)?;
let context = kind.signing_context();
let mut message = Vec::with_capacity(context.len() + LENGTH_BYTES + payload.len());
message.extend_from_slice(context);
message.extend_from_slice(&length.to_be_bytes());
message.extend_from_slice(payload);
Ok(message)
}
pub(crate) fn sign_payload(
kind: ProtocolRecordKind,
payload: &[u8],
signing_key: &SigningKey,
) -> Result<[u8; SIGNATURE_BYTES], ProtocolCodecError> {
Ok(signing_key
.sign(&signing_message(kind, payload)?)
.to_bytes())
}
pub(crate) fn verify_payload(
kind: ProtocolRecordKind,
payload: &[u8],
signature: &[u8],
verifying_key: &VerifyingKey,
) -> Result<(), ProtocolCodecError> {
let signature = Signature::from_slice(signature).map_err(|_| {
ProtocolCodecError::InvalidSignatureLength {
length: signature.len(),
}
})?;
verifying_key
.verify_strict(&signing_message(kind, payload)?, &signature)
.map_err(|_| ProtocolCodecError::InvalidSignature)
}
/// Signs the minimal Postcard payload and returns one bounded outer record.
///
/// # Errors
///
/// Returns an error when serialization fails or the complete record exceeds 65,536 bytes.
pub fn encode_signed<T: ProtocolPayload>(
payload: &T,
signing_key: &SigningKey,
) -> Result<Vec<u8>, ProtocolCodecError> {
let payload_bytes = encode_payload(payload)?;
let signature = sign_payload(T::RECORD_KIND, &payload_bytes, signing_key)?;
let record = SignedProtocolRecord {
payload_bytes,
signature: signature.to_vec(),
};
let encoded = encode_payload(&record)?;
if encoded.len() > MAX_SIGNED_RECORD_BYTES {
return Err(ProtocolCodecError::RecordTooLarge {
length: encoded.len(),
});
}
Ok(encoded)
}
pub(crate) fn decode_signed<T>(
encoded: &[u8],
verifying_key: &VerifyingKey,
) -> Result<(T, Vec<u8>), ProtocolCodecError>
where
T: ProtocolPayload,
{
if encoded.len() > MAX_SIGNED_RECORD_BYTES {
return Err(ProtocolCodecError::RecordTooLarge {
length: encoded.len(),
});
}
let record: SignedProtocolRecord = decode_payload(encoded)?;
verify_payload(
T::RECORD_KIND,
&record.payload_bytes,
&record.signature,
verifying_key,
)?;
let payload = decode_payload(&record.payload_bytes)?;
Ok((payload, record.payload_bytes))
}
#[must_use]
pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] {
Sha256::digest(bytes).into()
}
#[must_use]
pub fn request_digest(payload_bytes: &[u8]) -> RequestDigest {
RequestDigest::new(sha256(payload_bytes))
}
#[must_use]
pub fn stream_digest(bytes: &[u8]) -> StreamDigest {
StreamDigest::new(sha256(bytes))
}
#[must_use]
pub fn trust_manifest_digest(encoded_manifest: &[u8]) -> TrustManifestDigest {
TrustManifestDigest::new(sha256(encoded_manifest))
}
#[cfg(test)]
mod tests {
use ed25519_dalek::SigningKey;
use serde::Serialize;
use crate::identity::{
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId,
ProtocolCredentialId, RequestDigest, RequestId, SandboxId, SessionId,
};
use crate::records::{
GenerationIdentity, MailboxResultChunk, ResultStream, SessionBinding, SignedProtocolRecord,
};
use super::{
ProtocolCodecError, ProtocolPayload, ProtocolRecordKind, decode_signed, encode_payload,
sign_payload,
};
#[test]
fn signing_context_and_length_have_an_exact_vector() {
let key = SigningKey::from_bytes(&[7; 32]);
let signature = sign_payload(ProtocolRecordKind::MailboxRequest, &[0], &key).unwrap();
assert_eq!(
signature,
[
79, 132, 54, 199, 95, 126, 134, 255, 188, 61, 184, 49, 116, 41, 8, 91, 38, 77, 75,
188, 184, 7, 164, 133, 194, 37, 215, 228, 53, 141, 99, 203, 254, 220, 210, 170,
157, 52, 147, 199, 116, 247, 230, 77, 128, 126, 113, 16, 115, 207, 134, 21, 128,
210, 193, 102, 45, 148, 252, 17, 87, 57, 218, 11,
]
);
let record = encode_payload(&SignedProtocolRecord {
payload_bytes: vec![0],
signature: signature.to_vec(),
})
.unwrap();
assert_eq!(
record,
[
1, 0, 64, 79, 132, 54, 199, 95, 126, 134, 255, 188, 61, 184, 49, 116, 41, 8, 91,
38, 77, 75, 188, 184, 7, 164, 133, 194, 37, 215, 228, 53, 141, 99, 203, 254, 220,
210, 170, 157, 52, 147, 199, 116, 247, 230, 77, 128, 126, 113, 16, 115, 207, 134,
21, 128, 210, 193, 102, 45, 148, 252, 17, 87, 57, 218, 11,
]
);
}
#[test]
fn valid_signature_does_not_authorize_an_unknown_payload_version() {
#[derive(Serialize)]
struct UnknownVersionChunk {
protocol_version: u8,
session_binding: SessionBinding,
request_id: RequestId,
request_sha256: RequestDigest,
coordinator_credential_id: ProtocolCredentialId,
sequence: u64,
stream: ResultStream,
bytes: Vec<u8>,
}
let opaque = [1; 32];
let generation_identity = GenerationIdentity {
agent_profile: "agent".into(),
domain_id: DomainId::new(opaque),
generation_id: GenerationId::new(1),
configuration_fingerprint: ConfigurationFingerprint::new(opaque),
coordinator_id: CoordinatorId::new(opaque),
sandbox_id: SandboxId::new(opaque),
};
let payload = encode_payload(&UnknownVersionChunk {
protocol_version: 1,
session_binding: SessionBinding {
generation_identity,
cli_id: ClientId::new(opaque),
session_id: SessionId::new(opaque),
session_anchor_public_key: vec![1; 32],
},
request_id: RequestId::new(opaque),
request_sha256: RequestDigest::new(opaque),
coordinator_credential_id: ProtocolCredentialId::new(opaque),
sequence: 0,
stream: ResultStream::Stdout,
bytes: Vec::new(),
})
.unwrap();
let key = SigningKey::from_bytes(&[8; 32]);
let signature = sign_payload(MailboxResultChunk::RECORD_KIND, &payload, &key).unwrap();
let encoded = encode_payload(&SignedProtocolRecord {
payload_bytes: payload,
signature: signature.to_vec(),
})
.unwrap();
assert!(matches!(
decode_signed::<MailboxResultChunk>(&encoded, &key.verifying_key()),
Err(ProtocolCodecError::Malformed(_))
));
}
}

View file

@ -0,0 +1,181 @@
use std::fmt;
use sha2::{Digest, Sha256};
use crate::identity::StreamDigest;
use crate::records::{MailboxResult, MailboxResultChunk, ResultStream};
use super::MAX_RESULT_CHUNK_BYTES;
pub const MAX_UNACKNOWLEDGED_CHUNKS: usize = 8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultWindowError {
WindowFull,
SequenceExhausted,
ChunkTooLarge { length: usize },
UnexpectedSequence { expected: u64, actual: u64 },
AckRegression,
AckBeyondSent,
ChunkCountMismatch,
StdoutDigestMismatch,
StderrDigestMismatch,
}
impl fmt::Display for ResultWindowError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WindowFull => formatter.write_str("result sender window is full"),
Self::SequenceExhausted => formatter.write_str("result sequence space is exhausted"),
Self::ChunkTooLarge { length } => write!(
formatter,
"result chunk length {length} exceeds {MAX_RESULT_CHUNK_BYTES}"
),
Self::UnexpectedSequence { expected, actual } => {
write!(
formatter,
"expected result sequence {expected}, received {actual}"
)
}
Self::AckRegression => {
formatter.write_str("result acknowledgement regressed or repeated")
}
Self::AckBeyondSent => {
formatter.write_str("result acknowledgement exceeds sent sequence")
}
Self::ChunkCountMismatch => formatter.write_str("terminal result chunk count mismatch"),
Self::StdoutDigestMismatch => {
formatter.write_str("terminal result stdout digest mismatch")
}
Self::StderrDigestMismatch => {
formatter.write_str("terminal result stderr digest mismatch")
}
}
}
}
impl std::error::Error for ResultWindowError {}
#[derive(Clone, Debug, Default)]
pub struct ResultSenderWindow {
sent_count: u64,
acknowledged_count: u64,
}
impl ResultSenderWindow {
/// Reserves the next sequence while enforcing the eight-chunk unacknowledged bound.
///
/// # Errors
///
/// Returns an error when the payload is oversized or the sender window is full.
pub fn reserve(&mut self, byte_length: usize) -> Result<u64, ResultWindowError> {
if byte_length > MAX_RESULT_CHUNK_BYTES {
return Err(ResultWindowError::ChunkTooLarge {
length: byte_length,
});
}
if self.outstanding() >= MAX_UNACKNOWLEDGED_CHUNKS {
return Err(ResultWindowError::WindowFull);
}
let sequence = self.sent_count;
self.sent_count = self
.sent_count
.checked_add(1)
.ok_or(ResultWindowError::SequenceExhausted)?;
Ok(sequence)
}
/// Advances the window using a highest-contiguous sequence acknowledgement.
///
/// # Errors
///
/// Returns an error when the acknowledgement repeats, regresses, or exceeds sent data.
pub fn acknowledge(&mut self, highest_contiguous: u64) -> Result<(), ResultWindowError> {
let acknowledged_count = highest_contiguous
.checked_add(1)
.ok_or(ResultWindowError::AckBeyondSent)?;
if acknowledged_count <= self.acknowledged_count {
return Err(ResultWindowError::AckRegression);
}
if acknowledged_count > self.sent_count {
return Err(ResultWindowError::AckBeyondSent);
}
self.acknowledged_count = acknowledged_count;
Ok(())
}
#[must_use]
pub fn outstanding(&self) -> usize {
usize::try_from(self.sent_count - self.acknowledged_count).unwrap_or(usize::MAX)
}
}
#[derive(Clone, Debug)]
pub struct ResultCollector {
next_sequence: u64,
stdout: Sha256,
stderr: Sha256,
}
impl Default for ResultCollector {
fn default() -> Self {
Self {
next_sequence: 0,
stdout: Sha256::new(),
stderr: Sha256::new(),
}
}
}
impl ResultCollector {
/// Adds the next verified chunk to its ordered stream digest.
///
/// # Errors
///
/// Returns an error for oversized, duplicate, skipped, or reordered chunks.
pub fn push(&mut self, chunk: &MailboxResultChunk) -> Result<(), ResultWindowError> {
if chunk.bytes.len() > MAX_RESULT_CHUNK_BYTES {
return Err(ResultWindowError::ChunkTooLarge {
length: chunk.bytes.len(),
});
}
if chunk.sequence != self.next_sequence {
return Err(ResultWindowError::UnexpectedSequence {
expected: self.next_sequence,
actual: chunk.sequence,
});
}
match chunk.stream {
ResultStream::Stdout => self.stdout.update(&chunk.bytes),
ResultStream::Stderr => self.stderr.update(&chunk.bytes),
}
self.next_sequence = self
.next_sequence
.checked_add(1)
.ok_or(ResultWindowError::SequenceExhausted)?;
Ok(())
}
/// Checks that the terminal record commits the exact ordered chunks received.
///
/// # Errors
///
/// Returns an error when the count or either stream digest differs.
pub fn finish(&self, result: &MailboxResult) -> Result<(), ResultWindowError> {
if result.chunk_count != self.next_sequence {
return Err(ResultWindowError::ChunkCountMismatch);
}
if result.stdout_sha256 != StreamDigest::new(self.stdout.clone().finalize().into()) {
return Err(ResultWindowError::StdoutDigestMismatch);
}
if result.stderr_sha256 != StreamDigest::new(self.stderr.clone().finalize().into()) {
return Err(ResultWindowError::StderrDigestMismatch);
}
Ok(())
}
#[must_use]
pub const fn highest_contiguous_sequence(&self) -> Option<u64> {
self.next_sequence.checked_sub(1)
}
}

View file

@ -0,0 +1,494 @@
use ed25519_dalek::SigningKey;
use lazaret_core::identity::{
CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, CoordinatorId, DomainId,
GenerationId, RequestDigest, RequestId, SandboxId, SessionId, StreamDigest,
TrustManifestDigest,
};
use lazaret_core::protocol::{
MAX_RESULT_CHUNK_BYTES, MAX_UNACKNOWLEDGED_CHUNKS, ProtocolCodecError, ProtocolValidationError,
RequestReplayGuard, ResultCollector, ResultSenderWindow, ResultWindowError, VerifiedSession,
encode_signed, issue_protocol_credential, issue_session_capability, issue_trust_manifest,
request_digest, stream_digest, verify_protocol_credential, verify_tls_credential,
verify_trust_manifest,
};
use lazaret_core::records::{
DeveloperDecision, DeveloperDecisionKind, EscalationOutcome, GenerationIdentity,
MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk,
ProtocolCredential, ProtocolCredentialRole, ProtocolSubjectId, ProtocolVersion, ResultStream,
SessionBinding, SessionRequestCapability, TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
};
fn bytes(value: u8) -> [u8; 32] {
[value; 32]
}
fn key(value: u8) -> SigningKey {
SigningKey::from_bytes(&bytes(value))
}
fn generation(value: u8) -> GenerationIdentity {
GenerationIdentity {
agent_profile: format!("agent-{value}"),
domain_id: DomainId::new(bytes(value)),
generation_id: GenerationId::new(u64::from(value)),
configuration_fingerprint: ConfigurationFingerprint::new(bytes(value + 1)),
coordinator_id: CoordinatorId::new(bytes(value + 2)),
sandbox_id: SandboxId::new(bytes(value + 3)),
}
}
struct Fixture {
generation: GenerationIdentity,
tls_ca_digest: CertificateDigest,
manifest: TrustManifest,
manifest_digest: TrustManifestDigest,
coordinator_credential: ProtocolCredential,
sandbox_credential: ProtocolCredential,
capability: SessionRequestCapability,
binding: SessionBinding,
coordinator_key: SigningKey,
sandbox_key: SigningKey,
anchor_key: SigningKey,
session: VerifiedSession,
}
fn fixture() -> Fixture {
let generation = generation(1);
let tls_ca_digest = CertificateDigest::new(bytes(20));
let issuer = key(21);
let coordinator_key = key(22);
let sandbox_key = key(23);
let anchor_key = key(24);
let (manifest, manifest_digest) =
issue_trust_manifest(generation.clone(), tls_ca_digest, &issuer).unwrap();
let coordinator_credential = issue_protocol_credential(
generation.clone(),
ProtocolCredentialRole::SessionCoordinator,
ProtocolSubjectId::SessionCoordinator(generation.coordinator_id),
&coordinator_key.verifying_key(),
&issuer,
)
.unwrap();
let sandbox_credential = issue_protocol_credential(
generation.clone(),
ProtocolCredentialRole::AgentSandbox,
ProtocolSubjectId::AgentSandbox(generation.sandbox_id),
&sandbox_key.verifying_key(),
&issuer,
)
.unwrap();
let binding = SessionBinding {
generation_identity: generation.clone(),
cli_id: ClientId::new(bytes(25)),
session_id: SessionId::new(bytes(26)),
session_anchor_public_key: anchor_key.verifying_key().to_bytes().to_vec(),
};
let capability = issue_session_capability(
binding.clone(),
coordinator_credential.credential_id,
sandbox_credential.credential_id,
&coordinator_key,
)
.unwrap();
let session = VerifiedSession::new(
manifest_digest,
tls_ca_digest,
&manifest,
binding.clone(),
&coordinator_credential,
&sandbox_credential,
capability.clone(),
)
.unwrap();
Fixture {
generation,
tls_ca_digest,
manifest,
manifest_digest,
coordinator_credential,
sandbox_credential,
capability,
binding,
coordinator_key,
sandbox_key,
anchor_key,
session,
}
}
fn request(fixture: &Fixture, id: u8) -> MailboxRequest {
MailboxRequest::new(
fixture.binding.clone(),
RequestId::new(bytes(id)),
fixture.capability.clone(),
fixture.sandbox_credential.credential_id,
vec![b"/usr/bin/id".to_vec(), b"-u".to_vec()],
"show the numeric user identity".into(),
)
.unwrap()
}
#[test]
fn exact_session_tuple_is_authenticated_and_request_ids_do_not_replay() {
let fixture = fixture();
let request = request(&fixture, 30);
let encoded = encode_signed(&request, &fixture.sandbox_key).unwrap();
let mut replay = RequestReplayGuard::default();
let (accepted, digest) = replay.admit(&fixture.session, &encoded).unwrap();
assert_eq!(accepted, request);
assert_eq!(
digest,
request_digest(&postcard::to_stdvec(&request).unwrap())
);
assert!(matches!(
replay.admit(&fixture.session, &encoded),
Err(ProtocolValidationError::RequestReplay)
));
let mut tampered = encoded.clone();
let last = tampered.last_mut().unwrap();
*last ^= 1;
assert!(matches!(
fixture.session.verify_request(&tampered),
Err(ProtocolValidationError::Codec(
ProtocolCodecError::InvalidSignature
))
));
let wrong_key = encode_signed(&request, &key(31)).unwrap();
assert!(matches!(
fixture.session.verify_request(&wrong_key),
Err(ProtocolValidationError::Codec(
ProtocolCodecError::InvalidSignature
))
));
let mut stale_binding = fixture.binding.clone();
stale_binding.generation_identity.generation_id = GenerationId::new(2);
let stale = MailboxRequest::new(
stale_binding,
RequestId::new(bytes(32)),
fixture.capability.clone(),
fixture.sandbox_credential.credential_id,
vec![b"/usr/bin/true".to_vec()],
String::new(),
)
.unwrap();
let stale = encode_signed(&stale, &fixture.sandbox_key).unwrap();
assert!(matches!(
fixture.session.verify_request(&stale),
Err(ProtocolValidationError::SessionBindingMismatch)
));
}
#[test]
fn trust_credentials_refuse_key_role_generation_and_digest_confusion() {
let fixture = fixture();
let issuer = verify_trust_manifest(
&fixture.manifest,
fixture.manifest_digest,
&fixture.generation,
fixture.tls_ca_digest,
)
.unwrap();
assert!(matches!(
verify_trust_manifest(
&fixture.manifest,
TrustManifestDigest::new(bytes(99)),
&fixture.generation,
fixture.tls_ca_digest,
),
Err(ProtocolValidationError::ManifestDigestMismatch)
));
assert!(matches!(
verify_protocol_credential(
&fixture.sandbox_credential,
&issuer,
&fixture.generation,
ProtocolCredentialRole::SessionCoordinator,
ProtocolSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
),
Err(ProtocolValidationError::RoleMismatch)
));
let stale_generation = generation(40);
assert!(matches!(
verify_protocol_credential(
&fixture.sandbox_credential,
&issuer,
&stale_generation,
ProtocolCredentialRole::AgentSandbox,
ProtocolSubjectId::AgentSandbox(fixture.generation.sandbox_id),
),
Err(ProtocolValidationError::GenerationMismatch)
));
let mut tampered_credential = fixture.sandbox_credential.clone();
tampered_credential.public_key[0] ^= 1;
assert!(matches!(
verify_protocol_credential(
&tampered_credential,
&issuer,
&fixture.generation,
ProtocolCredentialRole::AgentSandbox,
ProtocolSubjectId::AgentSandbox(fixture.generation.sandbox_id),
),
Err(ProtocolValidationError::CredentialDigestMismatch)
));
let mut tampered_capability = fixture.capability.clone();
tampered_capability.capability_id = CapabilityId::new(bytes(98));
assert!(matches!(
VerifiedSession::new(
fixture.manifest_digest,
fixture.tls_ca_digest,
&fixture.manifest,
fixture.binding.clone(),
&fixture.coordinator_credential,
&fixture.sandbox_credential,
tampered_capability,
),
Err(ProtocolValidationError::CapabilityDigestMismatch)
));
}
#[test]
fn tls_credentials_refuse_role_and_certificate_digest_confusion() {
let fixture = fixture();
let certificate = b"synthetic DER fixture".to_vec();
let tls = TlsCredential::session_coordinator(
fixture.generation.clone(),
fixture.generation.coordinator_id,
certificate.clone(),
CertificateDigest::new(
lazaret_core::protocol::stream_digest(&certificate)
.as_bytes()
.to_owned(),
),
);
verify_tls_credential(
&tls,
&fixture.generation,
TlsRole::SessionCoordinator,
TlsSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
)
.unwrap();
assert!(matches!(
verify_tls_credential(
&tls,
&fixture.generation,
TlsRole::ClientCli,
TlsSubjectId::ClientCli(ClientId::new(bytes(1))),
),
Err(ProtocolValidationError::RoleMismatch)
));
let invalid_tls = TlsCredential::session_coordinator(
fixture.generation.clone(),
fixture.generation.coordinator_id,
certificate,
CertificateDigest::new(bytes(1)),
);
assert!(matches!(
verify_tls_credential(
&invalid_tls,
&fixture.generation,
TlsRole::SessionCoordinator,
TlsSubjectId::SessionCoordinator(fixture.generation.coordinator_id),
),
Err(ProtocolValidationError::CertificateDigestMismatch)
));
}
#[test]
fn session_anchor_and_sandbox_signatures_are_not_interchangeable() {
let fixture = fixture();
let request = request(&fixture, 50);
let request_bytes = encode_signed(&request, &fixture.sandbox_key).unwrap();
let (_, digest) = fixture.session.verify_request(&request_bytes).unwrap();
let decision = DeveloperDecision {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding.clone(),
request_id: request.request_id(),
request_sha256: digest,
decision: DeveloperDecisionKind::Approve,
approved_command_argv: request.command_argv().to_vec(),
};
let signed = encode_signed(&decision, &fixture.anchor_key).unwrap();
assert_eq!(
fixture
.session
.verify_developer_decision(&signed, request.request_id(), digest)
.unwrap(),
decision
);
let sandbox_signed = encode_signed(&decision, &fixture.sandbox_key).unwrap();
assert!(matches!(
fixture
.session
.verify_developer_decision(&sandbox_signed, request.request_id(), digest),
Err(ProtocolValidationError::Codec(
ProtocolCodecError::InvalidSignature
))
));
}
#[test]
fn cancellation_ack_chunks_and_terminal_result_bind_the_accepted_request() {
let fixture = fixture();
let request = request(&fixture, 60);
let request_id = request.request_id();
let (_, digest) = fixture
.session
.verify_request(&encode_signed(&request, &fixture.sandbox_key).unwrap())
.unwrap();
let cancellation = MailboxCancellation {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding.clone(),
request_id,
session_capability: fixture.capability.clone(),
sandbox_credential_id: fixture.sandbox_credential.credential_id,
};
let cancellation = encode_signed(&cancellation, &fixture.sandbox_key).unwrap();
fixture
.session
.verify_cancellation(&cancellation, request_id)
.unwrap();
let ack = MailboxResultAck {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding.clone(),
request_id,
request_sha256: digest,
session_capability: fixture.capability.clone(),
sandbox_credential_id: fixture.sandbox_credential.credential_id,
highest_contiguous_sequence: 1,
};
let ack = encode_signed(&ack, &fixture.sandbox_key).unwrap();
fixture
.session
.verify_result_ack(&ack, request_id, digest)
.unwrap();
let chunks = [
(ResultStream::Stdout, b"hello".as_slice()),
(ResultStream::Stderr, b"warning".as_slice()),
];
let mut collector = ResultCollector::default();
for (sequence, (stream, bytes)) in chunks.iter().enumerate() {
let chunk = MailboxResultChunk {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding.clone(),
request_id,
request_sha256: digest,
coordinator_credential_id: fixture.coordinator_credential.credential_id,
sequence: u64::try_from(sequence).unwrap(),
stream: *stream,
bytes: bytes.to_vec(),
};
let encoded = encode_signed(&chunk, &fixture.coordinator_key).unwrap();
let verified = fixture
.session
.verify_result_chunk(&encoded, request_id, digest)
.unwrap();
collector.push(&verified).unwrap();
if sequence == 0 {
assert_eq!(
collector.push(&verified),
Err(ResultWindowError::UnexpectedSequence {
expected: 1,
actual: 0,
})
);
}
}
let result = MailboxResult {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding.clone(),
request_id,
request_sha256: digest,
coordinator_credential_id: fixture.coordinator_credential.credential_id,
outcome: EscalationOutcome::Completed,
exit_code: Some(0),
chunk_count: 2,
stdout_sha256: stream_digest(b"hello"),
stderr_sha256: stream_digest(b"warning"),
};
let encoded = encode_signed(&result, &fixture.coordinator_key).unwrap();
let verified = fixture
.session
.verify_result(&encoded, request_id, digest)
.unwrap();
collector.finish(&verified).unwrap();
let mut wrong_digest = result;
wrong_digest.stdout_sha256 = StreamDigest::new(bytes(1));
assert_eq!(
collector.finish(&wrong_digest),
Err(ResultWindowError::StdoutDigestMismatch)
);
assert!(matches!(
fixture
.session
.verify_result(&encoded, request_id, RequestDigest::new(bytes(99))),
Err(ProtocolValidationError::RequestMismatch)
));
}
#[test]
fn result_windows_are_bounded_and_contiguous() {
let mut sender = ResultSenderWindow::default();
for expected in 0..MAX_UNACKNOWLEDGED_CHUNKS {
assert_eq!(sender.reserve(1).unwrap(), u64::try_from(expected).unwrap());
}
assert_eq!(sender.reserve(1), Err(ResultWindowError::WindowFull));
sender.acknowledge(3).unwrap();
assert_eq!(sender.outstanding(), 4);
assert_eq!(sender.acknowledge(3), Err(ResultWindowError::AckRegression));
assert_eq!(
sender.acknowledge(99),
Err(ResultWindowError::AckBeyondSent)
);
assert_eq!(
sender.reserve(MAX_RESULT_CHUNK_BYTES + 1),
Err(ResultWindowError::ChunkTooLarge {
length: MAX_RESULT_CHUNK_BYTES + 1
})
);
let fixture = fixture();
let oversized = MailboxResultChunk {
protocol_version: ProtocolVersion::V1,
session_binding: fixture.binding,
request_id: RequestId::new(bytes(70)),
request_sha256: RequestDigest::new(bytes(71)),
coordinator_credential_id: fixture.coordinator_credential.credential_id,
sequence: 0,
stream: ResultStream::Stdout,
bytes: vec![0; MAX_RESULT_CHUNK_BYTES + 1],
};
let encoded = encode_signed(&oversized, &fixture.coordinator_key).unwrap();
assert!(matches!(
fixture.session.verify_result_chunk(
&encoded,
RequestId::new(bytes(70)),
RequestDigest::new(bytes(71))
),
Err(ProtocolValidationError::ChunkTooLarge { .. })
));
}
#[test]
fn complete_signed_record_limit_is_enforced() {
let fixture = fixture();
let oversized = MailboxRequest::new(
fixture.binding,
RequestId::new(bytes(80)),
fixture.capability,
fixture.sandbox_credential.credential_id,
vec![b"/usr/bin/true".to_vec()],
"x".repeat(65_536),
)
.unwrap();
assert!(matches!(
encode_signed(&oversized, &fixture.sandbox_key),
Err(ProtocolCodecError::RecordTooLarge { .. })
));
}

View file

@ -9,6 +9,9 @@ skip = [
{ name = "oci-spec", version = "0.9.0", reason = "oci-client 0.17.0 uses its current OCI types while the runtime supervisor directly uses 0.10.0" }, { name = "oci-spec", version = "0.9.0", reason = "oci-client 0.17.0 uses its current OCI types while the runtime supervisor directly uses 0.10.0" },
{ name = "untrusted", version = "0.7.1", reason = "oci-client rustls authentication uses aws-lc-rs's compatibility line" }, { name = "untrusted", version = "0.7.1", reason = "oci-client rustls authentication uses aws-lc-rs's compatibility line" },
] ]
skip-tree = [
{ name = "sha2", version = "=0.10.9", depth = 2, reason = "ed25519-dalek 2.2.0 uses the SHA-2 0.10 line" },
]
[licenses] [licenses]
allow = [ allow = [

View file

@ -869,6 +869,22 @@ classDiagram
Host Coordinator. It is not a distinct Host Coordinator principal. Physical Host Coordinator. It is not a distinct Host Coordinator principal. Physical
encoding, signing, size, and validation rules are defined by the specification. encoding, signing, size, and validation rules are defined by the specification.
For embedded signed claims, the field order shown above is the Postcard field
order. A protocol credential ID is SHA-256 over the minimally encoded claim
fields excluding `credential_id` and `issuer_signature`. A Session Request
Capability ID is SHA-256 over the minimally encoded claim fields excluding
`capability_id` and `coordinator_signature`. A Trust Manifest digest is SHA-256
over the complete minimally encoded manifest, including its issuer signature.
The manifest issuer signs its minimally encoded fields excluding
`issuer_signature`.
Every signature input is the ASCII `lazaret/v1/` prefix plus the lower-kebab
record name, followed by the unsigned 64-bit big-endian payload length and the
exact payload bytes. Mailbox signatures use the bytes carried in
`SignedProtocolRecord`; verification occurs before decoding those payload bytes.
Result stream digests are SHA-256 over the ordered concatenation of bytes for
that stream.
### Participant ownership ### Participant ownership
```mermaid ```mermaid