diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index 8bd884a..c54547b 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -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" diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 5796d19..d60fab5 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -462,8 +462,48 @@ pub(crate) fn wire_encode(document: &T) -> Result rmp_serde::to_vec_named(document).map_err(|_| ContractError::Encoding) } -pub(crate) fn wire_decode(bytes: &[u8]) -> Result { - 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( + bytes: &[u8], +) -> Result { + 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 { + 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( diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 7ea243d..0f87546 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -33,10 +33,19 @@ pub struct TransportEnvelope { pub message_kind: EnvelopeKind, pub nonce: [u8; 12], #[serde(with = "serde_bytes")] - #[schemars(with = "Vec")] + #[schemars(schema_with = "binary_schema")] pub ciphertext: Vec, } +/// 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 { + 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 { + request_message(|message| { + if let CultNetMessage::OperationRequest { payload: sent, .. } = message { + *sent = STANDARD.encode(payload); + } + }) + } + + fn response_frame_carrying(payload: &[u8]) -> Vec { + 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::(&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::(&bytes), + Ok(invocation.clone()) + ); + } + } } diff --git a/crates/model-connector/tests/published_schemas.rs b/crates/model-connector/tests/published_schemas.rs index 1dabe18..4634280 100644 --- a/crates/model-connector/tests/published_schemas.rs +++ b/crates/model-connector/tests/published_schemas.rs @@ -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)); +} diff --git a/schemas/transport_envelope.json b/schemas/transport_envelope.json index aee5469..1983423 100644 --- a/schemas/transport_envelope.json +++ b/schemas/transport_envelope.json @@ -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"