Implement generation-bound protocol cryptography
This commit is contained in:
parent
fba5471352
commit
af8669c165
1 changed files with 1882 additions and 9 deletions
494
crates/lazaret-core/tests/protocol.rs
Normal file
494
crates/lazaret-core/tests/protocol.rs
Normal 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 { .. })
|
||||
));
|
||||
}
|
||||
Loading…
Reference in a new issue