security considerations

Security guidance for integrating canaad into production systems. Grounded in the project's architecture decisions and the AAD specification.


Error handling at trust boundaries

canaad surfaces detailed AadError variants — missing fields, constraint violations, duplicate keys, size limits, etc. This detail is safe and useful on the encrypt side, where the caller owns the input and needs to fix it.

At decryption boundaries, these details become dangerous. An attacker can probe field constraints, key formats, and extension patterns through error differentiation. This is an oracle.

Rule: At any decryption boundary — service endpoint, enclave, KMS proxy — wrap all AAD errors into a single opaque failure. Never expose AadError variants to callers who don't own the input.

Rust example

#[derive(Debug)]
pub struct DecryptionFailed;

impl std::fmt::Display for DecryptionFailed {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "decryption failed")
    }
}

impl std::error::Error for DecryptionFailed {}

fn decrypt(ciphertext: &[u8], aad_json: &str, key: &[u8]) -> Result<Vec<u8>, DecryptionFailed> {
    let aad_bytes = canaad_core::canonicalize(aad_json)
        .map_err(|_| DecryptionFailed)?;  // discard the specific error
    // ... AEAD decrypt using aad_bytes ...
    Ok(Vec::new())
}

JavaScript example

async function decrypt(ciphertext: Uint8Array, aadJson: string, key: CryptoKey) {
  let aadBytes: Uint8Array;
  try {
    aadBytes = canonicalize(aadJson);
  } catch {
    throw new Error('decryption failed');  // opaque — no AadError detail
  }
  // ... AEAD decrypt using aadBytes ...
}

Where this applies

  • Service endpoints that reconstruct AAD for decryption
  • Enclave boundaries
  • KMS proxies
  • Any path where the caller does not own the AAD input

Where this does NOT apply

  • Builder and construction APIs on the encrypt side
  • CLI usage (errors print to stderr for the local user)
  • Development and testing environments

validate() returns a bare boolean

By design, validate() in both Rust and JS returns bool / Result<AadContext, AadError> with no partial error context on the JS side. This is intentional — it prevents accidental leakage of validation details in contexts where a simple pass/fail is appropriate.

If you need error detail (encrypt side only), use canonicalize() or AadBuilder.build() instead, which throw/return the full AadError.


Size limits

Serialized AAD is capped at 16 KiB (16,384 bytes). build(), buildString(), canonicalize(), and canonicalize_string() all enforce this limit after canonicalization.

This prevents abuse from oversized extension payloads or deeply nested structures. If you hit this limit, reduce extension count or value sizes.


Integer safety

All integers — v, ts, and extension integer values — are capped at 2⁵³ − 1 (Number.MAX_SAFE_INTEGER). This is enforced universally across Rust, CLI, and WASM to guarantee cross-platform interoperability.

In the WASM layer, integers cross the JS↔Rust boundary as f64. Validation (NaN, Infinity, negative, fractional, above limit) is deferred to build() time, not setter time. This is documented behavior, not a bug.


Memory safety

All three crates enforce unsafe_code = "deny" at the workspace level, combined with clippy::pedantic and clippy::nursery at deny level. The entire codebase is 100% safe Rust — no unsafe blocks anywhere.


Duplicate key rejection

serde_json silently drops duplicate keys (keeping the last value). canaad implements custom single-pass duplicate key detection via a serde_json visitor. This is a spec requirement — duplicate keys in AAD JSON are rejected, not silently resolved.