connector-contract-v3: the decoder refuses every dialect the wire does not name; the ciphertext is published as binary

wire_decode accepts a document only when its MessagePack value equals the
named form wire_encode writes for the decoded value (key order, integer width
and an absent optional field aside), so a positional envelope or invocation, an
integer-array ciphertext, an enum sent as its index and bytes after the
document are refused Encoding through the real carriage. The schema describes
the ciphertext as binary, and a test pins schema and wire agreeing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 12:03:23 +01:00
commit a0a6ccbf0d
5 changed files with 245 additions and 13 deletions

View file

@ -18,6 +18,7 @@ cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs", optional = true }
rand_core = { version = "0.6", features = ["getrandom"] }
rmp-serde = "1"
rmpv = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
@ -25,6 +26,3 @@ serde_json = "1"
sha2 = "0.10"
thiserror = "2"
zeroize = "1"
[dev-dependencies]
rmpv = "1"

View file

@ -462,8 +462,48 @@ pub(crate) fn wire_encode<T: Serialize + ?Sized>(document: &T) -> Result<Vec<u8>
rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding)
}
pub(crate) fn wire_decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, ContractError> {
rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding)
/// Decodes a document and refuses every dialect the wire does not name. The
/// typed decoder alone also reads a positional array for a struct, an integer
/// array where `bin` is declared and an enum as its index; so the bytes must
/// equal what [`wire_encode`] writes for the decoded value, compared as
/// MessagePack values: key order, integer width and an absent optional field
/// do not matter, array against map, array against `bin` and index against name do.
pub(crate) fn wire_decode<T: DeserializeOwned + Serialize>(
bytes: &[u8],
) -> Result<T, ContractError> {
let document: T = rmp_serde::from_slice(bytes).map_err(|_| ContractError::Encoding)?;
if named_form(bytes)? != named_form(&wire_encode(&document)?)? {
return Err(ContractError::Encoding);
}
Ok(document)
}
fn named_form(bytes: &[u8]) -> Result<rmpv::Value, ContractError> {
let mut reader = bytes;
let value = rmpv::decode::read_value(&mut reader).map_err(|_| ContractError::Encoding)?;
if !reader.is_empty() {
return Err(ContractError::Encoding);
}
Ok(order_free(value))
}
/// A map compared as a set of its present entries: sorted by key, nil entries dropped.
fn order_free(value: rmpv::Value) -> rmpv::Value {
match value {
rmpv::Value::Map(entries) => {
let mut kept: Vec<(rmpv::Value, rmpv::Value)> = entries
.into_iter()
.filter(|(_, entry)| !entry.is_nil())
.map(|(key, entry)| (key, order_free(entry)))
.collect();
kept.sort_by_key(|(key, _)| key.to_string());
rmpv::Value::Map(kept)
}
rmpv::Value::Array(items) => {
rmpv::Value::Array(items.into_iter().map(order_free).collect())
}
other => other,
}
}
pub fn canonical_provider_request_bytes(

View file

@ -33,10 +33,19 @@ pub struct TransportEnvelope {
pub message_kind: EnvelopeKind,
pub nonce: [u8; 12],
#[serde(with = "serde_bytes")]
#[schemars(with = "Vec<u8>")]
#[schemars(schema_with = "binary_schema")]
pub ciphertext: Vec<u8>,
}
/// The ciphertext is MessagePack `bin` on the wire, never an array of integers.
fn binary_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"format": "binary",
"description": "MessagePack bin: the raw ciphertext 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 one operation of that service: run one sealed invocation.
@ -971,4 +980,159 @@ mod tests {
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())
);
}
}
}

View file

@ -64,3 +64,37 @@ fn a_published_schema_refuses_an_unknown_field() {
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&serde_json::json!("max_output_tokens")));
}
/// The schema and the wire agree on the ciphertext: the schema says binary, not
/// an array of integers, and the encoder writes MessagePack `bin`.
#[test]
fn the_published_ciphertext_is_what_the_wire_sends() {
let schema = serde_json::to_value(schema_for!(TransportEnvelope)).unwrap();
let ciphertext = &schema["properties"]["ciphertext"];
assert_eq!(ciphertext["type"], "string");
assert_eq!(ciphertext["format"], "binary");
assert!(ciphertext.get("items").is_none());
let published = std::fs::read_to_string(schemas_dir().join("transport_envelope.json")).unwrap();
let published: serde_json::Value = serde_json::from_str(&published).unwrap();
assert_eq!(&published["properties"]["ciphertext"], ciphertext);
let envelope = TransportEnvelope {
schema_id: "gamecult.model.transport_envelope.v3".to_string(),
caller_runtime_id: "caller".to_string(),
request_id: "request".to_string(),
message_kind: EnvelopeKind::Invocation,
nonce: [1; 12],
ciphertext: vec![9; 40],
};
let sent =
rmpv::decode::read_value(&mut rmp_serde::to_vec_named(&envelope).unwrap().as_slice())
.unwrap();
let rmpv::Value::Map(entries) = sent else {
panic!("the envelope is a named map");
};
let (_, wire) = entries
.iter()
.find(|(key, _)| key.as_str() == Some("ciphertext"))
.unwrap();
assert!(matches!(wire, rmpv::Value::Binary(bytes) if bytes.len() == 40));
}

View file

@ -7,13 +7,9 @@
"type": "string"
},
"ciphertext": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"maximum": 255,
"minimum": 0
}
"description": "MessagePack bin: the raw ciphertext bytes, never an array of integers",
"type": "string",
"format": "binary"
},
"message_kind": {
"$ref": "#/$defs/EnvelopeKind"