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:
parent
03bc88ae97
commit
a0a6ccbf0d
5 changed files with 245 additions and 13 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue