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.