Implement generation-bound protocol cryptography

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

View file

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

View file

@ -3,6 +3,7 @@
pub mod codec;
pub mod configuration;
pub mod identity;
pub mod protocol;
pub mod records;
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 { .. })
));
}