Cord v2: serialization for security-sensitive systems

Serialization is plumbing until the bytes become evidence. The moment serialized data feeds a signature, hash, cache key, or protocol decision, byte identity matters.

Cord is a Rust serialization format for that boundary. It handles ordinary application data: primitives, collections, structs, enums, timestamps, decimals, UUIDs, maps, and sets. Every value has exactly one legal byte string. The verifier gets byte identity; a close-enough parse gives an attacker room to move.

Representation ambiguity hides in small places. Cord collapses that space to one legal encoding per value.

Use Cord when the encoded bytes become part of a decision:

  • Sign or hash structured data. Serialize a Rust value, sign or hash the bytes, then re-serialize on the verifier side and compare the same input.
  • Content-address, cache, or deduplicate records. Stable encodings give stable keys across processes, machines, and platforms.
  • Carry data across service versions. Add fields and future enum variants while older readers preserve unknown payloads byte-for-byte.
  • Define protocol messages with deterministic defaults. Start with fixed-width big-endian encodings, then opt into #[cord(varint)] or #[cord(width = 8)] where size matters.

Threat model

Assume the attacker controls the bytes handed to the decoder. Their goal is to make two byte strings behave like the same value, or make one component verify one representation while another component acts on another.

Ambiguity shows up in the details: shuffled map or set entries, duplicate entries, non-NFC strings, non-minimal varints, trailing bytes, invalid booleans, NaN, negative zero, deep nesting, oversized lengths, and malformed schema-evolution payloads.

Cord's decoder has one answer: accept the canonical representation or fail. It consumes the full input, decodes strings in NFC, requires canonical map and set order, rejects duplicates, requires minimal varints that fit the target type, rejects NaN and encoded negative zero, and applies default bounds to nested and length-prefixed payloads. Unknown future enum payloads can be preserved byte-for-byte; malformed known payloads fail.

That blocks three failure modes:

  • Canonicalization bypass: inputs crafted to verify against a normalized form, then execute against a different raw form, the shape of XML signature-wrapping and JWT header-manipulation attacks.
  • Protocol confusion: data parsed one way by the subsystem that authorizes it and another way by the subsystem that acts on it.
  • Reproducibility failures: third parties unable to independently reproduce the exact authenticated byte sequence, which breaks transparency logs and consensus.

A format where one value has exactly one legal encoding closes the gap those attacks live in.

API

Serde users get the usual derive flow. Add #[derive(Cord)], then call serialize.

use cord::{serialize, Cord};

#[derive(Cord)]
struct User { id: u32, name: String, active: bool }

let user = User { id: 42, name: "Alice".into(), active: true };
let bytes = serialize(&user).unwrap();
// Same value -> same bytes, every time.

Booleans, integers (i8–i128, u8–u128), floats, strings, byte arrays, options, sequences, structs, and enums encode directly, along with DateTime, Map, Set, Decimal, and Uuid. Cord-specific field attributes change the wire format at the field that needs them.

Hash encoded bytes. Turn on the hash feature and cord::hash() hashes the encoded bytes with SHA3-256:

use cord::{hash, Cord};

#[derive(Cord)]
struct Receipt { order: u64, total_cents: u64, paid: bool }

let a = hash(&Receipt { order: 7, total_cents: 1999, paid: true }).unwrap();
let b = hash(&Receipt { order: 7, total_cents: 1999, paid: true }).unwrap();
assert_eq!(a, b); // same value, same digest

Preserve future variants. Wrap a field in Evolving<T> and Cord length-prefixes its payload. When a newer service adds an enum variant your code has never heard of, you read it as Evolving::Unknown, and re-serialization returns the exact bytes you received. Old readers can preserve new data while treating it as unknown.

use cord::{Cord, Evolving};

#[derive(Cord)]
enum Status { Active, Inactive } // a future version may add more

#[derive(Cord)]
struct Message { id: u32, status: Evolving<Status> }
// Old readers round-trip future variants byte-for-byte.

Choose compact field encodings. The default is fixed-width big-endian, which is predictable and simple to encode and decode. For a size-sensitive protocol, drop #[cord(varint)] on a field for LEB128/zigzag, or #[cord(width = 8)] to narrow a length prefix or variant index from the 32-bit default down to one byte. You buy compactness at the field that needs it, with the trade-off visible in the type definition.

Limits

Cord's strict decoder makes it a poor fit when you need to preserve arbitrary third-party encodings as-is. Schema evolution is additive: you can add fields, and field removal breaks compatibility. The output is binary, so you will want tooling to inspect it.

Out of scope: encryption, authentication, signatures, authorization, replay protection, key management, side-channel resistance, service-level denial-of-service policy, and cryptographic protocol design around Cord's bytes. For tolerant parsing or frequent schema reshaping, use protobuf. For raw text, use JSON. Add canonicalization where signatures or hashes depend on the bytes.

Try it

cargo add cord

Cord has seen production use in Backbone and is Apache-2.0 licensed. Verify the determinism claim: read src/ser.rs, then check the decoder path for rejected alternate encodings. Feature requests and bugs go to https://github.com/backbone-hq/cord/issues; security reports follow SECURITY.md.


Upgrading from v1? The v2 wire format is a clean break: v1 bytes fail to decode under v2. Migrate persisted data by deserializing with v1 and re-serializing with v2.