1335 lines
47 KiB
Rust
1335 lines
47 KiB
Rust
use std::io::{Read, Write};
|
|
|
|
use aes_gcm::aead::{Aead, Payload};
|
|
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::STANDARD;
|
|
use cultnet_rs::{
|
|
CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice,
|
|
encode_cultnet_message_to_vec,
|
|
};
|
|
use rand_core::{OsRng, RngCore};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::contract::{
|
|
ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult,
|
|
canonical_invocation_bytes, require_id, wire_decode, wire_encode,
|
|
};
|
|
use crate::readout::SpendReadout;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
pub enum EnvelopeKind {
|
|
Invocation,
|
|
Result,
|
|
SpendReadoutRequest,
|
|
SpendReadoutResult,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct TransportEnvelope {
|
|
pub schema_id: String,
|
|
pub caller_runtime_id: String,
|
|
pub request_id: String,
|
|
pub message_kind: EnvelopeKind,
|
|
pub nonce: [u8; 12],
|
|
#[serde(with = "serde_bytes")]
|
|
#[schemars(schema_with = "binary_schema")]
|
|
pub ciphertext: Vec<u8>,
|
|
}
|
|
|
|
/// Raw bytes are MessagePack `bin` on the wire, never an array of integers.
|
|
/// Serves the ciphertext and both reasoning states.
|
|
pub(crate) fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
|
schemars::json_schema!({
|
|
"type": "string",
|
|
"format": "binary",
|
|
"description": "MessagePack bin: raw bytes, never an array of integers"
|
|
})
|
|
}
|
|
|
|
/// The CultNet service every frame of this connector addresses.
|
|
pub const CONNECTOR_SERVICE_ID: &str = "gamecult.model.connector";
|
|
/// The operation that runs one sealed invocation.
|
|
pub const INVOKE_OPERATION: &str = "invoke";
|
|
/// The operation that reads the spend ledger and the plan's quota.
|
|
pub const SPEND_READOUT_OPERATION: &str = "spend_readout";
|
|
|
|
const PAYLOAD_ENCODING: &str = "messagepack-base64";
|
|
const OK_STATUS: &str = "ok";
|
|
|
|
/// The longest caller id, request id or model name a service admits. The
|
|
/// frame arithmetic below depends on it. A request whose id is longer is
|
|
/// refused on the wire as malformed.
|
|
pub const MAX_IDENTITY_BYTES: usize = 256;
|
|
|
|
fn carriage_bytes(message: &CultNetMessage) -> Result<Vec<u8>, ServiceError> {
|
|
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)
|
|
.map_err(|_| ServiceError::Carriage)
|
|
}
|
|
|
|
fn seal_payload(envelope: &TransportEnvelope) -> Result<String, ServiceError> {
|
|
Ok(STANDARD.encode(wire_encode(envelope)?))
|
|
}
|
|
|
|
/// The two operations of the connector service.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum Operation {
|
|
Invoke,
|
|
SpendReadout,
|
|
}
|
|
|
|
impl Operation {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Operation::Invoke => INVOKE_OPERATION,
|
|
Operation::SpendReadout => SPEND_READOUT_OPERATION,
|
|
}
|
|
}
|
|
|
|
fn from_name(name: &str) -> Option<Self> {
|
|
[Operation::Invoke, Operation::SpendReadout]
|
|
.into_iter()
|
|
.find(|operation| operation.name() == name)
|
|
}
|
|
}
|
|
|
|
/// The CultNet frame a request travels in: a `cultnet.operation_request.v0`
|
|
/// for [`CONNECTOR_SERVICE_ID`] and the named operation, its `message_id` the
|
|
/// request id and its payload the base64 of the named sealed envelope.
|
|
fn request_frame(
|
|
operation: Operation,
|
|
envelope: &TransportEnvelope,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
carriage_bytes(&CultNetMessage::OperationRequest {
|
|
message_id: envelope.request_id.clone(),
|
|
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
|
operation: operation.name().to_string(),
|
|
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
|
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
|
payload: seal_payload(envelope)?,
|
|
source_runtime_id: None,
|
|
target_runtime_id: None,
|
|
})
|
|
}
|
|
|
|
/// The CultNet frame a response travels in: a `cultnet.operation_response.v0`
|
|
/// with status `ok`, otherwise shaped like the request's.
|
|
#[cfg(any(feature = "daemon", test))]
|
|
fn response_frame(
|
|
operation: Operation,
|
|
envelope: &TransportEnvelope,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
carriage_bytes(&CultNetMessage::OperationResponse {
|
|
message_id: envelope.request_id.clone(),
|
|
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
|
operation: operation.name().to_string(),
|
|
status: OK_STATUS.to_string(),
|
|
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
|
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
|
payload: seal_payload(envelope)?,
|
|
diagnostics: Vec::new(),
|
|
source_runtime_id: None,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn encode_invocation_frame(
|
|
envelope: &TransportEnvelope,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
request_frame(Operation::Invoke, envelope)
|
|
}
|
|
|
|
pub(crate) fn encode_spend_readout_request_frame(
|
|
envelope: &TransportEnvelope,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
request_frame(Operation::SpendReadout, envelope)
|
|
}
|
|
|
|
#[cfg(any(feature = "daemon", test))]
|
|
pub(crate) fn encode_result_frame(envelope: &TransportEnvelope) -> Result<Vec<u8>, ServiceError> {
|
|
response_frame(Operation::Invoke, envelope)
|
|
}
|
|
|
|
#[cfg(feature = "daemon")]
|
|
pub(crate) fn encode_spend_readout_response_frame(
|
|
envelope: &TransportEnvelope,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
response_frame(Operation::SpendReadout, envelope)
|
|
}
|
|
|
|
/// Opens the carriage of one frame. Everything the carriage says is checked
|
|
/// here, before any decryption: the service, that the operation is one this
|
|
/// service has, the payload schema, and that the message id is the sealed
|
|
/// envelope's request id.
|
|
fn open_carriage(
|
|
message_id: &str,
|
|
service_id: &str,
|
|
operation: &str,
|
|
payload_schema: &str,
|
|
payload: &str,
|
|
) -> Result<(Operation, TransportEnvelope), ServiceError> {
|
|
let operation = Operation::from_name(operation).ok_or(ServiceError::Carriage)?;
|
|
if service_id != CONNECTOR_SERVICE_ID || payload_schema != ENVELOPE_SCHEMA_ID {
|
|
return Err(ServiceError::Carriage);
|
|
}
|
|
let bytes = STANDARD
|
|
.decode(payload)
|
|
.map_err(|_| ServiceError::Carriage)?;
|
|
let envelope: TransportEnvelope = wire_decode(&bytes).map_err(|_| ServiceError::Encoding)?;
|
|
if envelope.request_id != message_id {
|
|
return Err(ServiceError::OuterIdentity);
|
|
}
|
|
Ok((operation, envelope))
|
|
}
|
|
|
|
fn read_carriage(bytes: &[u8]) -> Result<CultNetMessage, ServiceError> {
|
|
decode_cultnet_message_from_slice(bytes, CultNetWireContract::CultNetSchemaV0)
|
|
.map_err(|_| ServiceError::Carriage)
|
|
}
|
|
|
|
/// A request frame, by the operation it names.
|
|
#[cfg(any(feature = "daemon", test))]
|
|
pub(crate) enum RequestFrame {
|
|
Invoke(TransportEnvelope),
|
|
SpendReadout(TransportEnvelope),
|
|
}
|
|
|
|
#[cfg(any(feature = "daemon", test))]
|
|
pub(crate) fn decode_request_frame(bytes: &[u8]) -> Result<RequestFrame, ServiceError> {
|
|
match read_carriage(bytes)? {
|
|
CultNetMessage::OperationRequest {
|
|
message_id,
|
|
service_id,
|
|
operation,
|
|
payload_schema,
|
|
payload,
|
|
..
|
|
} => {
|
|
match open_carriage(
|
|
&message_id,
|
|
&service_id,
|
|
&operation,
|
|
&payload_schema,
|
|
&payload,
|
|
)? {
|
|
(Operation::Invoke, envelope) => Ok(RequestFrame::Invoke(envelope)),
|
|
(Operation::SpendReadout, envelope) => Ok(RequestFrame::SpendReadout(envelope)),
|
|
}
|
|
}
|
|
_ => Err(ServiceError::Carriage),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn decode_invocation_frame(bytes: &[u8]) -> Result<TransportEnvelope, ServiceError> {
|
|
match decode_request_frame(bytes)? {
|
|
RequestFrame::Invoke(envelope) => Ok(envelope),
|
|
RequestFrame::SpendReadout(_) => Err(ServiceError::Carriage),
|
|
}
|
|
}
|
|
|
|
fn decode_response_frame(
|
|
bytes: &[u8],
|
|
expected: Operation,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
match read_carriage(bytes)? {
|
|
CultNetMessage::OperationResponse {
|
|
message_id,
|
|
service_id,
|
|
operation,
|
|
status,
|
|
payload_schema,
|
|
payload,
|
|
..
|
|
} if status == OK_STATUS => {
|
|
match open_carriage(
|
|
&message_id,
|
|
&service_id,
|
|
&operation,
|
|
&payload_schema,
|
|
&payload,
|
|
)? {
|
|
(found, envelope) if found == expected => Ok(envelope),
|
|
_ => Err(ServiceError::Carriage),
|
|
}
|
|
}
|
|
_ => Err(ServiceError::Carriage),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn decode_result_frame(bytes: &[u8]) -> Result<TransportEnvelope, ServiceError> {
|
|
decode_response_frame(bytes, Operation::Invoke)
|
|
}
|
|
|
|
pub(crate) fn decode_spend_readout_response_frame(
|
|
bytes: &[u8],
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
decode_response_frame(bytes, Operation::SpendReadout)
|
|
}
|
|
|
|
/// The most bytes one frame holding a sealed envelope of `ciphertext_len`
|
|
/// ciphertext bytes can occupy, with ids at their longest: the frame a caller
|
|
/// payload bound (`CallerAdmission`'s `max_payload_bytes`, a bound on
|
|
/// ciphertext length) needs.
|
|
///
|
|
/// A frame is a CultNet operation message whose payload is the base64 of the
|
|
/// named envelope. The result frame is the larger of the two directions (it
|
|
/// also carries a status), so this bound holds for an invocation as well. The
|
|
/// envelope shell and the message shell are measured from their real
|
|
/// encodings; only the MessagePack length headers and base64 expansion that
|
|
/// grow with `ciphertext_len` are arithmetic.
|
|
pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize {
|
|
let long = "x".repeat(MAX_IDENTITY_BYTES);
|
|
let envelope_shell = wire_encode(&TransportEnvelope {
|
|
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
|
caller_runtime_id: long.clone(),
|
|
request_id: long.clone(),
|
|
message_kind: EnvelopeKind::Invocation,
|
|
nonce: [0xff; 12],
|
|
ciphertext: Vec::new(),
|
|
})
|
|
.map_or(usize::MAX, |bytes| bytes.len());
|
|
let message_shell = encode_cultnet_message_to_vec(
|
|
&CultNetMessage::OperationResponse {
|
|
message_id: long,
|
|
service_id: CONNECTOR_SERVICE_ID.to_string(),
|
|
operation: INVOKE_OPERATION.to_string(),
|
|
status: OK_STATUS.to_string(),
|
|
payload_schema: ENVELOPE_SCHEMA_ID.to_string(),
|
|
payload_encoding: PAYLOAD_ENCODING.to_string(),
|
|
payload: "A".to_string(),
|
|
diagnostics: Vec::new(),
|
|
source_runtime_id: None,
|
|
},
|
|
CultNetWireContract::CultNetSchemaV0,
|
|
)
|
|
.map_or(usize::MAX, |bytes| bytes.len());
|
|
// The shell holds an empty `bin 8`: a marker and a zero length. The shell's
|
|
// one-character payload takes a one-byte string header and the character.
|
|
let envelope = envelope_shell
|
|
.saturating_sub(2)
|
|
.saturating_add(bin_header_bytes(ciphertext_len))
|
|
.saturating_add(ciphertext_len);
|
|
let payload = envelope.div_ceil(3).saturating_mul(4);
|
|
message_shell
|
|
.saturating_sub(2)
|
|
.saturating_add(str_header_bytes(payload))
|
|
.saturating_add(payload)
|
|
}
|
|
|
|
/// MessagePack `bin` header width for a byte string of `len` bytes.
|
|
fn bin_header_bytes(len: usize) -> usize {
|
|
match len {
|
|
0..=255 => 2,
|
|
256..=65_535 => 3,
|
|
_ => 5,
|
|
}
|
|
}
|
|
|
|
/// MessagePack `str` header width for the base64 payload. The payload always
|
|
/// exceeds 255 bytes: the envelope it encodes holds two ids of at least
|
|
/// `MAX_IDENTITY_BYTES` in the worst case this bound measures.
|
|
fn str_header_bytes(len: usize) -> usize {
|
|
if len <= 65_535 { 3 } else { 5 }
|
|
}
|
|
|
|
/// The largest payload bound whose worst-case frame fits `max_frame_bytes`:
|
|
/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot
|
|
/// carry even an empty envelope.
|
|
pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option<usize> {
|
|
if frame_bytes_for_payload(0) > max_frame_bytes {
|
|
return None;
|
|
}
|
|
// A frame is longer than its payload, so the answer is below the bound.
|
|
let (mut fits, mut over) = (0, max_frame_bytes.saturating_add(1));
|
|
while over - fits > 1 {
|
|
let middle = fits + (over - fits) / 2;
|
|
if frame_bytes_for_payload(middle) <= max_frame_bytes {
|
|
fits = middle;
|
|
} else {
|
|
over = middle;
|
|
}
|
|
}
|
|
Some(fits)
|
|
}
|
|
|
|
pub fn encrypt_invocation(
|
|
invocation: &TransportInvocation,
|
|
security: &TransportKey,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
let plaintext = canonical_invocation_bytes(invocation)?;
|
|
encrypt_message(
|
|
&invocation.caller_runtime_id,
|
|
invocation.request_id(),
|
|
EnvelopeKind::Invocation,
|
|
&plaintext,
|
|
security,
|
|
)
|
|
}
|
|
|
|
pub fn decrypt_invocation(
|
|
envelope: &TransportEnvelope,
|
|
security: &TransportKey,
|
|
) -> Result<TransportInvocation, ServiceError> {
|
|
validate_envelope(envelope, EnvelopeKind::Invocation)?;
|
|
let plaintext = decrypt_message(envelope, security)?;
|
|
let invocation: TransportInvocation =
|
|
wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
|
if invocation.caller_runtime_id != envelope.caller_runtime_id
|
|
|| invocation.request_id() != envelope.request_id
|
|
{
|
|
return Err(ServiceError::OuterIdentity);
|
|
}
|
|
Ok(invocation)
|
|
}
|
|
|
|
pub fn encrypt_result(
|
|
result: &TransportResult,
|
|
security: &TransportKey,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
let plaintext = wire_encode(result).map_err(|_| ServiceError::Encoding)?;
|
|
encrypt_message(
|
|
&result.caller_runtime_id,
|
|
&result.request_id,
|
|
EnvelopeKind::Result,
|
|
&plaintext,
|
|
security,
|
|
)
|
|
}
|
|
|
|
pub fn decrypt_result(
|
|
envelope: &TransportEnvelope,
|
|
security: &TransportKey,
|
|
invocation: &TransportInvocation,
|
|
) -> Result<TransportResult, ServiceError> {
|
|
validate_envelope(envelope, EnvelopeKind::Result)?;
|
|
let plaintext = decrypt_message(envelope, security)?;
|
|
let result: TransportResult = wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
|
if result.caller_runtime_id != envelope.caller_runtime_id
|
|
|| result.request_id != envelope.request_id
|
|
{
|
|
return Err(ServiceError::OuterIdentity);
|
|
}
|
|
result.validate_against(invocation)?;
|
|
Ok(result)
|
|
}
|
|
|
|
/// A spend readout request carries no content: the sealed envelope's
|
|
/// authenticated identity and kind are the whole request.
|
|
pub(crate) fn encrypt_spend_readout_request(
|
|
caller_runtime_id: &str,
|
|
request_id: &str,
|
|
security: &TransportKey,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
encrypt_message(
|
|
caller_runtime_id,
|
|
request_id,
|
|
EnvelopeKind::SpendReadoutRequest,
|
|
&[],
|
|
security,
|
|
)
|
|
}
|
|
|
|
#[cfg(any(feature = "daemon", test))]
|
|
pub(crate) fn decrypt_spend_readout_request(
|
|
envelope: &TransportEnvelope,
|
|
security: &TransportKey,
|
|
) -> Result<(), ServiceError> {
|
|
validate_envelope(envelope, EnvelopeKind::SpendReadoutRequest)?;
|
|
decrypt_message(envelope, security).map(drop)
|
|
}
|
|
|
|
#[cfg(feature = "daemon")]
|
|
pub(crate) fn encrypt_spend_readout(
|
|
caller_runtime_id: &str,
|
|
request_id: &str,
|
|
readout: &SpendReadout,
|
|
security: &TransportKey,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
let plaintext = wire_encode(readout).map_err(|_| ServiceError::Encoding)?;
|
|
encrypt_message(
|
|
caller_runtime_id,
|
|
request_id,
|
|
EnvelopeKind::SpendReadoutResult,
|
|
&plaintext,
|
|
security,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn decrypt_spend_readout(
|
|
envelope: &TransportEnvelope,
|
|
security: &TransportKey,
|
|
) -> Result<SpendReadout, ServiceError> {
|
|
validate_envelope(envelope, EnvelopeKind::SpendReadoutResult)?;
|
|
let plaintext = decrypt_message(envelope, security)?;
|
|
let readout: SpendReadout = wire_decode(&plaintext).map_err(|_| ServiceError::Encoding)?;
|
|
readout.validate()?;
|
|
Ok(readout)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum TransportFrameError {
|
|
Connection(std::io::Error),
|
|
Size,
|
|
}
|
|
|
|
pub(crate) fn read_transport_frame(
|
|
reader: &mut impl Read,
|
|
max_frame_bytes: usize,
|
|
) -> Result<Vec<u8>, TransportFrameError> {
|
|
let mut length = [0_u8; 4];
|
|
reader
|
|
.read_exact(&mut length)
|
|
.map_err(TransportFrameError::Connection)?;
|
|
let length = u32::from_be_bytes(length) as usize;
|
|
if length == 0 || length > max_frame_bytes {
|
|
return Err(TransportFrameError::Size);
|
|
}
|
|
let mut payload = vec![0_u8; length];
|
|
reader
|
|
.read_exact(&mut payload)
|
|
.map_err(TransportFrameError::Connection)?;
|
|
Ok(payload)
|
|
}
|
|
|
|
pub(crate) fn write_transport_frame(
|
|
writer: &mut impl Write,
|
|
payload: &[u8],
|
|
max_frame_bytes: usize,
|
|
) -> Result<(), TransportFrameError> {
|
|
if payload.is_empty() || payload.len() > max_frame_bytes || payload.len() > u32::MAX as usize {
|
|
return Err(TransportFrameError::Size);
|
|
}
|
|
writer
|
|
.write_all(&(payload.len() as u32).to_be_bytes())
|
|
.and_then(|()| writer.write_all(payload))
|
|
.and_then(|()| writer.flush())
|
|
.map_err(TransportFrameError::Connection)
|
|
}
|
|
|
|
pub(crate) fn encrypt_message(
|
|
caller_runtime_id: &str,
|
|
request_id: &str,
|
|
message_kind: EnvelopeKind,
|
|
plaintext: &[u8],
|
|
security: &TransportKey,
|
|
) -> Result<TransportEnvelope, ServiceError> {
|
|
require_id(caller_runtime_id, "caller_runtime_id")?;
|
|
require_id(request_id, "request_id")?;
|
|
let mut nonce = [0; 12];
|
|
OsRng.fill_bytes(&mut nonce);
|
|
let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?;
|
|
let aad = envelope_aad(
|
|
ENVELOPE_SCHEMA_ID,
|
|
caller_runtime_id,
|
|
request_id,
|
|
message_kind,
|
|
)?;
|
|
let ciphertext = cipher
|
|
.encrypt(
|
|
Nonce::from_slice(&nonce),
|
|
Payload {
|
|
msg: plaintext,
|
|
aad: &aad,
|
|
},
|
|
)
|
|
.map_err(|_| ServiceError::Encryption)?;
|
|
Ok(TransportEnvelope {
|
|
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
|
caller_runtime_id: caller_runtime_id.to_string(),
|
|
request_id: request_id.to_string(),
|
|
message_kind,
|
|
nonce,
|
|
ciphertext,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn decrypt_message(
|
|
envelope: &TransportEnvelope,
|
|
security: &TransportKey,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?;
|
|
let aad = envelope_aad(
|
|
&envelope.schema_id,
|
|
&envelope.caller_runtime_id,
|
|
&envelope.request_id,
|
|
envelope.message_kind,
|
|
)?;
|
|
cipher
|
|
.decrypt(
|
|
Nonce::from_slice(&envelope.nonce),
|
|
Payload {
|
|
msg: &envelope.ciphertext,
|
|
aad: &aad,
|
|
},
|
|
)
|
|
.map_err(|_| ServiceError::Encryption)
|
|
}
|
|
|
|
/// The authenticated data of a sealed message: a positional tuple, a MAC input
|
|
/// and not a wire document (a tuple is positional under the named codec too).
|
|
pub(crate) fn envelope_aad(
|
|
schema_id: &str,
|
|
caller_runtime_id: &str,
|
|
request_id: &str,
|
|
message_kind: EnvelopeKind,
|
|
) -> Result<Vec<u8>, ServiceError> {
|
|
wire_encode(&(schema_id, caller_runtime_id, request_id, message_kind))
|
|
.map_err(|_| ServiceError::Encoding)
|
|
}
|
|
|
|
pub(crate) fn validate_envelope(
|
|
envelope: &TransportEnvelope,
|
|
expected_kind: EnvelopeKind,
|
|
) -> Result<(), ServiceError> {
|
|
if envelope.schema_id != ENVELOPE_SCHEMA_ID || envelope.message_kind != expected_kind {
|
|
return Err(ServiceError::Envelope);
|
|
}
|
|
require_id(&envelope.caller_runtime_id, "caller_runtime_id")?;
|
|
require_id(&envelope.request_id, "request_id")?;
|
|
if envelope.ciphertext.is_empty() {
|
|
return Err(ServiceError::Envelope);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum ServiceError {
|
|
#[error(transparent)]
|
|
Contract(#[from] ContractError),
|
|
#[error("invalid caller admission")]
|
|
InvalidAdmission,
|
|
#[error("caller runtime was admitted twice")]
|
|
DuplicateCaller,
|
|
#[error("callers must not share transport keys")]
|
|
SharedCallerKey,
|
|
#[error("connector replay store failed: {0}")]
|
|
ReplayStore(String),
|
|
#[error("an allowed model has no price: {0}")]
|
|
UnpricedModel(String),
|
|
#[error("connector replay record is malformed")]
|
|
InvalidReplayRecord,
|
|
#[error("caller key changed while durable replay records still exist")]
|
|
ReplayCallerKeyMismatch,
|
|
#[error("caller runtime is not admitted")]
|
|
CallerNotAdmitted,
|
|
#[error("unexpected transport envelope")]
|
|
Envelope,
|
|
#[error("transport envelope identity substitution")]
|
|
OuterIdentity,
|
|
#[error("transport encryption or authentication failed")]
|
|
Encryption,
|
|
#[error("transport encoding failed")]
|
|
Encoding,
|
|
#[error("frame is not a connector operation message")]
|
|
Carriage,
|
|
#[error("completion did not carry a transported provider outcome")]
|
|
InvalidCompletion,
|
|
#[error("completion has no active request")]
|
|
NoActiveRequest,
|
|
#[error("completion does not match the active request")]
|
|
ActiveRequestMismatch,
|
|
#[error("sealed result does not fit the frame bound")]
|
|
FrameBound,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fixtures::*;
|
|
|
|
#[test]
|
|
fn encrypted_invocation_hides_content_and_binds_outer_identity() {
|
|
let invocation = invocation();
|
|
let epiphany_key = security("epiphany-distinct-test-key");
|
|
let envelope = encrypt_invocation(&invocation, &epiphany_key).unwrap();
|
|
assert!(
|
|
!envelope
|
|
.ciphertext
|
|
.windows("Projected state".len())
|
|
.any(|window| window == b"Projected state")
|
|
);
|
|
assert_eq!(
|
|
decrypt_invocation(&envelope, &epiphany_key).unwrap(),
|
|
invocation
|
|
);
|
|
assert_eq!(
|
|
decrypt_invocation(&envelope, &security("wrong-caller-key")),
|
|
Err(ServiceError::Encryption)
|
|
);
|
|
|
|
let mut substituted = envelope;
|
|
substituted.request_id = "request-2".to_string();
|
|
assert_eq!(
|
|
decrypt_invocation(&substituted, &epiphany_key),
|
|
Err(ServiceError::Encryption)
|
|
);
|
|
}
|
|
|
|
fn sealed_invocation(caller: &str, request_id: &str, kind: EnvelopeKind) -> TransportEnvelope {
|
|
let invocation = invocation();
|
|
let plaintext = canonical_invocation_bytes(&invocation).unwrap();
|
|
encrypt_message(
|
|
caller,
|
|
request_id,
|
|
kind,
|
|
&plaintext,
|
|
&security("epiphany-distinct-test-key"),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_outer_identity_must_match_the_sealed_invocation_on_each_field() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
let invocation = invocation();
|
|
let caller = invocation.caller_runtime_id.clone();
|
|
let request_id = invocation.request_id().to_string();
|
|
let matching = sealed_invocation(&caller, &request_id, EnvelopeKind::Invocation);
|
|
assert_eq!(decrypt_invocation(&matching, &key).unwrap(), invocation);
|
|
for envelope in [
|
|
sealed_invocation("other-caller", &request_id, EnvelopeKind::Invocation),
|
|
sealed_invocation(&caller, "other-request", EnvelopeKind::Invocation),
|
|
] {
|
|
assert_eq!(
|
|
decrypt_invocation(&envelope, &key),
|
|
Err(ServiceError::OuterIdentity)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_outer_identity_must_match_the_sealed_result_on_each_field() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
let invocation = invocation();
|
|
let result = TransportResult::transported(Vec::new(), receipt(&invocation));
|
|
let plaintext = wire_encode(&result).unwrap();
|
|
let seal = |caller: &str, request_id: &str| {
|
|
encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap()
|
|
};
|
|
let caller = result.caller_runtime_id.clone();
|
|
let request_id = result.request_id.clone();
|
|
assert_eq!(
|
|
decrypt_result(&seal(&caller, &request_id), &key, &invocation).unwrap(),
|
|
result
|
|
);
|
|
for envelope in [
|
|
seal("other-caller", &request_id),
|
|
seal(&caller, "other-request"),
|
|
] {
|
|
assert_eq!(
|
|
decrypt_result(&envelope, &key, &invocation),
|
|
Err(ServiceError::OuterIdentity)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_envelope_is_refused_before_any_decryption() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
let invocation = invocation();
|
|
let good = encrypt_invocation(&invocation, &key).unwrap();
|
|
|
|
let mut schema = good.clone();
|
|
schema.schema_id = concat!("gamecult.", "codex.transport_envelope.v2").to_string();
|
|
let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result);
|
|
let mut empty = good.clone();
|
|
empty.ciphertext.clear();
|
|
for envelope in [schema, wrong_kind, empty] {
|
|
assert_eq!(
|
|
decrypt_invocation(&envelope, &key),
|
|
Err(ServiceError::Envelope)
|
|
);
|
|
}
|
|
for field in ["caller_runtime_id", "request_id"] {
|
|
let mut blank = good.clone();
|
|
if field == "caller_runtime_id" {
|
|
blank.caller_runtime_id = " ".to_string();
|
|
} else {
|
|
blank.request_id = String::new();
|
|
}
|
|
assert_eq!(
|
|
decrypt_invocation(&blank, &key),
|
|
Err(ServiceError::Contract(ContractError::Invalid(field)))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_is_bounded_inclusively_and_never_empty() {
|
|
use std::io::Cursor;
|
|
let read = |length: u32, body: usize, max: usize| {
|
|
let mut frame = length.to_be_bytes().to_vec();
|
|
frame.extend(vec![1; body]);
|
|
read_transport_frame(&mut Cursor::new(frame), max)
|
|
};
|
|
assert_eq!(read(16, 16, 16).unwrap(), vec![1; 16]);
|
|
assert!(matches!(read(17, 17, 16), Err(TransportFrameError::Size)));
|
|
assert!(matches!(read(0, 0, 16), Err(TransportFrameError::Size)));
|
|
// A bound above the length never turns an empty frame into an empty payload.
|
|
assert!(matches!(read(0, 0, 1_000), Err(TransportFrameError::Size)));
|
|
|
|
let write = |payload: &[u8], max: usize| {
|
|
let mut out = Vec::new();
|
|
write_transport_frame(&mut out, payload, max).map(|()| out)
|
|
};
|
|
assert_eq!(write(&[1; 16], 16).unwrap().len(), 20);
|
|
assert!(matches!(
|
|
write(&[1; 17], 16),
|
|
Err(TransportFrameError::Size)
|
|
));
|
|
assert!(matches!(write(&[], 16), Err(TransportFrameError::Size)));
|
|
assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size)));
|
|
}
|
|
|
|
/// The real result frame for an envelope with ids at the longest admitted
|
|
/// and a ciphertext of `length` bytes.
|
|
fn real_worst_frame(length: usize) -> Vec<u8> {
|
|
let long = "x".repeat(MAX_IDENTITY_BYTES);
|
|
encode_result_frame(&TransportEnvelope {
|
|
schema_id: ENVELOPE_SCHEMA_ID.to_string(),
|
|
caller_runtime_id: long.clone(),
|
|
request_id: long,
|
|
message_kind: EnvelopeKind::Invocation,
|
|
nonce: [0xff; 12],
|
|
ciphertext: vec![0xff; length],
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
/// Ciphertext sizes either side of every length-header boundary the frame
|
|
/// crosses: bin 8 to 16 to 32, str 8 to 16 to 32, and each base64 padding.
|
|
fn boundary_sizes() -> Vec<usize> {
|
|
let mut sizes: Vec<usize> = (0..=40).collect();
|
|
for center in [
|
|
100, 200, 250, 256, 300, 1000, 3000, 65_535, 65_536, 49_000, 49_100, 49_152, 49_200,
|
|
70_000,
|
|
] {
|
|
for delta in 0..=40 {
|
|
sizes.push(center + delta);
|
|
sizes.push(center.saturating_sub(delta));
|
|
}
|
|
}
|
|
sizes.sort_unstable();
|
|
sizes.dedup();
|
|
sizes
|
|
}
|
|
|
|
#[test]
|
|
fn the_frame_bound_is_the_real_carriage_encoding() {
|
|
for length in boundary_sizes() {
|
|
assert_eq!(
|
|
real_worst_frame(length).len(),
|
|
frame_bytes_for_payload(length),
|
|
"{length}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
for id in ["a".to_string(), "i".repeat(MAX_IDENTITY_BYTES)] {
|
|
for plaintext in [1, 100, 5_000, 70_000] {
|
|
let sealed =
|
|
encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key)
|
|
.unwrap();
|
|
let worst = frame_bytes_for_payload(sealed.ciphertext.len());
|
|
for real in [
|
|
encode_result_frame(&sealed).unwrap().len(),
|
|
encode_invocation_frame(&sealed).unwrap().len(),
|
|
] {
|
|
assert!(real <= worst, "{real} > {worst}");
|
|
}
|
|
// The frame is the base64 of the envelope: its worst case is
|
|
// within the base64 expansion of the real one.
|
|
if id.len() == MAX_IDENTITY_BYTES {
|
|
let real = encode_result_frame(&sealed).unwrap().len();
|
|
assert!(worst <= real + 32, "{worst} {real}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_payload_bound_a_frame_allows_is_the_largest_payload_that_fits() {
|
|
// The frame grows in base64 groups of three bytes, so several payloads
|
|
// share a frame: the inverse is the largest of them.
|
|
assert_eq!(
|
|
payload_bytes_for_frame(frame_bytes_for_payload(0) - 1),
|
|
None
|
|
);
|
|
for bytes in boundary_sizes().into_iter().chain([2_000_000]) {
|
|
let frame = frame_bytes_for_payload(bytes);
|
|
let allowed = payload_bytes_for_frame(frame).unwrap();
|
|
assert!(allowed >= bytes && allowed - bytes < 3, "{bytes} {allowed}");
|
|
assert_eq!(frame_bytes_for_payload(allowed), frame, "{bytes}");
|
|
assert!(frame_bytes_for_payload(allowed + 1) > frame, "{bytes}");
|
|
assert!(
|
|
payload_bytes_for_frame(frame - 1).is_none_or(|smaller| smaller < bytes),
|
|
"{bytes}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ciphertext_travels_as_binary() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
for plaintext in [10, 300, 70_000] {
|
|
let sealed = encrypt_message(
|
|
"caller",
|
|
"request",
|
|
EnvelopeKind::Invocation,
|
|
&vec![0x41; plaintext],
|
|
&key,
|
|
)
|
|
.unwrap();
|
|
let length = sealed.ciphertext.len();
|
|
let mut expected = match length {
|
|
0..=255 => vec![0xc4, length as u8],
|
|
256..=65_535 => [&[0xc5][..], &(length as u16).to_be_bytes()].concat(),
|
|
_ => [&[0xc6][..], &(length as u32).to_be_bytes()].concat(),
|
|
};
|
|
expected.extend(&sealed.ciphertext);
|
|
let encoded = wire_encode(&sealed).unwrap();
|
|
assert!(
|
|
encoded
|
|
.windows(expected.len())
|
|
.any(|window| window == expected),
|
|
"{plaintext}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn sealed_for_carriage() -> TransportEnvelope {
|
|
encrypt_invocation(&invocation(), &security("epiphany-distinct-test-key")).unwrap()
|
|
}
|
|
|
|
fn request_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec<u8> {
|
|
let envelope = sealed_for_carriage();
|
|
let mut message = decode_cultnet_message_from_slice(
|
|
&encode_invocation_frame(&envelope).unwrap(),
|
|
CultNetWireContract::CultNetSchemaV0,
|
|
)
|
|
.unwrap();
|
|
edit(&mut message);
|
|
carriage_bytes(&message).unwrap()
|
|
}
|
|
|
|
fn response_message(edit: impl FnOnce(&mut CultNetMessage)) -> Vec<u8> {
|
|
let envelope = sealed_for_carriage();
|
|
let mut message = decode_cultnet_message_from_slice(
|
|
&encode_result_frame(&envelope).unwrap(),
|
|
CultNetWireContract::CultNetSchemaV0,
|
|
)
|
|
.unwrap();
|
|
edit(&mut message);
|
|
carriage_bytes(&message).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_carriage_round_trips_both_directions() {
|
|
let envelope = sealed_for_carriage();
|
|
assert_eq!(
|
|
decode_invocation_frame(&encode_invocation_frame(&envelope).unwrap()).unwrap(),
|
|
envelope
|
|
);
|
|
assert_eq!(
|
|
decode_result_frame(&encode_result_frame(&envelope).unwrap()).unwrap(),
|
|
envelope
|
|
);
|
|
// A request is not a response, in either direction.
|
|
assert_eq!(
|
|
decode_result_frame(&encode_invocation_frame(&envelope).unwrap()),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
assert_eq!(
|
|
decode_invocation_frame(&encode_result_frame(&envelope).unwrap()),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_carriage_refuses_another_service_operation_or_schema() {
|
|
fn edit_request(field: &str, value: &str) -> Vec<u8> {
|
|
request_message(|message| {
|
|
if let CultNetMessage::OperationRequest {
|
|
service_id,
|
|
operation,
|
|
payload_schema,
|
|
..
|
|
} = message
|
|
{
|
|
match field {
|
|
"service" => *service_id = value.to_string(),
|
|
"operation" => *operation = value.to_string(),
|
|
_ => *payload_schema = value.to_string(),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn edit_response(field: &str, value: &str) -> Vec<u8> {
|
|
response_message(|message| {
|
|
if let CultNetMessage::OperationResponse {
|
|
service_id,
|
|
operation,
|
|
payload_schema,
|
|
status,
|
|
..
|
|
} = message
|
|
{
|
|
match field {
|
|
"service" => *service_id = value.to_string(),
|
|
"operation" => *operation = value.to_string(),
|
|
"status" => *status = value.to_string(),
|
|
_ => *payload_schema = value.to_string(),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
for (field, value) in [
|
|
("service", "gamecult.model.other"),
|
|
("operation", "cancel"),
|
|
("schema", "gamecult.model.transport_envelope.v2"),
|
|
] {
|
|
assert_eq!(
|
|
decode_invocation_frame(&edit_request(field, value)),
|
|
Err(ServiceError::Carriage),
|
|
"{field}"
|
|
);
|
|
assert_eq!(
|
|
decode_result_frame(&edit_response(field, value)),
|
|
Err(ServiceError::Carriage),
|
|
"{field}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
decode_result_frame(&edit_response("status", "rejected")),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
// The unedited message decodes, so each refusal above is the edit's.
|
|
assert!(decode_invocation_frame(&edit_request("service", CONNECTOR_SERVICE_ID)).is_ok());
|
|
assert!(decode_result_frame(&edit_response("status", "ok")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_carriage_refuses_a_message_id_that_is_not_the_envelopes_request_id() {
|
|
let request = request_message(|message| {
|
|
if let CultNetMessage::OperationRequest { message_id, .. } = message {
|
|
*message_id = "another-request".to_string();
|
|
}
|
|
});
|
|
let response = response_message(|message| {
|
|
if let CultNetMessage::OperationResponse { message_id, .. } = message {
|
|
*message_id = "another-request".to_string();
|
|
}
|
|
});
|
|
assert_eq!(
|
|
decode_invocation_frame(&request),
|
|
Err(ServiceError::OuterIdentity)
|
|
);
|
|
assert_eq!(
|
|
decode_result_frame(&response),
|
|
Err(ServiceError::OuterIdentity)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_carriage_refuses_what_is_not_a_connector_message() {
|
|
let good = encode_invocation_frame(&sealed_for_carriage()).unwrap();
|
|
// Not MessagePack of any CultNet message, nor a truncated one.
|
|
assert_eq!(
|
|
decode_invocation_frame(&[0xc1, 0x00, 0x01]),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
assert_eq!(
|
|
decode_invocation_frame(&good[..good.len() / 2]),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
// A payload that is not base64, and base64 of something else.
|
|
let not_base64 = request_message(|message| {
|
|
if let CultNetMessage::OperationRequest { payload, .. } = message {
|
|
*payload = "!!not base64!!".to_string();
|
|
}
|
|
});
|
|
assert_eq!(
|
|
decode_invocation_frame(¬_base64),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
let not_an_envelope = request_message(|message| {
|
|
if let CultNetMessage::OperationRequest { payload, .. } = message {
|
|
*payload = STANDARD.encode(b"\x01\x02");
|
|
}
|
|
});
|
|
assert_eq!(
|
|
decode_invocation_frame(¬_an_envelope),
|
|
Err(ServiceError::Encoding)
|
|
);
|
|
}
|
|
|
|
fn golden_frame() -> Vec<u8> {
|
|
std::fs::read(
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/wire_v3/carriage_invocation.frame"),
|
|
)
|
|
.expect("carriage_invocation.frame is committed")
|
|
}
|
|
|
|
#[test]
|
|
fn the_committed_golden_frame_is_what_the_kit_reads_and_writes() {
|
|
let frame = golden_frame();
|
|
let envelope = decode_invocation_frame(&frame).unwrap();
|
|
assert_eq!(encode_invocation_frame(&envelope).unwrap(), frame);
|
|
}
|
|
|
|
fn top_level(frame: &[u8]) -> std::collections::BTreeMap<String, rmpv::Value> {
|
|
let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &frame[..]).unwrap() else {
|
|
panic!("a CultNet frame is a map");
|
|
};
|
|
entries
|
|
.into_iter()
|
|
.map(|(key, value)| (key.as_str().unwrap().to_string(), value))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn the_frames_name_the_connector_service_and_its_one_operation_on_the_wire() {
|
|
let envelope = sealed_for_carriage();
|
|
let text = |map: &std::collections::BTreeMap<String, rmpv::Value>, key: &str| {
|
|
map[key].as_str().unwrap().to_string()
|
|
};
|
|
let request = top_level(&encode_invocation_frame(&envelope).unwrap());
|
|
assert_eq!(
|
|
text(&request, "schemaVersion"),
|
|
"cultnet.operation_request.v0"
|
|
);
|
|
assert_eq!(text(&request, "serviceId"), "gamecult.model.connector");
|
|
assert_eq!(text(&request, "operation"), "invoke");
|
|
assert_eq!(
|
|
text(&request, "payloadSchema"),
|
|
"gamecult.model.transport_envelope.v3"
|
|
);
|
|
assert_eq!(text(&request, "payloadEncoding"), "messagepack-base64");
|
|
assert_eq!(text(&request, "messageId"), envelope.request_id);
|
|
let response = top_level(&encode_result_frame(&envelope).unwrap());
|
|
assert_eq!(
|
|
text(&response, "schemaVersion"),
|
|
"cultnet.operation_response.v0"
|
|
);
|
|
assert_eq!(text(&response, "serviceId"), "gamecult.model.connector");
|
|
assert_eq!(text(&response, "operation"), "invoke");
|
|
assert_eq!(text(&response, "status"), "ok");
|
|
assert_eq!(
|
|
text(&response, "payloadSchema"),
|
|
"gamecult.model.transport_envelope.v3"
|
|
);
|
|
assert_eq!(text(&response, "payloadEncoding"), "messagepack-base64");
|
|
assert_eq!(text(&response, "messageId"), envelope.request_id);
|
|
}
|
|
|
|
fn value_of(bytes: &[u8]) -> rmpv::Value {
|
|
rmpv::decode::read_value(&mut &bytes[..]).unwrap()
|
|
}
|
|
|
|
fn bytes_of(value: &rmpv::Value) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
rmpv::encode::write_value(&mut out, value).unwrap();
|
|
out
|
|
}
|
|
|
|
fn with_entry(value: rmpv::Value, name: &str, entry: rmpv::Value) -> rmpv::Value {
|
|
let rmpv::Value::Map(mut entries) = value else {
|
|
panic!("a named document is a map");
|
|
};
|
|
let slot = entries
|
|
.iter_mut()
|
|
.find(|(key, _)| key.as_str() == Some(name))
|
|
.unwrap();
|
|
slot.1 = entry;
|
|
rmpv::Value::Map(entries)
|
|
}
|
|
|
|
fn reverse_maps(value: rmpv::Value) -> rmpv::Value {
|
|
match value {
|
|
rmpv::Value::Map(entries) => rmpv::Value::Map(
|
|
entries
|
|
.into_iter()
|
|
.rev()
|
|
.map(|(key, entry)| (key, reverse_maps(entry)))
|
|
.collect(),
|
|
),
|
|
rmpv::Value::Array(items) => {
|
|
rmpv::Value::Array(items.into_iter().map(reverse_maps).collect())
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn without_nils(value: rmpv::Value) -> rmpv::Value {
|
|
match value {
|
|
rmpv::Value::Map(entries) => rmpv::Value::Map(
|
|
entries
|
|
.into_iter()
|
|
.filter(|(_, entry)| !entry.is_nil())
|
|
.map(|(key, entry)| (key, without_nils(entry)))
|
|
.collect(),
|
|
),
|
|
rmpv::Value::Array(items) => {
|
|
rmpv::Value::Array(items.into_iter().map(without_nils).collect())
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
|
|
fn request_frame_carrying(payload: &[u8]) -> Vec<u8> {
|
|
request_message(|message| {
|
|
if let CultNetMessage::OperationRequest { payload: sent, .. } = message {
|
|
*sent = STANDARD.encode(payload);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn response_frame_carrying(payload: &[u8]) -> Vec<u8> {
|
|
response_message(|message| {
|
|
if let CultNetMessage::OperationResponse { payload: sent, .. } = message {
|
|
*sent = STANDARD.encode(payload);
|
|
}
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn the_carriage_refuses_every_dialect_the_wire_does_not_name() {
|
|
let envelope = sealed_for_carriage();
|
|
let named = value_of(&wire_encode(&envelope).unwrap());
|
|
let integer_ciphertext = rmpv::Value::Array(
|
|
envelope
|
|
.ciphertext
|
|
.iter()
|
|
.map(|byte| rmpv::Value::from(*byte))
|
|
.collect(),
|
|
);
|
|
let dialects = [
|
|
("positional envelope", rmp_serde::to_vec(&envelope).unwrap()),
|
|
(
|
|
"integer-array ciphertext",
|
|
bytes_of(&with_entry(named.clone(), "ciphertext", integer_ciphertext)),
|
|
),
|
|
(
|
|
"enum as its index",
|
|
bytes_of(&with_entry(named, "message_kind", 0.into())),
|
|
),
|
|
(
|
|
"bytes after the document",
|
|
[wire_encode(&envelope).unwrap(), vec![0xc0]].concat(),
|
|
),
|
|
];
|
|
for (dialect, payload) in &dialects {
|
|
assert_eq!(
|
|
decode_invocation_frame(&request_frame_carrying(payload)),
|
|
Err(ServiceError::Encoding),
|
|
"{dialect} on a request"
|
|
);
|
|
assert_eq!(
|
|
decode_result_frame(&response_frame_carrying(payload)),
|
|
Err(ServiceError::Encoding),
|
|
"{dialect} on a response"
|
|
);
|
|
}
|
|
// The named form itself is read, so the refusals above are the dialect's.
|
|
let named_bytes = wire_encode(&envelope).unwrap();
|
|
assert_eq!(
|
|
decode_invocation_frame(&request_frame_carrying(&named_bytes)),
|
|
Ok(envelope)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_sealed_positional_invocation_is_refused_after_decryption() {
|
|
let key = security("epiphany-distinct-test-key");
|
|
let invocation = invocation();
|
|
let sealed = encrypt_message(
|
|
&invocation.caller_runtime_id,
|
|
invocation.request_id(),
|
|
EnvelopeKind::Invocation,
|
|
&rmp_serde::to_vec(&invocation).unwrap(),
|
|
&key,
|
|
)
|
|
.unwrap();
|
|
let arrived = decode_invocation_frame(&encode_invocation_frame(&sealed).unwrap()).unwrap();
|
|
assert_eq!(
|
|
decrypt_invocation(&arrived, &key),
|
|
Err(ServiceError::Encoding)
|
|
);
|
|
let sealed = encrypt_invocation(&invocation, &key).unwrap();
|
|
assert_eq!(decrypt_invocation(&sealed, &key), Ok(invocation));
|
|
}
|
|
|
|
#[test]
|
|
fn named_maps_in_any_key_order_and_without_absent_options_are_the_named_form() {
|
|
let envelope = sealed_for_carriage();
|
|
let reversed = bytes_of(&reverse_maps(value_of(&wire_encode(&envelope).unwrap())));
|
|
assert_ne!(reversed, wire_encode(&envelope).unwrap());
|
|
assert_eq!(wire_decode::<TransportEnvelope>(&reversed), Ok(envelope));
|
|
let invocation = invocation();
|
|
let named = value_of(&wire_encode(&invocation).unwrap());
|
|
for edited in [reverse_maps(named.clone()), without_nils(named)] {
|
|
let bytes = bytes_of(&edited);
|
|
assert_ne!(bytes, wire_encode(&invocation).unwrap());
|
|
assert_eq!(
|
|
wire_decode::<TransportInvocation>(&bytes),
|
|
Ok(invocation.clone())
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_readout_request_is_not_an_invocation_and_names_its_own_operation() {
|
|
let key = TransportKey::from_connection_secret("readout-test-key").unwrap();
|
|
let sealed = encrypt_spend_readout_request("caller-1", "readout-1", &key).unwrap();
|
|
let frame = encode_spend_readout_request_frame(&sealed).unwrap();
|
|
assert!(matches!(
|
|
decode_request_frame(&frame),
|
|
Ok(RequestFrame::SpendReadout(found)) if found == sealed
|
|
));
|
|
assert_eq!(decode_invocation_frame(&frame), Err(ServiceError::Carriage));
|
|
// Sealed as one kind, the authenticated data refuses it as another.
|
|
assert!(decrypt_spend_readout_request(&sealed, &key).is_ok());
|
|
assert_eq!(
|
|
decrypt_spend_readout(&sealed, &key).unwrap_err(),
|
|
ServiceError::Envelope
|
|
);
|
|
let mut relabelled = sealed.clone();
|
|
relabelled.message_kind = EnvelopeKind::SpendReadoutResult;
|
|
assert_eq!(
|
|
decrypt_spend_readout(&relabelled, &key).unwrap_err(),
|
|
ServiceError::Encryption
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "daemon")]
|
|
#[test]
|
|
fn a_response_is_read_as_its_own_operation_only() {
|
|
use crate::readout::PlanQuotaSnapshot;
|
|
let key = TransportKey::from_connection_secret("readout-test-key").unwrap();
|
|
let readout = SpendReadout::new(1, Vec::new(), PlanQuotaSnapshot::default());
|
|
let sealed = encrypt_spend_readout("caller-1", "readout-1", &readout, &key).unwrap();
|
|
let readout_frame = encode_spend_readout_response_frame(&sealed).unwrap();
|
|
let invoke_frame = encode_result_frame(&sealed).unwrap();
|
|
assert_eq!(
|
|
decode_spend_readout_response_frame(&readout_frame).unwrap(),
|
|
sealed
|
|
);
|
|
assert_eq!(decode_result_frame(&invoke_frame).unwrap(), sealed);
|
|
assert_eq!(
|
|
decode_result_frame(&readout_frame),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
assert_eq!(
|
|
decode_spend_readout_response_frame(&invoke_frame),
|
|
Err(ServiceError::Carriage)
|
|
);
|
|
assert_eq!(decrypt_spend_readout(&sealed, &key).unwrap(), readout);
|
|
}
|
|
}
|