From 0494580e6aff5412080f7d24bf3fca66345be03f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 21:42:20 +1100 Subject: [PATCH 01/28] feat(prf): derive canonical equality from values --- .github/workflows/test.yml | 7 + Cargo.lock | 39 +++++ packages/aead-value/Cargo.toml | 21 ++- packages/aead-value/src/canonical.rs | 140 +++++++++++++++ packages/aead-value/src/canonical_tests.rs | 177 +++++++++++++++++++ packages/aead-value/src/lib.rs | 2 + packages/hmac/Cargo.toml | 3 + packages/hmac/tests/value_equality.rs | 191 +++++++++++++++++++++ packages/prf/Cargo.toml | 6 + packages/prf/README.md | 27 +++ packages/prf/src/error.rs | 2 + packages/prf/src/lib.rs | 3 + packages/prf/src/value.rs | 58 +++++++ 13 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 packages/aead-value/src/canonical.rs create mode 100644 packages/aead-value/src/canonical_tests.rs create mode 100644 packages/hmac/tests/value_equality.rs create mode 100644 packages/prf/src/value.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb70cdbd..8af5e5e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,6 +96,13 @@ jobs: cargo test -p vitaminc-aead-value --features rust_decimal && cargo test -p vitaminc-aead-value --all-features + - name: test (canonical equality features) + run: | + cargo test -p vitaminc-prf --features chrono && + cargo test -p vitaminc-prf --features rust_decimal && + cargo test -p vitaminc-prf -p vitaminc-aead-value --all-features && + cargo test -p vitaminc-hmac --test value_equality + - name: test (vitaminc-encrypt, RustCrypto backend on native) # Forces the RustCrypto path that wasm32 uses, so the same property # tests run against both backends in CI. diff --git a/Cargo.lock b/Cargo.lock index a2ed2cc6..a1eb3a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1615,6 +1615,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "orderable-bytes" +version = "0.1.1" +source = "git+https://github.com/cipherstash/ore.rs?rev=b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5#b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5" +dependencies = [ + "chrono", + "rust_decimal", +] + [[package]] name = "outref" version = "0.5.2" @@ -2320,6 +2329,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" @@ -2511,12 +2526,27 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -2639,7 +2669,12 @@ name = "vitaminc-aead-value" version = "0.5.1" dependencies = [ "chrono", + "orderable-bytes", + "quickcheck", + "quickcheck_macros", "rust_decimal", + "unicode-general-category", + "unicode-normalization", "vitaminc-aead", "vitaminc-encrypt", "vitaminc-protected", @@ -2683,12 +2718,15 @@ dependencies = [ name = "vitaminc-hmac" version = "0.5.1" dependencies = [ + "chrono", "futures", "hmac", "quickcheck", "quickcheck_macros", + "rust_decimal", "sha2", "trybuild", + "vitaminc-aead-value", "vitaminc-prf", "vitaminc-protected", "zeroize", @@ -2738,6 +2776,7 @@ dependencies = [ "quickcheck", "quickcheck_macros", "thiserror", + "vitaminc-aead-value", "vitaminc-context", "vitaminc-protected", ] diff --git a/packages/aead-value/Cargo.toml b/packages/aead-value/Cargo.toml index 33e5cac2..97cdbf50 100644 --- a/packages/aead-value/Cargo.toml +++ b/packages/aead-value/Cargo.toml @@ -19,8 +19,9 @@ zeroize = { workspace = true } [features] default = [] -chrono = ["dep:chrono"] -rust_decimal = ["dep:rust_decimal"] +canonical = ["dep:orderable-bytes", "dep:unicode-normalization", "dep:unicode-general-category"] +chrono = ["dep:chrono", "orderable-bytes?/chrono"] +rust_decimal = ["dep:rust_decimal", "orderable-bytes?/decimal"] [dependencies.chrono] version = "0.4.44" @@ -35,4 +36,20 @@ features = ["std"] optional = true [dev-dependencies] +quickcheck = "1.1.0" +quickcheck_macros = "1.2.0" vitaminc-encrypt = { path = "../encrypt" } + +# Temporary review pin: ore.rs #97; replace with its 0.2 release before publishing. +[dependencies.orderable-bytes] +git = "https://github.com/cipherstash/ore.rs" +rev = "b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5" +optional = true + +[dependencies.unicode-normalization] +version = "=0.1.24" +optional = true + +[dependencies.unicode-general-category] +version = "=1.1.0" +optional = true diff --git a/packages/aead-value/src/canonical.rs b/packages/aead-value/src/canonical.rs new file mode 100644 index 00000000..3ed30be6 --- /dev/null +++ b/packages/aead-value/src/canonical.rs @@ -0,0 +1,140 @@ +//! Canonical scalar plaintext for search terms, separate from reversible ciphertext. +//! +//! Numeric encodings come from `orderable-bytes` at their natural widths. +//! Text uses Unicode 16 NFC. These bytes are protocol data: changing them +//! requires a new term domain. Text processing is variable-time; this module +//! makes no constant-time claim for Unicode normalization. + +use orderable_bytes::ToOrderableBytes; +use unicode_general_category::{get_general_category, GeneralCategory}; +use unicode_normalization::UnicodeNormalization; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroize; + +use crate::{Value, ValueKind}; + +/// A refused search-term input. Errors never include plaintext. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CanonicalError { + /// A known kind has no search terms (booleans and containers). + UnsupportedKind(ValueKind), + /// Null, undefined and passthrough values have no search terms. + UnsupportedValue, + /// Text contains a code point unassigned in Unicode 16. + UnassignedCodePoint, + /// Invalid UTF-8 (defensive validation of a protected string). + InvalidText, +} + +impl std::fmt::Display for CanonicalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedKind(kind) => write!(f, "{kind} has no search terms"), + Self::UnsupportedValue => f.write_str("this value has no search terms"), + Self::UnassignedCodePoint => { + f.write_str("text contains a code point unassigned in Unicode 16") + } + Self::InvalidText => f.write_str("invalid UTF-8"), + } + } +} + +impl std::error::Error for CanonicalError {} + +/// Encode a scalar for equality. Every owned byte buffer remains protected. +/// +/// Float zero signs and NaN payloads are folded. Timestamps truncate their +/// fractional second to microseconds (also before the epoch and during leap +/// seconds), retaining the upstream 12-byte seconds/nanoseconds layout. +/// Decimal encoding itself normalizes scale, including signed zero, without +/// a variable-iteration call to `Decimal::normalize`. +pub fn equality_bytes(value: &Value) -> Result>, CanonicalError> { + // This match lives alongside Value so additions cannot silently fall into + // a downstream non_exhaustive wildcard. Both term layers reuse it. + Ok(match value { + Value::Int8(v) => fixed(v), + Value::UInt8(v) => fixed(v), + Value::Int16(v) => fixed(v), + Value::UInt16(v) => fixed(v), + Value::Int32(v) => fixed(v), + Value::UInt32(v) => fixed(v), + Value::Int64(v) => fixed(v), + Value::UInt64(v) => fixed(v), + Value::Int128(v) => fixed(v), + Value::UInt128(v) => fixed(v), + Value::Float32(v) => fixed(&canonical_f32(*v)), + Value::Float64(v) => fixed(&canonical_f64(*v)), + #[cfg(feature = "chrono")] + Value::Date(v) => fixed(v), + #[cfg(feature = "chrono")] + Value::Timestamp(v) => timestamp(v), + #[cfg(feature = "rust_decimal")] + Value::Decimal(v) => fixed(v), + Value::String(v) => text_nfc(v.risky_ref())?, + Value::Bytes(v) => Protected::new(v.risky_ref().to_orderable_bytes().to_vec()), + Value::Bool(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Bool)), + Value::Array(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Array)), + Value::Object(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Object)), + Value::Null | Value::Undefined | Value::Passthrough(_) => { + return Err(CanonicalError::UnsupportedValue) + } + }) +} + +fn fixed(value: &T) -> Protected> +where + for<'a> T::Bytes<'a>: Zeroize, +{ + let bytes = Protected::new(value.to_orderable_bytes()); + Protected::new(bytes.risky_ref().as_ref().to_vec()) +} + +fn canonical_f32(value: f32) -> f32 { + let bits = value.to_bits(); + let magnitude = bits & 0x7fff_ffff; + let nan = 0u32.wrapping_sub((magnitude > 0x7f80_0000) as u32); + let nonzero = 0u32.wrapping_sub((magnitude != 0) as u32); + f32::from_bits(((bits & !nan) | (0x7fc0_0000 & nan)) & nonzero) +} + +fn canonical_f64(value: f64) -> f64 { + let bits = value.to_bits(); + let magnitude = bits & 0x7fff_ffff_ffff_ffff; + let nan = 0u64.wrapping_sub((magnitude > 0x7ff0_0000_0000_0000) as u64); + let nonzero = 0u64.wrapping_sub((magnitude != 0) as u64); + f64::from_bits(((bits & !nan) | (0x7ff8_0000_0000_0000 & nan)) & nonzero) +} + +#[cfg(feature = "chrono")] +fn timestamp(value: &chrono::DateTime) -> Protected> { + // Truncate the subsecond field, not an epoch count: this preserves the + // full chrono range and its leap-second representation without overflow. + let mut bytes = Protected::new(value.to_orderable_bytes()); + let nanos = value.timestamp_subsec_nanos() / 1_000 * 1_000; + bytes.risky_inner_mut()[8..].copy_from_slice(&nanos.to_be_bytes()); + Protected::new(bytes.risky_ref().to_vec()) +} + +/// NFC in the pinned Unicode version, without case or accent folding. +pub fn text_nfc(bytes: &[u8]) -> Result>, CanonicalError> { + let text = std::str::from_utf8(bytes).map_err(|_| CanonicalError::InvalidText)?; + if text + .chars() + .any(|c| get_general_category(c) == GeneralCategory::Unassigned) + { + return Err(CanonicalError::UnassignedCodePoint); + } + let mut result = Protected::new(Vec::with_capacity(bytes.len())); + for c in text.nfc() { + let mut encoded = Protected::new([0u8; 4]); + result + .risky_inner_mut() + .extend_from_slice(c.encode_utf8(encoded.risky_inner_mut()).as_bytes()); + } + Ok(result) +} + +#[cfg(test)] +#[path = "canonical_tests.rs"] +mod tests; diff --git a/packages/aead-value/src/canonical_tests.rs b/packages/aead-value/src/canonical_tests.rs new file mode 100644 index 00000000..1a1cc460 --- /dev/null +++ b/packages/aead-value/src/canonical_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use quickcheck_macros::quickcheck; + +fn bytes(value: Value) -> Vec { + equality_bytes(&value) + .expect("valid test value") + .risky_ref() + .clone() +} + +#[test] +fn unicode_version_and_equivalence_are_frozen() { + assert_eq!(unicode_normalization::UNICODE_VERSION, (16, 0, 0)); + assert_eq!(unicode_general_category::UNICODE_VERSION, (16, 0, 0)); + assert_eq!(bytes(Value::String("e\u{301}".into())), "é".as_bytes()); + assert_ne!( + bytes(Value::String("É".into())), + bytes(Value::String("é".into())) + ); + assert_ne!( + bytes(Value::String("e".into())), + bytes(Value::String("é".into())) + ); + for text in ["\u{378}", "\u{10ffff}", "\u{1faea}"] { + assert_eq!( + text_nfc(text.as_bytes()).expect_err("unsupported input"), + CanonicalError::UnassignedCodePoint + ); + } + assert_eq!( + text_nfc(&[0xff]).expect_err("unsupported input"), + CanonicalError::InvalidText + ); + assert_eq!( + text_nfc(b"a\0").expect("valid test value").risky_ref(), + b"a\0" + ); +} + +#[test] +fn every_non_scalar_is_explicitly_refused() { + let cases = [ + ( + Value::Bool(false), + CanonicalError::UnsupportedKind(ValueKind::Bool), + ), + ( + Value::Array(vec![]), + CanonicalError::UnsupportedKind(ValueKind::Array), + ), + ( + Value::Object(vec![]), + CanonicalError::UnsupportedKind(ValueKind::Object), + ), + (Value::Null, CanonicalError::UnsupportedValue), + (Value::Undefined, CanonicalError::UnsupportedValue), + ( + Value::Passthrough(Box::new(Value::Int32(1))), + CanonicalError::UnsupportedValue, + ), + ]; + for (value, error) in cases { + assert_eq!( + equality_bytes(&value).expect_err("unsupported input"), + error + ); + assert!(!error.to_string().is_empty()); + } + assert!(!CanonicalError::InvalidText.to_string().is_empty()); + assert!(!CanonicalError::UnassignedCodePoint.to_string().is_empty()); +} + +#[test] +fn floats_have_postgres_zero_and_nan_ordering() { + assert_eq!(bytes(Value::Float64(-0.0)), bytes(Value::Float64(0.0))); + assert_eq!(bytes(Value::Float32(-0.0)), bytes(Value::Float32(0.0))); + for bits in [0x7fc0_0000, 0xff80_0001, 0xffff_ffff, 0x7f80_0001] { + assert_eq!( + bytes(Value::Float32(f32::from_bits(bits))), + [0xff, 0xc0, 0, 0] + ); + } + for bits in [0x7ff8_0000_0000_0000, 0xfff0_0000_0000_0001, u64::MAX] { + assert_eq!( + bytes(Value::Float64(f64::from_bits(bits))), + [0xff, 0xf8, 0, 0, 0, 0, 0, 0] + ); + } + let ascending = [f64::NEG_INFINITY, -1.0, 0.0, 1.0, f64::INFINITY, f64::NAN]; + for pair in ascending.windows(2) { + assert!(bytes(Value::Float64(pair[0])) < bytes(Value::Float64(pair[1]))); + } +} + +#[quickcheck] +fn f64_order_agrees_with_numeric_order(a: f64, b: f64) -> bool { + a.is_nan() + || b.is_nan() + || bytes(Value::Float64(a)).cmp(&bytes(Value::Float64(b))) + == a.partial_cmp(&b).expect("valid test value") +} + +#[quickcheck] +fn f32_canonicalisation_is_idempotent(bits: u32) -> bool { + let value = canonical_f32(f32::from_bits(bits)); + canonical_f32(value).to_bits() == value.to_bits() +} + +#[quickcheck] +fn f64_canonicalisation_is_idempotent(bits: u64) -> bool { + let value = canonical_f64(f64::from_bits(bits)); + canonical_f64(value).to_bits() == value.to_bits() +} + +#[quickcheck] +fn nfc_is_idempotent(text: String) -> bool { + match text_nfc(text.as_bytes()) { + Ok(first) => { + text_nfc(first.risky_ref()) + .expect("valid test value") + .risky_ref() + == first.risky_ref() + } + Err(_) => text + .chars() + .any(|c| get_general_category(c) == GeneralCategory::Unassigned), + } +} + +#[cfg(feature = "chrono")] +#[test] +fn timestamps_truncate_fractional_seconds_across_epoch_and_leap_seconds() { + use chrono::{DateTime, Utc}; + for seconds in [-1, 0, 59] { + let value = + DateTime::::from_timestamp(seconds, 123_456_789).expect("valid test value"); + let canonical = + DateTime::::from_timestamp(seconds, 123_456_000).expect("valid test value"); + assert_eq!( + bytes(Value::Timestamp(value)), + canonical.to_orderable_bytes() + ); + } + let leap = DateTime::::from_timestamp(59, 1_123_456_789).expect("valid test value"); + let canonical = DateTime::::from_timestamp(59, 1_123_456_000).expect("valid test value"); + assert_eq!( + bytes(Value::Timestamp(leap)), + canonical.to_orderable_bytes() + ); + assert!( + bytes(Value::Timestamp(leap)) + < bytes(Value::Timestamp( + DateTime::from_timestamp(60, 0).expect("valid test value") + )) + ); + for value in [DateTime::::MIN_UTC, DateTime::::MAX_UTC] { + assert_eq!(bytes(Value::Timestamp(value)).len(), 12); + } +} + +#[cfg(feature = "rust_decimal")] +#[test] +fn decimals_normalize_scale_and_signed_zero_without_touching_ciphertext() { + use rust_decimal::Decimal; + for spelling in ["1", "1.0", "1.00"] { + let value = spelling.parse::().expect("valid test value"); + assert_eq!( + bytes(Value::Decimal(value)), + bytes(Value::Decimal(Decimal::ONE)) + ); + } + assert_eq!( + bytes(Value::Decimal("-0.00".parse().expect("valid test value"))), + bytes(Value::Decimal(Decimal::ZERO)) + ); + assert!(bytes(Value::Decimal(Decimal::MIN)) < bytes(Value::Decimal(Decimal::MAX))); +} diff --git a/packages/aead-value/src/lib.rs b/packages/aead-value/src/lib.rs index 9374b51e..26a5603a 100644 --- a/packages/aead-value/src/lib.rs +++ b/packages/aead-value/src/lib.rs @@ -1,6 +1,8 @@ #![deny(clippy::unwrap_used, clippy::todo, unsafe_code, unused_imports)] #![doc = include_str!("../README.md")] pub mod aad; +#[cfg(feature = "canonical")] +pub mod canonical; pub mod kind; mod scalar; pub mod tagged; diff --git a/packages/hmac/Cargo.toml b/packages/hmac/Cargo.toml index c5aa6c29..bff7994b 100644 --- a/packages/hmac/Cargo.toml +++ b/packages/hmac/Cargo.toml @@ -19,6 +19,9 @@ vitaminc-protected = { version = "0.5.1", path = "../protected" } zeroize = { workspace = true } [dev-dependencies] +vitaminc-aead-value = { path = "../aead-value", features = ["canonical", "chrono", "rust_decimal"] } +chrono = { version = "0.4.44", default-features = false, features = ["std"] } +rust_decimal = { version = "1.41", default-features = false, features = ["std"] } futures = "0.3.31" quickcheck = "1.1.0" quickcheck_macros = "1.2.0" diff --git a/packages/hmac/tests/value_equality.rs b/packages/hmac/tests/value_equality.rs new file mode 100644 index 00000000..3a4b39fa --- /dev/null +++ b/packages/hmac/tests/value_equality.rs @@ -0,0 +1,191 @@ +//! Vectors independently calculated with Python hashlib/hmac and explicit +//! big-endian canonical payloads; key = 0x0b * 32, empty context, PAE framing. +use vitaminc_aead_value::{canonical::equality_bytes, Value, ValueKind}; +use vitaminc_hmac::HmacSha256Prf; +use vitaminc_prf::{CanonicalError, PrfEncoding, PrfError, PrfKeyInit, PrfValue}; +use vitaminc_protected::{Controlled, Protected}; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[test] +fn every_equality_domain_has_a_known_answer() { + let prf = HmacSha256Prf::new(Protected::new([0x0b; 32])); + let vectors = [ + ( + Value::Int8(-7), + "vitaminc/prf/value/int8-orderable/v1", + "79", + "45b646e792baac429bc9b0909bfee1f5d830340c2689dce4a157f7cd3f5c52f3", + ), + ( + Value::Int16(-7), + "vitaminc/prf/value/int16-orderable/v1", + "7ff9", + "910b81e2c549234d1853e3d638b211586992e5ec46004f2d99d2287201dd6b64", + ), + ( + Value::Int32(-7), + "vitaminc/prf/value/int32-orderable/v1", + "7ffffff9", + "c8e0b6c9ea09379eebe54ca54775961eedba9a140a60d5e1cb2a2e74e6971152", + ), + ( + Value::Int64(-7), + "vitaminc/prf/value/int64-orderable/v1", + "7ffffffffffffff9", + "230a90e2b90dca97dcb96b6f8949fc481eac133c6009026a41b8d69c4df984a2", + ), + ( + Value::Int128(-7), + "vitaminc/prf/value/int128-orderable/v1", + "7ffffffffffffffffffffffffffffff9", + "86ea7b332d238ddeaba562eb280075e1345eb9835f25a2ec0d4e239a9a341951", + ), + ( + Value::UInt8(7), + "vitaminc/prf/value/uint8-orderable/v1", + "07", + "93bf0e77c1b9e2859c6307cea9a3c36a0eec73bc0930927bce0d147d627fab34", + ), + ( + Value::UInt16(7), + "vitaminc/prf/value/uint16-orderable/v1", + "0007", + "75adfa1dd83181469e110035f65cc39710887aa9dbc674b5f9f938bd69934ddc", + ), + ( + Value::UInt32(7), + "vitaminc/prf/value/uint32-orderable/v1", + "00000007", + "d1348bee44399b980d31a146f062a6398a4c12ac118d33a80fe4fede2c970e7c", + ), + ( + Value::UInt64(7), + "vitaminc/prf/value/uint64-orderable/v1", + "0000000000000007", + "7fdc5f704d19e6a9dbcc43943379e4c7f6322146f2e78efd1799b130d772cda9", + ), + ( + Value::UInt128(7), + "vitaminc/prf/value/uint128-orderable/v1", + "00000000000000000000000000000007", + "41d5f93103768546432733266bf168544b82cc329c97b91a96cf70a1db3e78d9", + ), + ( + Value::Float32(1.5), + "vitaminc/prf/value/float32-orderable/v1", + "bfc00000", + "b14abcd83e1c53639315fae63c96b98d93cfec5793929ff37376dff64b3f73e9", + ), + ( + Value::Float64(-1.5), + "vitaminc/prf/value/float64-orderable/v1", + "4007ffffffffffff", + "e31d40623c1470970496696ec78f168e100d0404e5e5f1da83b9d5d01402e48a", + ), + ( + Value::Date(chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()), + "vitaminc/prf/value/date-orderable/v1", + "800af93b", + "57720e5c53bfa2e7e83b04faf4f740927c2ffd6f87f84fa32a91e709a71c83f4", + ), + ( + Value::Timestamp(chrono::DateTime::from_timestamp(-1, 999_999_999).unwrap()), + "vitaminc/prf/value/timestamp-micros-orderable/v1", + "7fffffffffffffff3b9ac618", + "9fc40c1a26ed4aed4bd73402040bcd8da02b4665509c99ac5a4edd5bee9ac57f", + ), + ( + Value::Decimal("1.00".parse().unwrap()), + "vitaminc/prf/value/decimal-orderable/v1", + "c000204fce5e3e25026110000000", + "4a2d2091d09c610d416209925d3ac31b12a34d06d0771539a55065e46a542dc3", + ), + ( + Value::String("e\u{301}".into()), + "vitaminc/prf/value/text-nfc/unicode-16/v1", + "c3a9", + "b1bd0d5919b11845e2b4e1df2dd74a0fe7dddc7505a4bec84883393564bf64a7", + ), + ( + Value::Bytes(Protected::new(vec![0, 255, 7])), + "vitaminc/prf/value/bytes/v1", + "00ff07", + "39bb4ddca2af43c8afb23184af6a3baab6c5a3a0dc420d78700abc2e37abc36d", + ), + ]; + let mut covered = Vec::new(); + for (value, domain, payload, expected) in vectors { + let kind = value.kind().unwrap(); + covered.push(kind); + assert_eq!(PrfEncoding::for_value_kind(kind).unwrap().as_str(), domain); + assert_eq!( + hex(equality_bytes(&value).unwrap().risky_ref()), + payload, + "{kind}" + ); + assert_eq!( + hex(&(&value).prf(&prf).into_result().unwrap()), + expected, + "{kind}" + ); + assert_ne!( + (&value) + .prf_with_context(&prf, ("tenant", 1u64)) + .into_result() + .unwrap(), + (&value) + .prf_with_context(&prf, ("tenant", 2u64)) + .into_result() + .unwrap(), + ); + } + let exceptions = [ValueKind::Bool, ValueKind::Array, ValueKind::Object]; + for &kind in ValueKind::ALL { + assert_eq!( + covered.contains(&kind), + !exceptions.contains(&kind), + "{kind}" + ); + assert_eq!( + PrfEncoding::for_value_kind(kind).is_some(), + covered.contains(&kind) + ); + } +} + +#[test] +fn unsupported_values_return_typed_errors_without_hashing() { + let prf = HmacSha256Prf::new(Protected::new([0x0b; 32])); + assert!(matches!( + (&Value::Bool(true)).prf(&prf).into_result(), + Err(PrfError::Canonical(CanonicalError::UnsupportedKind( + ValueKind::Bool + ))) + )); + assert!(matches!( + (&Value::String("\u{378}".into())).prf(&prf).into_result(), + Err(PrfError::Canonical(CanonicalError::UnassignedCodePoint)) + )); +} + +#[test] +fn canonical_equivalence_preserves_source_and_legacy_domains() { + let prf = HmacSha256Prf::new(Protected::new([0x0b; 32])); + let source = Value::String("e\u{301}".into()); + let equivalent = Value::String("é".into()); + assert_eq!( + (&source).prf(&prf).into_result().unwrap(), + (&equivalent).prf(&prf).into_result().unwrap() + ); + match source { + Value::String(text) => assert_eq!(text.risky_ref(), "e\u{301}".as_bytes()), + _ => unreachable!(), + } + assert_ne!( + (&Value::Int64(7)).prf(&prf).into_result().unwrap(), + 7i64.prf(&prf).into_result().unwrap() + ); +} diff --git a/packages/prf/Cargo.toml b/packages/prf/Cargo.toml index 8646b83e..afec1b63 100644 --- a/packages/prf/Cargo.toml +++ b/packages/prf/Cargo.toml @@ -11,7 +11,13 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[features] +default = [] +chrono = ["vitaminc-aead-value/chrono"] +rust_decimal = ["vitaminc-aead-value/rust_decimal"] + [dependencies] +vitaminc-aead-value = { version = "0.5.1", path = "../aead-value", features = ["canonical"] } mutants = "0.0.4" thiserror = { workspace = true } vitaminc-context = { version = "0.5.1", path = "../context" } diff --git a/packages/prf/README.md b/packages/prf/README.md index b0d6d327..9a17172d 100644 --- a/packages/prf/README.md +++ b/packages/prf/README.md @@ -108,3 +108,30 @@ impl

PrfVisitor<[u8; 32], P> for UserTermsVisitor { Executing the derivation requires a backend; with `vitaminc-hmac` in scope the value above resolves through `user.prf_visit_with_context(&prf, "tenant/acme/users/v1", UserTermsVisitor).await`. + +### Equality terms from `Value` + +`PrfValue` is implemented for `&vitaminc_aead_value::Value`. It borrows the +source and derives one scalar equality term. Booleans, containers, null, +undefined and passthrough return `PrfError::Canonical`; text with code points +unassigned in Unicode 16 is also refused. `chrono` and `rust_decimal` features +enable the corresponding value variants. + +The shared `vitaminc_aead_value::canonical` module owns the exhaustive value +dispatch. It uses `orderable-bytes` at each scalar's natural width: signed +integers are biased big-endian, floats fold signed zero and every NaN to one +positive quiet NaN, timestamps truncate fractional nanoseconds to microseconds +while retaining the 12-byte seconds/nanoseconds layout, and decimal encodings +normalize scale and signed zero. Text equality is Unicode 16 NFC without +accent or case folding. Unicode normalization and assignment tables are pinned. +Ciphertext retains the original value, including scale and sub-microsecond data. + +`PrfEncoding::for_value_kind` exposes the new, versioned domains. Existing +primitive `PrfValue` implementations retain their established little-endian or +raw UTF-8 domains. Applications must use the same input API and context for +writes and queries. Every new domain has an independently calculated HMAC +known-answer vector in `vitaminc-hmac/tests/value_equality.rs`. + +**Merge gate for #373:** Dan Draper's written sign-off on canonical order bytes +as PRF inputs is required before merge. The temporary `orderable-bytes` Git pin +tracks ore.rs #97 and must become its registry release before publication. diff --git a/packages/prf/src/error.rs b/packages/prf/src/error.rs index 55818464..785836df 100644 --- a/packages/prf/src/error.rs +++ b/packages/prf/src/error.rs @@ -31,6 +31,8 @@ pub enum PrfVisitorError { /// remote batch request being rejected). #[derive(Debug, PartialEq, Eq, Error)] pub enum PrfError { + #[error("cannot derive an equality term: {0}")] + Canonical(#[from] crate::CanonicalError), #[error("failed to build PRF operation: {0}")] Build(#[from] PrfBuildError), #[error("failed to interpret PRF result: {0}")] diff --git a/packages/prf/src/lib.rs b/packages/prf/src/lib.rs index ff94b411..b10356a0 100644 --- a/packages/prf/src/lib.rs +++ b/packages/prf/src/lib.rs @@ -7,8 +7,11 @@ mod error; mod impls; mod ready; mod traits; +mod value; mod visitor; +pub use vitaminc_aead_value::canonical::CanonicalError; + #[cfg(test)] mod test_backend; diff --git a/packages/prf/src/value.rs b/packages/prf/src/value.rs new file mode 100644 index 00000000..1dba3bf6 --- /dev/null +++ b/packages/prf/src/value.rs @@ -0,0 +1,58 @@ +//! Equality terms for the language-neutral value model. + +use vitaminc_aead_value::{canonical::equality_bytes, Value, ValueKind}; + +use crate::{IntoPrfContext, Prf, PrfEncoding, PrfError, PrfValue, PrfVisitor}; + +impl PrfEncoding { + /// The canonical equality domain for a value kind. Legacy primitive + /// `PrfValue` encodings retain their existing domains and bytes. + pub const fn for_value_kind(kind: ValueKind) -> Option { + let name = match kind { + ValueKind::Int8 => "vitaminc/prf/value/int8-orderable/v1", + ValueKind::UInt8 => "vitaminc/prf/value/uint8-orderable/v1", + ValueKind::Int16 => "vitaminc/prf/value/int16-orderable/v1", + ValueKind::UInt16 => "vitaminc/prf/value/uint16-orderable/v1", + ValueKind::Int32 => "vitaminc/prf/value/int32-orderable/v1", + ValueKind::UInt32 => "vitaminc/prf/value/uint32-orderable/v1", + ValueKind::Int64 => "vitaminc/prf/value/int64-orderable/v1", + ValueKind::UInt64 => "vitaminc/prf/value/uint64-orderable/v1", + ValueKind::Int128 => "vitaminc/prf/value/int128-orderable/v1", + ValueKind::UInt128 => "vitaminc/prf/value/uint128-orderable/v1", + ValueKind::Float32 => "vitaminc/prf/value/float32-orderable/v1", + ValueKind::Float64 => "vitaminc/prf/value/float64-orderable/v1", + ValueKind::Date => "vitaminc/prf/value/date-orderable/v1", + ValueKind::Timestamp => "vitaminc/prf/value/timestamp-micros-orderable/v1", + ValueKind::Decimal => "vitaminc/prf/value/decimal-orderable/v1", + ValueKind::String => "vitaminc/prf/value/text-nfc/unicode-16/v1", + ValueKind::Bytes => "vitaminc/prf/value/bytes/v1", + _ => return None, + }; + Some(Self::new(name)) + } +} + +impl PrfValue for &Value { + fn prf_visit_with_context<'a, P, V, C>(self, prf: &P, context: C, visitor: V) -> P::Ok + where + P: Prf, + V: PrfVisitor, + C: IntoPrfContext<'a>, + { + // The exhaustive Value dispatch lives in the shared canonical layer, + // inside Value's crate, where non_exhaustive cannot hide a new kind. + let bytes = match equality_bytes(self) { + Ok(bytes) => bytes, + Err(error) => return prf.failure(PrfError::Canonical(error)), + }; + match self.kind().and_then(PrfEncoding::for_value_kind) { + Some(encoding) => prf.prf_bytes_vec( + bytes, + encoding, + context.into_prf_context().into_owned(), + visitor, + ), + None => prf.failure(PrfError::Canonical(crate::CanonicalError::UnsupportedValue)), + } + } +} From d1f34179c36f6820026bb8453cf8a90e16f5970a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 22:14:53 +1100 Subject: [PATCH 02/28] test(prf): cover canonical domain mutations --- packages/aead-value/src/canonical.rs | 4 +- packages/aead-value/src/canonical_tests.rs | 7 ++ packages/prf/src/value.rs | 89 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/aead-value/src/canonical.rs b/packages/aead-value/src/canonical.rs index 3ed30be6..c2ef28c5 100644 --- a/packages/aead-value/src/canonical.rs +++ b/packages/aead-value/src/canonical.rs @@ -95,7 +95,7 @@ fn canonical_f32(value: f32) -> f32 { let magnitude = bits & 0x7fff_ffff; let nan = 0u32.wrapping_sub((magnitude > 0x7f80_0000) as u32); let nonzero = 0u32.wrapping_sub((magnitude != 0) as u32); - f32::from_bits(((bits & !nan) | (0x7fc0_0000 & nan)) & nonzero) + f32::from_bits((bits & !nan).wrapping_add(0x7fc0_0000 & nan) & nonzero) } fn canonical_f64(value: f64) -> f64 { @@ -103,7 +103,7 @@ fn canonical_f64(value: f64) -> f64 { let magnitude = bits & 0x7fff_ffff_ffff_ffff; let nan = 0u64.wrapping_sub((magnitude > 0x7ff0_0000_0000_0000) as u64); let nonzero = 0u64.wrapping_sub((magnitude != 0) as u64); - f64::from_bits(((bits & !nan) | (0x7ff8_0000_0000_0000 & nan)) & nonzero) + f64::from_bits((bits & !nan).wrapping_add(0x7ff8_0000_0000_0000 & nan) & nonzero) } #[cfg(feature = "chrono")] diff --git a/packages/aead-value/src/canonical_tests.rs b/packages/aead-value/src/canonical_tests.rs index 1a1cc460..e3fb2bd1 100644 --- a/packages/aead-value/src/canonical_tests.rs +++ b/packages/aead-value/src/canonical_tests.rs @@ -86,6 +86,13 @@ fn floats_have_postgres_zero_and_nan_ordering() { [0xff, 0xf8, 0, 0, 0, 0, 0, 0] ); } + assert_eq!(bytes(Value::Float32(1.5)), [0xbf, 0xc0, 0, 0]); + assert_eq!(bytes(Value::Float32(-1.5)), [0x40, 0x3f, 0xff, 0xff]); + assert_eq!(bytes(Value::Float32(f32::INFINITY)), [0xff, 0x80, 0, 0]); + assert_eq!( + bytes(Value::Float32(f32::NEG_INFINITY)), + [0, 0x7f, 0xff, 0xff] + ); let ascending = [f64::NEG_INFINITY, -1.0, 0.0, 1.0, f64::INFINITY, f64::NAN]; for pair in ascending.windows(2) { assert!(bytes(Value::Float64(pair[0])) < bytes(Value::Float64(pair[1]))); diff --git a/packages/prf/src/value.rs b/packages/prf/src/value.rs index 1dba3bf6..bc54884a 100644 --- a/packages/prf/src/value.rs +++ b/packages/prf/src/value.rs @@ -56,3 +56,92 @@ impl PrfValue for &Value { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_backend::MockPrf, CanonicalError}; + + #[test] + fn value_domains_are_frozen_and_complete() { + let expected = [ + (ValueKind::Int8, "vitaminc/prf/value/int8-orderable/v1"), + (ValueKind::UInt8, "vitaminc/prf/value/uint8-orderable/v1"), + (ValueKind::Int16, "vitaminc/prf/value/int16-orderable/v1"), + (ValueKind::UInt16, "vitaminc/prf/value/uint16-orderable/v1"), + (ValueKind::Int32, "vitaminc/prf/value/int32-orderable/v1"), + (ValueKind::UInt32, "vitaminc/prf/value/uint32-orderable/v1"), + (ValueKind::Int64, "vitaminc/prf/value/int64-orderable/v1"), + (ValueKind::UInt64, "vitaminc/prf/value/uint64-orderable/v1"), + (ValueKind::Int128, "vitaminc/prf/value/int128-orderable/v1"), + ( + ValueKind::UInt128, + "vitaminc/prf/value/uint128-orderable/v1", + ), + ( + ValueKind::Float32, + "vitaminc/prf/value/float32-orderable/v1", + ), + ( + ValueKind::Float64, + "vitaminc/prf/value/float64-orderable/v1", + ), + (ValueKind::Date, "vitaminc/prf/value/date-orderable/v1"), + ( + ValueKind::Timestamp, + "vitaminc/prf/value/timestamp-micros-orderable/v1", + ), + ( + ValueKind::Decimal, + "vitaminc/prf/value/decimal-orderable/v1", + ), + ( + ValueKind::String, + "vitaminc/prf/value/text-nfc/unicode-16/v1", + ), + (ValueKind::Bytes, "vitaminc/prf/value/bytes/v1"), + ]; + for (kind, label) in expected { + assert_eq!(PrfEncoding::for_value_kind(kind).unwrap().as_str(), label); + } + for &kind in ValueKind::ALL { + assert_eq!( + PrfEncoding::for_value_kind(kind).is_some(), + expected.iter().any(|(supported, _)| *supported == kind) + ); + } + } + + #[test] + fn borrowed_value_dispatches_canonical_bytes_and_context() { + let backend = MockPrf; + let source = Value::String("e\u{301}".into()); + let equivalent = Value::String("é".into()); + assert_eq!( + (&source).prf(&backend).into_result().unwrap(), + (&equivalent).prf(&backend).into_result().unwrap() + ); + assert_ne!( + (&source) + .prf_with_context(&backend, ("tenant", 1u64)) + .into_result() + .unwrap(), + (&source) + .prf_with_context(&backend, ("tenant", 2u64)) + .into_result() + .unwrap() + ); + assert!(matches!( + (&Value::Bool(false)).prf(&backend).into_result(), + Err(PrfError::Canonical(CanonicalError::UnsupportedKind( + ValueKind::Bool + ))) + )); + assert!(matches!( + (&Value::String("\u{378}".into())) + .prf(&backend) + .into_result(), + Err(PrfError::Canonical(CanonicalError::UnassignedCodePoint)) + )); + } +} From 410a02e7bcfe335cdb2ebf0b2b15438e22a73f67 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 22:20:50 +1100 Subject: [PATCH 03/28] feat(bindings): preserve extended scalar kinds Map JS BigInt through 128 bits and Go integers to exact widths. Add calendar dates, precise timestamps and finite decimal SDK values, with shared Rust/Node/Go vectors and an updated WASM guest. BREAKING CHANGE: JS integer kinds now always decode as BigInt. Go int8, int16, uint8 and uint16 retain their widths instead of widening to 32 bits. Closes #375 --- .github/workflows/crap.yml | 4 + .github/workflows/go.yml | 15 + .github/workflows/mutants.yml | 4 + .github/workflows/test.yml | 4 + Cargo.lock | 4 + bindings/go/vcencrypt/guest/Cargo.lock | 344 +++++- bindings/go/vcencrypt/guest/Cargo.toml | 2 +- bindings/go/vcencrypt/scalars_test.go | 57 + .../go/vcencrypt/wasm/vitaminc_guest.wasm | Bin 142934 -> 151872 bytes bindings/go/vcffi/README.md | 14 + bindings/go/vcffi/codec_test.go | 12 +- bindings/go/vcffi/conformance_test.go | 59 + bindings/go/vcffi/decode.go | 6 +- bindings/go/vcffi/encoder.go | 40 +- bindings/go/vcffi/scalars.go | 124 ++ bindings/go/vcffi/scalars_test.go | 70 ++ bindings/go/vcffi/transport.go | 9 + bindings/go/vcvalue/README.md | 21 +- bindings/go/vcvalue/scalars.go | 199 ++++ bindings/go/vcvalue/scalars_test.go | 79 ++ packages/aead-napi/Cargo.toml | 5 +- packages/aead-napi/README.md | 54 +- packages/aead-napi/src/convert.rs | 155 +-- packages/aead-napi/src/lib.rs | 2 + packages/aead-napi/src/scalar.rs | 296 +++++ packages/aead-napi/src/value.rs | 3 +- packages/aead-napi/tests/addon/.gitignore | 2 + packages/aead-napi/tests/addon/Cargo.lock | 1049 +++++++++++++++++ packages/aead-napi/tests/addon/Cargo.toml | 20 + packages/aead-napi/tests/addon/build.rs | 3 + packages/aead-napi/tests/addon/src/lib.rs | 36 + packages/aead-napi/tests/node.rs | 43 + .../aead-napi/tests/value-conformance.cjs | 49 + packages/aead-value/Cargo.toml | 1 + packages/aead-value/README.md | 39 +- packages/aead-value/tests/conformance.rs | 47 + testdata/README.md | 21 + testdata/value-conformance.json | 295 +++++ 38 files changed, 3047 insertions(+), 140 deletions(-) create mode 100644 bindings/go/vcencrypt/scalars_test.go create mode 100644 bindings/go/vcffi/conformance_test.go create mode 100644 bindings/go/vcffi/scalars.go create mode 100644 bindings/go/vcffi/scalars_test.go create mode 100644 bindings/go/vcvalue/scalars.go create mode 100644 bindings/go/vcvalue/scalars_test.go create mode 100644 packages/aead-napi/src/scalar.rs create mode 100644 packages/aead-napi/tests/addon/.gitignore create mode 100644 packages/aead-napi/tests/addon/Cargo.lock create mode 100644 packages/aead-napi/tests/addon/Cargo.toml create mode 100644 packages/aead-napi/tests/addon/build.rs create mode 100644 packages/aead-napi/tests/addon/src/lib.rs create mode 100644 packages/aead-napi/tests/node.rs create mode 100644 packages/aead-napi/tests/value-conformance.cjs create mode 100644 packages/aead-value/tests/conformance.rs create mode 100644 testdata/README.md create mode 100644 testdata/value-conformance.json diff --git a/.github/workflows/crap.yml b/.github/workflows/crap.yml index a7635193..046c8022 100644 --- a/.github/workflows/crap.yml +++ b/.github/workflows/crap.yml @@ -73,6 +73,10 @@ jobs: # llvm-tools-preview is required by cargo-llvm-cov to produce coverage. components: llvm-tools-preview + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Start LocalStack # Mirrors the test.yml gate: vitaminc-kms' tests call a KMS endpoint on # localhost:4566, so coverage of that crate needs LocalStack running too. diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 57a63dac..b26cfb02 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -38,6 +38,21 @@ jobs: out=$(gofmt -l bindings/go) && if [ -n "$out" ]; then echo "gofmt needed:"; echo "$out"; exit 1; fi + - uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: setup Rust for Node addon + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.96.0 + + - name: Node value conformance + run: | + cargo build --locked --manifest-path packages/aead-napi/tests/addon/Cargo.toml && + cp packages/aead-napi/tests/addon/target/debug/libvitaminc_napi_test_addon.so packages/aead-napi/tests/addon/addon.node && + node packages/aead-napi/tests/value-conformance.cjs packages/aead-napi/tests/addon/addon.node + - name: vet (vcvalue) working-directory: bindings/go/vcvalue run: go vet ./... diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 498165ca..7b6ad696 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -72,6 +72,10 @@ jobs: - name: setup rust stable uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Start LocalStack # The unmutated baseline runs the whole test suite, and vitaminc-kms' # tests call a KMS endpoint on localhost:4566 — same as the test.yml and diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8af5e5e8..9c018d9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,10 @@ jobs: components: clippy, rustfmt targets: wasm32-unknown-unknown + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Start LocalStack uses: LocalStack/setup-localstack@v0.3.2 with: diff --git a/Cargo.lock b/Cargo.lock index a1eb3a02..edb1c38c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2657,7 +2657,10 @@ dependencies = [ name = "vitaminc-aead-napi" version = "0.5.1" dependencies = [ + "chrono", "napi", + "rust_decimal", + "thiserror", "vitaminc-aead", "vitaminc-aead-value", "vitaminc-protected", @@ -2673,6 +2676,7 @@ dependencies = [ "quickcheck", "quickcheck_macros", "rust_decimal", + "serde_json", "unicode-general-category", "unicode-normalization", "vitaminc-aead", diff --git a/bindings/go/vcencrypt/guest/Cargo.lock b/bindings/go/vcencrypt/guest/Cargo.lock index 9db52df4..60b69541 100644 --- a/bindings/go/vcencrypt/guest/Cargo.lock +++ b/bindings/go/vcencrypt/guest/Cargo.lock @@ -38,6 +38,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "aws-lc-rs" version = "1.17.3" @@ -83,6 +95,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -116,6 +152,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -124,10 +166,19 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -225,6 +276,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -253,6 +310,29 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -262,8 +342,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -277,6 +357,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -286,6 +372,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" @@ -295,13 +391,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jobserver" version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] @@ -321,12 +423,27 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "mutants" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -339,6 +456,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "orderable-bytes" +version = "0.1.1" +source = "git+https://github.com/cipherstash/ore.rs?rev=b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5#b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5" +dependencies = [ + "chrono", + "rust_decimal", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -357,6 +483,24 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -375,6 +519,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -387,6 +537,27 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -394,8 +565,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom", - "rand_core", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -404,6 +613,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -450,6 +676,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" @@ -510,6 +749,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.16+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" +dependencies = [ + "winnow", +] + [[package]] name = "typenum" version = "1.20.1" @@ -570,6 +839,9 @@ dependencies = [ name = "vitaminc-aead-value" version = "0.5.1" dependencies = [ + "chrono", + "orderable-bytes", + "rust_decimal", "vitaminc-aead", "vitaminc-protected", "zeroize", @@ -624,8 +896,8 @@ name = "vitaminc-random" version = "0.5.1" dependencies = [ "chacha20", - "getrandom", - "rand", + "getrandom 0.4.3", + "rand 0.10.2", "thiserror", "vitaminc-protected", "vitaminc-random-derives", @@ -652,6 +924,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -697,6 +984,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wyz" version = "0.5.1" @@ -706,6 +1008,26 @@ dependencies = [ "tap", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -725,3 +1047,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bindings/go/vcencrypt/guest/Cargo.toml b/bindings/go/vcencrypt/guest/Cargo.toml index 18c42096..ab7b176a 100644 --- a/bindings/go/vcencrypt/guest/Cargo.toml +++ b/bindings/go/vcencrypt/guest/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] vitaminc-aead = { path = "../../../../packages/aead" } -vitaminc-aead-value = { path = "../../../../packages/aead-value" } +vitaminc-aead-value = { path = "../../../../packages/aead-value", features = ["chrono", "rust_decimal"] } vitaminc-encrypt = { path = "../../../../packages/encrypt" } vitaminc-protected = { path = "../../../../packages/protected" } zeroize = "1" diff --git a/bindings/go/vcencrypt/scalars_test.go b/bindings/go/vcencrypt/scalars_test.go new file mode 100644 index 00000000..430fc5b7 --- /dev/null +++ b/bindings/go/vcencrypt/scalars_test.go @@ -0,0 +1,57 @@ +package vcencrypt_test + +import ( + "encoding/hex" + "encoding/json" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "os" + "testing" +) + +func TestScalarCorpusThroughWasm(t *testing.T) { + data, err := os.ReadFile("../../../testdata/value-conformance.json") + if err != nil { + t.Fatal(err) + } + var corpus struct { + Vectors []struct { + Name, Transport string + GoReencode string `json:"go_reencode"` + } + } + if err := json.Unmarshal(data, &corpus); err != nil { + t.Fatal(err) + } + cipher := newCipher(t) + for _, row := range corpus.Vectors { + t.Run(row.Name, func(t *testing.T) { + raw, err := hex.DecodeString(row.Transport) + if err != nil { + t.Fatal(err) + } + value, err := vcffi.Unmarshal(raw) + if err != nil { + t.Fatal(err) + } + sealed, err := cipher.Encrypt(t.Context(), value, []byte("shared-corpus")) + if err != nil { + t.Fatal(err) + } + decoded, err := cipher.Decrypt(t.Context(), sealed, []byte("shared-corpus")) + if err != nil { + t.Fatal(err) + } + encoded, err := vcffi.Marshal(decoded) + if err != nil { + t.Fatal(err) + } + want := row.Transport + if row.GoReencode != "" { + want = row.GoReencode + } + if hex.EncodeToString(encoded) != want { + t.Fatalf("got %x, want %s", encoded, want) + } + }) + } +} diff --git a/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm b/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm index af5a63ebd38c2481d6936b25a8422b10fee444a5..2974e29f9b91ca234d1355fa0584867e0e70c7ae 100755 GIT binary patch delta 47418 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�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 delta 40488 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 G8UG*DoJcJI diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index 525a8c47..7224975b 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -143,3 +143,17 @@ var stackLeaves = vcffi.LeafSet{ (`LeafSingle`, `LeafNone`, `LeafEmptySeq`, `LeafEmptyMap`); returning nil for a kind the wire carries fails the decode attributably rather than injecting an untyped nil. + +## Scalar mappings and shared vectors + +Fixed integer widths remain exact (`int8`→Int8, `uint16`→UInt16, and so on). +`vcvalue.Int128`, `Uint128`, `Date` and `Decimal` are intercepted before +reflection, including inside structs, pointers and containers. `time.Time` +encodes a nanosecond Timestamp; leap-second timestamps decode into +`vcvalue.Timestamp` so they can be re-encoded without loss. Matching explicit +Encoder channels are available for custom `Encryptable` implementations. + +The codec reads `testdata/value-conformance.json` from the repo root directly, +as do Rust and Node. Invalid scalar widths, date ranges, nanoseconds, decimal +flags and scales return `ErrMalformed`. Value construction errors retain the +typed `vcvalue` errors. The broader #332 guest-object corpus remains separate. diff --git a/bindings/go/vcffi/codec_test.go b/bindings/go/vcffi/codec_test.go index cdebce1a..660dae9e 100644 --- a/bindings/go/vcffi/codec_test.go +++ b/bindings/go/vcffi/codec_test.go @@ -35,12 +35,12 @@ func TestReflectNatives(t *testing.T) { {"nil", nil, nil}, {"bool", true, true}, {"int", 42, int64(42)}, // int → Int64 - {"int8", int8(-5), int32(-5)}, // narrow signed → Int32 - {"int16", int16(-300), int32(-300)}, // narrow signed → Int32 + {"int8", int8(-5), int8(-5)}, // exact signed width + {"int16", int16(-300), int16(-300)}, // exact signed width {"int32", int32(-9), int32(-9)}, // Int32, exact width {"int64", int64(-9), int64(-9)}, // Int64 - {"uint8", uint8(7), uint32(7)}, // narrow unsigned → UInt32 - {"uint16", uint16(300), uint32(300)}, // narrow unsigned → UInt32 + {"uint8", uint8(7), uint8(7)}, // exact unsigned width + {"uint16", uint16(300), uint16(300)}, // exact unsigned width {"uint32", uint32(7), uint32(7)}, // UInt32, exact width {"uint", uint(7), uint64(7)}, // bare uint → UInt64 (no fit-check) {"uint64-small", uint64(7), uint64(7)}, // uint64 → UInt64 @@ -1005,8 +1005,8 @@ func TestFramingTags(t *testing.T) { } } for _, old := range []byte{0x10, 0x11, 0x12} { - if _, err := Unmarshal([]byte{old, 0, 0, 0, 0}); err == nil { - t.Fatalf("old framing tag %x accepted", old) + if _, err := Unmarshal([]byte{old}); err == nil { + t.Fatalf("truncated scalar tag %x accepted", old) } } } diff --git a/bindings/go/vcffi/conformance_test.go b/bindings/go/vcffi/conformance_test.go new file mode 100644 index 00000000..3e2e39ae --- /dev/null +++ b/bindings/go/vcffi/conformance_test.go @@ -0,0 +1,59 @@ +package vcffi + +import ( + "encoding/hex" + "encoding/json" + "os" + "testing" +) + +func TestSharedValueConformance(t *testing.T) { + data, err := os.ReadFile("../../../testdata/value-conformance.json") + if err != nil { + t.Fatal(err) + } + var corpus struct { + Vectors []struct { + Name, Transport string + GoReencode string `json:"go_reencode"` + } + Malformed []struct{ Name, Transport string } + } + if err := json.Unmarshal(data, &corpus); err != nil { + t.Fatal(err) + } + for _, row := range corpus.Vectors { + t.Run(row.Name, func(t *testing.T) { + raw, err := hex.DecodeString(row.Transport) + if err != nil { + t.Fatal(err) + } + value, err := Unmarshal(raw) + if err != nil { + t.Fatal(err) + } + encoded, err := Marshal(value) + if err != nil { + t.Fatal(err) + } + want := row.Transport + if row.GoReencode != "" { + want = row.GoReencode + } + if hex.EncodeToString(encoded) != want { + t.Fatalf("got %x, want %s (%#v)", encoded, want, value) + } + }) + } + for _, row := range corpus.Malformed { + t.Run(row.Name, func(t *testing.T) { + raw, err := hex.DecodeString(row.Transport) + if err != nil { + t.Fatal(err) + } + if _, err := Unmarshal(raw); err != ErrMalformed { + t.Fatalf("got %v, want ErrMalformed", err) + } + }) + } +} diff --git a/bindings/go/vcffi/decode.go b/bindings/go/vcffi/decode.go index 6f202dc2..b49fd1c4 100644 --- a/bindings/go/vcffi/decode.go +++ b/bindings/go/vcffi/decode.go @@ -24,8 +24,8 @@ import ( // - Passthrough → vcvalue.Plain{V: } (the clear-field marker). // // Go maps exactly in both directions — the 32-bit tags decode to int32 / -// uint32 / float32, not widened to 64-bit — unlike JavaScript, which widens -// every numeric tag to a JS number on decode. +// uint32 / float32, not widened to 64-bit. JavaScript decodes integer tags +// as BigInt and widens Float32 to a number. // // This self-describing shape is deliberately spike-scoped. A reflection-based // unmarshal into caller structs (the mirror of Encode) is future work. @@ -50,6 +50,8 @@ func decodeValue(r *reader, depth int) (any, error) { return nil, err } switch tag { + case tagInt8, tagUint8, tagInt16, tagUint16, tagInt128, tagUint128, tagDate, tagTimestamp, tagDecimal: + return decodeScalar(r, tag) case tagNull, tagUndefined: return nil, nil case tagFalse: diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index d91f887f..5b2dd000 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -8,6 +8,7 @@ import ( "reflect" "slices" "strings" + "time" "unicode/utf8" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" @@ -164,8 +165,7 @@ func (e Encoder) UInt32(u uint32) { e.complete() } -// UInt64 records an unsigned 64-bit integer. Values above math.MaxInt64 are -// representable here and nowhere else in the model. +// UInt64 records an unsigned 64-bit integer without changing its width. func (e Encoder) UInt64(u uint64) { if e.st.err != nil { return @@ -440,9 +440,9 @@ func Marshal(v any) ([]byte, error) { // - a value implementing Encryptable uses its EncryptValue; // - nil → Null; // - bool → Bool; -// - int8/int16/int32 → Int32; +// - int8/int16/int32 → Int8/Int16/Int32; // - int/int64 → Int64; -// - uint8/uint16/uint32 → UInt32; +// - uint8/uint16/uint32 → UInt8/UInt16/UInt32; // - uint/uint64/uintptr → UInt64; // - float32 → Float32; // - float64 → Float64; @@ -499,6 +499,24 @@ func encodeAny(enc Encoder, v any) { // in typical implementations; it encodes as Null like every other nil. func interceptMarkers(enc Encoder, v any) bool { switch x := v.(type) { + case vcvalue.Int128: + enc.Int128(x) + return true + case vcvalue.Uint128: + enc.UInt128(x) + return true + case vcvalue.Date: + enc.Date(x) + return true + case time.Time: + enc.Timestamp(x) + return true + case vcvalue.Timestamp: + enc.TimestampParts(x) + return true + case vcvalue.Decimal: + enc.Decimal(x) + return true case vcvalue.Plain: encodeAny(enc.Passthrough(), x.V) return true @@ -547,13 +565,19 @@ func encodeReflect(enc Encoder, rv reflect.Value) { switch rv.Kind() { case reflect.Bool: enc.Bool(rv.Bool()) - case reflect.Int8, reflect.Int16, reflect.Int32: - // Narrower signed kinds map to the 32-bit tag (schema fidelity with - // int4); int/int64 keep 64-bit width. + case reflect.Int8: + enc.Int8(int8(rv.Int())) + case reflect.Int16: + enc.Int16(int16(rv.Int())) + case reflect.Int32: enc.Int32(int32(rv.Int())) case reflect.Int, reflect.Int64: enc.Int64(rv.Int()) - case reflect.Uint8, reflect.Uint16, reflect.Uint32: + case reflect.Uint8: + enc.UInt8(uint8(rv.Uint())) + case reflect.Uint16: + enc.UInt16(uint16(rv.Uint())) + case reflect.Uint32: enc.UInt32(uint32(rv.Uint())) case reflect.Uint, reflect.Uint64, reflect.Uintptr: // Every unsigned word-or-wider kind keeps its unsigned identity as diff --git a/bindings/go/vcffi/scalars.go b/bindings/go/vcffi/scalars.go new file mode 100644 index 00000000..000800a1 --- /dev/null +++ b/bindings/go/vcffi/scalars.go @@ -0,0 +1,124 @@ +package vcffi + +import ( + "encoding/binary" + "time" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +func (e Encoder) scalar(tag byte, payload []byte) { + if e.st.err != nil { + return + } + e.push(tag) + e.push(payload...) + e.complete() +} + +func (e Encoder) Int8(v int8) { e.scalar(tagInt8, []byte{byte(v)}) } +func (e Encoder) UInt8(v uint8) { e.scalar(tagUint8, []byte{v}) } +func (e Encoder) Int16(v int16) { e.scalar(tagInt16, binary.LittleEndian.AppendUint16(nil, uint16(v))) } +func (e Encoder) UInt16(v uint16) { e.scalar(tagUint16, binary.LittleEndian.AppendUint16(nil, v)) } +func (e Encoder) Int128(v vcvalue.Int128) { e.scalar(tagInt128, v[:]) } +func (e Encoder) UInt128(v vcvalue.Uint128) { e.scalar(tagUint128, v[:]) } + +func (e Encoder) Date(v vcvalue.Date) { + if e.st.err != nil { + return + } + days, err := v.DaysFromCE() + if err != nil { + e.st.fail(err) + return + } + e.scalar(tagDate, binary.LittleEndian.AppendUint32(nil, uint32(days))) +} + +// Timestamp keeps the instant and nanoseconds, discarding location and monotonic metadata. +func (e Encoder) Timestamp(v time.Time) { + e.TimestampParts(vcvalue.Timestamp{Seconds: v.Unix(), Nanoseconds: uint32(v.Nanosecond())}) +} +func (e Encoder) TimestampParts(v vcvalue.Timestamp) { + if e.st.err != nil { + return + } + if err := v.Validate(); err != nil { + e.st.fail(err) + return + } + b := binary.LittleEndian.AppendUint64(nil, uint64(v.Seconds)) + b = binary.LittleEndian.AppendUint32(b, v.Nanoseconds) + e.scalar(tagTimestamp, b) +} + +func (e Encoder) Decimal(v vcvalue.Decimal) { + if e.st.err != nil { + return + } + if err := v.Validate(); err != nil { + e.st.fail(err) + return + } + flags := uint32(v.Scale) << 16 + if v.Negative { + flags |= 0x80000000 + } + b := binary.LittleEndian.AppendUint32(nil, flags) + b = append(b, v.Coefficient[:]...) + e.scalar(tagDecimal, b) +} + +func decodeScalar(r *reader, tag byte) (any, error) { + widths := map[byte]int{tagInt8: 1, tagUint8: 1, tagInt16: 2, tagUint16: 2, tagDate: 4, tagTimestamp: 12, tagInt128: 16, tagUint128: 16, tagDecimal: 16} + width, ok := widths[tag] + if !ok { + return nil, ErrMalformed + } + b, err := r.take(width) + if err != nil { + return nil, err + } + switch tag { + case tagInt8: + return int8(b[0]), nil + case tagUint8: + return b[0], nil + case tagInt16: + return int16(binary.LittleEndian.Uint16(b)), nil + case tagUint16: + return binary.LittleEndian.Uint16(b), nil + case tagInt128: + return vcvalue.Int128(b), nil + case tagUint128: + return vcvalue.Uint128(b), nil + case tagDate: + d, err := vcvalue.DateFromDaysCE(int32(binary.LittleEndian.Uint32(b))) + if err != nil { + return nil, ErrMalformed + } + return d, nil + case tagTimestamp: + t := vcvalue.Timestamp{Seconds: int64(binary.LittleEndian.Uint64(b)), Nanoseconds: binary.LittleEndian.Uint32(b[8:])} + if t.Validate() != nil { + return nil, ErrMalformed + } + if t.Nanoseconds >= 1000000000 { + return t, nil + } + return time.Unix(t.Seconds, int64(t.Nanoseconds)).UTC(), nil + case tagDecimal: + flags := binary.LittleEndian.Uint32(b) + if flags&0x7f00ffff != 0 { + return nil, ErrMalformed + } + d := vcvalue.Decimal{Scale: uint8(flags >> 16), Negative: flags>>31 != 0} + copy(d.Coefficient[:], b[4:]) + if d.Validate() != nil { + return nil, ErrMalformed + } + return d, nil + default: + return nil, ErrMalformed + } +} diff --git a/bindings/go/vcffi/scalars_test.go b/bindings/go/vcffi/scalars_test.go new file mode 100644 index 00000000..7bf91f63 --- /dev/null +++ b/bindings/go/vcffi/scalars_test.go @@ -0,0 +1,70 @@ +package vcffi + +import ( + "encoding/hex" + "errors" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "reflect" + "testing" + "time" +) + +func TestScalarHostEncoding(t *testing.T) { + signed, err := vcvalue.ParseInt128("-170141183460469231731687303715884105728") + if err != nil { + t.Fatal(err) + } + unsigned, err := vcvalue.ParseUint128("340282366920938463463374607431768211455") + if err != nil { + t.Fatal(err) + } + decimal, err := vcvalue.ParseDecimal("1.50") + if err != nil { + t.Fatal(err) + } + cases := []struct { + value any + wire string + }{ + {int8(-128), "0c80"}, {uint8(255), "0dff"}, {int16(-32768), "0e0080"}, {uint16(65535), "0fffff"}, + {signed, "1000000000000000000000000000000080"}, {unsigned, "11ffffffffffffffffffffffffffffffff"}, + {vcvalue.Date{Year: 1, Month: 1, Day: 1}, "1201000000"}, + {time.Unix(0, 123456789).UTC(), "13000000000000000015cd5b07"}, + {decimal, "1400000200960000000000000000000000"}, + } + for _, tc := range cases { + encoded, err := Marshal(tc.value) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(encoded) != tc.wire { + t.Fatalf("%T: %x != %s", tc.value, encoded, tc.wire) + } + decoded, err := Unmarshal(encoded) + if err != nil || !reflect.DeepEqual(decoded, tc.value) { + t.Fatalf("%T: got %#v: %v", tc.value, decoded, err) + } + // The intercept also applies through pointers, fields and interfaces. + nested := struct{ Value any }{Value: &tc.value} + if _, err := Marshal(nested); err != nil { + t.Fatal(err) + } + } +} + +func TestInvalidScalarHostEncoding(t *testing.T) { + cases := []struct { + value any + err error + }{ + {vcvalue.Date{Year: 2023, Month: 2, Day: 29}, vcvalue.ErrInvalidDate}, + {vcvalue.Timestamp{Seconds: 0, Nanoseconds: 1000000000}, vcvalue.ErrInvalidTimestamp}, + {time.Date(262143, 1, 1, 0, 0, 0, 0, time.UTC), vcvalue.ErrInvalidTimestamp}, + {vcvalue.Decimal{Scale: 29}, vcvalue.ErrInvalidDecimal}, + } + for _, tc := range cases { + if _, err := Marshal(tc.value); !errors.Is(err, tc.err) { + t.Fatalf("got %v, want %v", err, tc.err) + } + } +} diff --git a/bindings/go/vcffi/transport.go b/bindings/go/vcffi/transport.go index bf0ffb7e..2e1dedd2 100644 --- a/bindings/go/vcffi/transport.go +++ b/bindings/go/vcffi/transport.go @@ -26,6 +26,15 @@ const ( tagFloat64 = 0x09 tagString = 0x0A tagBytes = 0x0B + tagInt8 = 0x0C + tagUint8 = 0x0D + tagInt16 = 0x0E + tagUint16 = 0x0F + tagInt128 = 0x10 + tagUint128 = 0x11 + tagDate = 0x12 + tagTimestamp = 0x13 + tagDecimal = 0x14 tagArray = 0xF0 tagObject = 0xF1 // tagPassthrough wraps one value node that travels in the clear diff --git a/bindings/go/vcvalue/README.md b/bindings/go/vcvalue/README.md index ef5e4815..9ac72377 100644 --- a/bindings/go/vcvalue/README.md +++ b/bindings/go/vcvalue/README.md @@ -91,7 +91,26 @@ bound into its own value's AAD. ## Decoding Decryption (in `vcencrypt`) returns Go natives at the exact numeric widths -(`nil`, `bool`, `int32`, `int64`, `uint32`, `uint64`, `float32`, `float64`, +(`nil`, `bool`, `int8`, `int16`, `int32`, `int64`, `uint8`, `uint16`, `uint32`, `uint64`, `float32`, `float64`, `string`, `[]byte`, `[]any`), this package's ordered `Object` for maps, and `Plain` for passthrough. A reflection-based decode into caller structs (the mirror of the reflection encode) is future work. + + +## Extended scalars + +`Int128` and `Uint128` are 16-byte little-endian values, with exact decimal +`ParseInt128` / `ParseUint128` constructors and `String` methods. Out-of-range +inputs return `ErrIntegerRange`. + +`Date{Year, Month, Day}` is a Gregorian calendar date in chrono's range +(-262143 through 262142). `time.Time` maps to Timestamp with nanoseconds and +decodes in UTC; timezone/location and monotonic metadata are not retained. +`Timestamp{Seconds, Nanoseconds}` retains chrono leap seconds that time.Time +cannot represent. Invalid calendar/timestamp inputs return `ErrInvalidDate` +or `ErrInvalidTimestamp`. + +`ParseDecimal("1.50")` constructs a finite 96-bit coefficient with scale 0–28, +retaining trailing zeroes and signed zero. Exponents and rounding are not +accepted. NaN and infinities return `ErrNonFiniteDecimal`; malformed or +out-of-range text returns `ErrInvalidDecimal`. Match these with `errors.Is`. diff --git a/bindings/go/vcvalue/scalars.go b/bindings/go/vcvalue/scalars.go new file mode 100644 index 00000000..e6876e09 --- /dev/null +++ b/bindings/go/vcvalue/scalars.go @@ -0,0 +1,199 @@ +package vcvalue + +import ( + "errors" + "math/big" + "strings" + "time" +) + +var ( + ErrIntegerRange = errors.New("vcvalue: integer is outside its 128-bit range") + ErrInvalidDate = errors.New("vcvalue: invalid date") + ErrInvalidTimestamp = errors.New("vcvalue: invalid timestamp") + ErrInvalidDecimal = errors.New("vcvalue: invalid or out-of-range decimal") + ErrNonFiniteDecimal = errors.New("vcvalue: decimal must be finite") +) + +// Int128 is a signed two's-complement integer in little-endian byte order. +type Int128 [16]byte + +// Uint128 is an unsigned integer in little-endian byte order. +type Uint128 [16]byte + +func littleInteger(value *big.Int) [16]byte { + var out [16]byte + bytes := value.Bytes() + for i, b := range bytes { + out[len(bytes)-1-i] = b + } + return out +} + +func ParseInt128(s string) (Int128, error) { + value, ok := new(big.Int).SetString(s, 10) + if !ok { + return Int128{}, ErrIntegerRange + } + limit := new(big.Int).Lsh(big.NewInt(1), 127) + if value.Cmp(new(big.Int).Neg(new(big.Int).Set(limit))) < 0 || value.Cmp(limit) >= 0 { + return Int128{}, ErrIntegerRange + } + if value.Sign() < 0 { + value.Add(value, new(big.Int).Lsh(big.NewInt(1), 128)) + } + return Int128(littleInteger(value)), nil +} + +func ParseUint128(s string) (Uint128, error) { + value, ok := new(big.Int).SetString(s, 10) + if !ok || value.Sign() < 0 || value.BitLen() > 128 { + return Uint128{}, ErrIntegerRange + } + return Uint128(littleInteger(value)), nil +} + +func bigInteger(value [16]byte) *big.Int { + var be [16]byte + for i, b := range value { + be[15-i] = b + } + return new(big.Int).SetBytes(be[:]) +} +func (v Uint128) String() string { return bigInteger([16]byte(v)).String() } +func (v Int128) String() string { + value := bigInteger([16]byte(v)) + if v[15]&0x80 != 0 { + value.Sub(value, new(big.Int).Lsh(big.NewInt(1), 128)) + } + return value.String() +} + +// Date is a proleptic Gregorian calendar date, without time or timezone. +// Its range matches chrono::NaiveDate, including year zero and BCE dates. +type Date struct { + Year int + Month time.Month + Day int +} + +func (d Date) Validate() error { + if d.Year < -262143 || d.Year > 262142 || d.Month < 1 || d.Month > 12 || d.Day < 1 || d.Day > 31 { + return ErrInvalidDate + } + t := time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC) + if t.Year() != d.Year || t.Month() != d.Month || t.Day() != d.Day { + return ErrInvalidDate + } + return nil +} + +// DaysFromCE returns the frozen wire day count (0001-01-01 is day 1). +func (d Date) DaysFromCE() (int32, error) { + if err := d.Validate(); err != nil { + return 0, err + } + return int32(time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC).Unix()/86400 + 719163), nil +} + +func DateFromDaysCE(days int32) (Date, error) { + t := time.Unix((int64(days)-719163)*86400, 0).UTC() + d := Date{Year: t.Year(), Month: t.Month(), Day: t.Day()} + return d, d.Validate() +} + +// Timestamp preserves chrono's leap-second representation, which time.Time +// cannot represent. Ordinary timestamps decode to time.Time; leap seconds +// decode to this type. Both encode with the same Timestamp tag. +type Timestamp struct { + Seconds int64 + Nanoseconds uint32 +} + +func (t Timestamp) Validate() error { + min := time.Date(-262143, 1, 1, 0, 0, 0, 0, time.UTC).Unix() + max := time.Date(262142, 12, 31, 23, 59, 59, 0, time.UTC).Unix() + if t.Seconds < min || t.Seconds > max || t.Nanoseconds >= 2000000000 { + return ErrInvalidTimestamp + } + second := (t.Seconds%60 + 60) % 60 + if t.Nanoseconds >= 1000000000 && second != 59 { + return ErrInvalidTimestamp + } + return nil +} + +// Decimal is a finite base-10 value: sign * Coefficient * 10^-Scale. +// Coefficient is an unsigned 96-bit little-endian integer. Scale is retained, +// including trailing zeroes, and must be at most 28 (rust_decimal's range). +type Decimal struct { + Coefficient [12]byte + Scale uint8 + Negative bool +} + +func (d Decimal) Validate() error { + if d.Scale > 28 { + return ErrInvalidDecimal + } + return nil +} + +// ParseDecimal accepts exact fixed-point decimal text without rounding. +// NaN and infinities have their own error so callers can reject them by type. +func ParseDecimal(s string) (Decimal, error) { + switch strings.ToLower(s) { + case "nan", "+nan", "-nan", "inf", "+inf", "-inf", "infinity", "+infinity", "-infinity": + return Decimal{}, ErrNonFiniteDecimal + } + var d Decimal + if strings.HasPrefix(s, "-") { + d.Negative = true + s = s[1:] + } else if strings.HasPrefix(s, "+") { + s = s[1:] + } + parts := strings.Split(s, ".") + if len(parts) > 2 || len(parts[0]) == 0 { + return Decimal{}, ErrInvalidDecimal + } + if len(parts) == 2 { + if len(parts[1]) == 0 || len(parts[1]) > 28 { + return Decimal{}, ErrInvalidDecimal + } + d.Scale = uint8(len(parts[1])) + } + digits := strings.Join(parts, "") + for _, c := range digits { + if c < '0' || c > '9' { + return Decimal{}, ErrInvalidDecimal + } + } + if len(strings.TrimLeft(digits, "0")) > 29 { + return Decimal{}, ErrInvalidDecimal + } + coefficient, ok := new(big.Int).SetString(digits, 10) + if !ok || coefficient.BitLen() > 96 { + return Decimal{}, ErrInvalidDecimal + } + raw := littleInteger(coefficient) + copy(d.Coefficient[:], raw[:12]) + return d, nil +} + +func (d Decimal) String() string { + var raw [16]byte + copy(raw[:12], d.Coefficient[:]) + digits := bigInteger(raw).String() + scale := int(d.Scale) + if scale > 0 { + if len(digits) <= scale { + digits = strings.Repeat("0", scale+1-len(digits)) + digits + } + digits = digits[:len(digits)-scale] + "." + digits[len(digits)-scale:] + } + if d.Negative { + digits = "-" + digits + } + return digits +} diff --git a/bindings/go/vcvalue/scalars_test.go b/bindings/go/vcvalue/scalars_test.go new file mode 100644 index 00000000..1cdfa270 --- /dev/null +++ b/bindings/go/vcvalue/scalars_test.go @@ -0,0 +1,79 @@ +package vcvalue + +import ( + "errors" + "testing" + "time" +) + +func TestInteger128Boundaries(t *testing.T) { + for _, text := range []string{"0", "-1", "170141183460469231731687303715884105727", "-170141183460469231731687303715884105728"} { + v, err := ParseInt128(text) + if err != nil || v.String() != text { + t.Fatalf("%s: %v %s", text, err, v.String()) + } + } + for _, text := range []string{"0", "1", "340282366920938463463374607431768211455"} { + v, err := ParseUint128(text) + if err != nil || v.String() != text { + t.Fatalf("%s: %v %s", text, err, v.String()) + } + } + for _, text := range []string{"bad", "170141183460469231731687303715884105728", "-170141183460469231731687303715884105729"} { + if _, err := ParseInt128(text); !errors.Is(err, ErrIntegerRange) { + t.Fatalf("accepted %s", text) + } + } + for _, text := range []string{"bad", "-1", "340282366920938463463374607431768211456"} { + if _, err := ParseUint128(text); !errors.Is(err, ErrIntegerRange) { + t.Fatalf("accepted %s", text) + } + } +} + +func TestDecimalExactness(t *testing.T) { + for _, text := range []string{"0", "-0.00", "1.50", "-79228162514264337593543950335", "0.0000000000000000000000000001"} { + v, err := ParseDecimal(text) + if err != nil || v.String() != text { + t.Fatalf("%s: %v %s", text, err, v.String()) + } + } + for _, text := range []string{"NaN", "+nan", "-NaN", "inf", "+Infinity", "-Infinity"} { + if _, err := ParseDecimal(text); !errors.Is(err, ErrNonFiniteDecimal) { + t.Fatalf("%s: %v", text, err) + } + } + for _, text := range []string{"", ".1", "1.", "1.2.3", "1e2", "1_0", " 1", "a", "0.00000000000000000000000000001", "79228162514264337593543950336"} { + if _, err := ParseDecimal(text); !errors.Is(err, ErrInvalidDecimal) { + t.Fatalf("accepted %s", text) + } + } +} + +func TestDatesAndTimestampBounds(t *testing.T) { + for _, d := range []Date{{1, 1, 1}, {0, 1, 1}, {2024, 2, 29}, {-262143, 1, 1}, {262142, 12, 31}} { + days, err := d.DaysFromCE() + if err != nil { + t.Fatal(err) + } + got, err := DateFromDaysCE(days) + if err != nil || got != d { + t.Fatalf("%v: %v %v", d, got, err) + } + } + for _, d := range []Date{{2023, 2, 29}, {2024, 0, 1}, {2024, 13, 1}, {2024, 1, 0}, {2024, 1, 32}, {-262144, 1, 1}, {262143, 1, 1}} { + if d.Validate() == nil { + t.Fatalf("accepted %v", d) + } + } + for _, ts := range []Timestamp{{0, 0}, {-1, 999999999}, {59, 1123456789}, {-1, 1999999999}} { + if ts.Validate() != nil { + t.Fatalf("rejected %v", ts) + } + } + for _, ts := range []Timestamp{{0, 1000000000}, {59, 2000000000}, {time.Date(262143, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), 0}, {1<<63 - 1, 0}} { + if ts.Validate() == nil { + t.Fatalf("accepted %v", ts) + } + } +} diff --git a/packages/aead-napi/Cargo.toml b/packages/aead-napi/Cargo.toml index fd8f6f10..bb4da32e 100644 --- a/packages/aead-napi/Cargo.toml +++ b/packages/aead-napi/Cargo.toml @@ -12,8 +12,11 @@ keywords.workspace = true categories.workspace = true [dependencies] +chrono = { version = "0.4.44", default-features = false, features = ["std"] } +rust_decimal = { version = "1.41", default-features = false, features = ["std"] } +thiserror.workspace = true vitaminc-aead = { version = "0.5.1", path = "../aead" } -vitaminc-aead-value = { version = "0.5.1", path = "../aead-value" } +vitaminc-aead-value = { version = "0.5.1", path = "../aead-value", features = ["chrono", "rust_decimal"] } vitaminc-protected = { version = "0.5.1", path = "../protected" } # napi5 (Node ≥ 12) is needed for Date detection; napi6 (implies napi5) for diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index 402e963a..ba561c6a 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -13,14 +13,21 @@ cross-language leaf wire format. This crate adds only what is JS-specific: implementing napi's traits on the foreign type directly). Conversions run on the JS thread; everything past the `#[napi]` boundary works with the bare `Value`, on any thread. - - JS `number` ↔ `Value::Float64` (always — integral JS numbers stay - numbers). JS `BigInt` carries integer typing: fits `i64` → - `Value::Int64`, above `i64::MAX` but fits `u64` → `Value::UInt64`, - larger → rejected. JS has no 32-bit numeric types, so it never encodes - `Int32`/`UInt32`/`Float32`. On decode: `Float32` widens exactly to a JS - `number` (`f64::from(f32)`); `Float64` is a `number`; `Int32`/`UInt32` - are always a `number` (magnitude ≤ 2⁵³); `Int64`/`UInt64` yield a JS - `number` within `Number.MAX_SAFE_INTEGER` and a `BigInt` beyond it. + - JS `number` ↔ `Value::Float64`. `BigInt` selects the smallest fitting + integer width through 128 bits, preferring signed at each width. Every + integer kind decodes as `BigInt`; `Float32` widens exactly to `number`. + - JS `Date` ↔ `Timestamp` at millisecond precision within chrono's range. + Sub-millisecond timestamps, leap seconds and timestamps beyond JS Date's + range decode as `{ timestamp: "RFC3339-with-nanoseconds" }`, also accepted + on input, so no precision is discarded. + - Calendar dates use `{ date: "YYYY-MM-DD" }` (including ISO extended years); + finite decimals use `{ decimal: "1.50" }`, retaining scale and signed zero. + These wrappers reserve exactly one own enumerable property; an object with + additional properties remains an ordinary object. Decimal input is exact + fixed-point text, not a JS number or an arbitrary decimal-library instance. + NaN and infinities throw `TypeError` with code `ERR_NON_FINITE_DECIMAL`. + Other scalar failures have stable `ERR_INTEGER_RANGE`, `ERR_INVALID_DATE`, + `ERR_INVALID_TIMESTAMP` and `ERR_INVALID_DECIMAL` codes. - **`JsCipherText`** — projects the generic `CipherText` container onto plain JS values (`{ t, v }` nodes with `Buffer` leaves) and back, as an in-memory/application-side representation. Durable cross-language @@ -31,23 +38,22 @@ it is not itself loadable from Node. ## Testing the conversion layer -The `Encrypt`/`Decrypt` impls and the value tree are covered by ordinary Rust -unit tests. The JS-boundary conversion functions — `js_to_value`, -`value_to_js`, `node_to_js`, `node_from_js` — and their helpers — -`own_enumerable_keys`, `get_property_unknown`, `ensure_plain_object`, -`define_own_property` — are -**not**, and cannot be: each takes a live `napi_env`, `Unknown`, or `Object`, -which only exists inside a running V8 isolate. The `napi/noop` dev-dependency stubs those -symbols so the crate links under `cargo test`; it does not make the calls -work. They are therefore exempted in `.cargo-crap.toml` and -`.cargo/mutants.toml`, by name rather than by file so the rest of those -modules stays gated. +`cargo test -p vitaminc-aead-napi` runs pure Rust boundary tests and builds a +small standalone addon to run `tests/value-conformance.cjs` inside Node. +Node.js must be available on PATH. The addon is a separate Cargo workspace so +the library's `napi/noop` dev feature cannot stub the real Node symbols. -That exemption is a deferral, not a dismissal — it is the difference between -"cannot be reached by this harness" and "does not need testing". Covering -them needs JS-level tests driving a built addon, which should land alongside -the first `#[napi]` entry points that consume this crate. Until then, treat -changes to those two modules as unguarded by CI and review them by hand. +Rust, Node, Go's codec, and the embedded WASM guest consume the same +`testdata/value-conformance.json` vectors. They pin all scalar tags, numeric +boundaries, calendar/decimal payloads and hostile-input refusals. JS integer +host values round-trip as BigInt; Float32 becomes a JS number and re-encodes +as Float64. Go retains its established undefined-to-nil projection. These two +host projections are explicit fields in the corpus; Rust codec vectors always +round-trip byte-for-byte. + +The existing named metric exemptions for live N-API entry points remain; +new pure scalar conversion helpers participate in coverage and mutation gates. +The Node test runs in package-scoped mutation tests as well as CI. ## Safety notes diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index 6c52ffe7..fb5e999a 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -6,22 +6,11 @@ //! //! # Numeric mapping //! -//! JS `number` always converts to [`Value::Float64`] — integral JS -//! numbers do **not** become an integer variant, preserving JS semantics -//! (`42` and `42.0` are the same value in JS). JS `BigInt` carries integer -//! typing: one that fits `i64` converts to [`Value::Int64`]; one above -//! `i64::MAX` that still fits `u64` converts to [`Value::UInt64`]; -//! anything larger is rejected. JS has no 32-bit numeric types, so it -//! **never** encodes [`Value::Int32`], [`Value::UInt32`], or -//! [`Value::Float32`]. -//! -//! On the way out, values written by other languages (Python, Go) surface -//! losslessly: [`Value::Float32`] widens exactly to a JS `number` -//! (`f64::from(f32)`); [`Value::Float64`] is a `number`; -//! [`Value::Int32`] / [`Value::UInt32`] are always a `number` (their -//! magnitude never exceeds 2⁵³); [`Value::Int64`] / [`Value::UInt64`] -//! become a `number` when within `Number.MAX_SAFE_INTEGER` (2⁵³ − 1 in -//! magnitude) and a `BigInt` otherwise. +//! JS `number` converts to `Float64`. BigInt uses the smallest fitting +//! integer width, preferring signed at a given width, through 128 bits. +//! Every integer kind decodes as BigInt. Date maps to Timestamp; calendar +//! dates use `{ date: "YYYY-MM-DD" }` and decimals `{ decimal: "1.50" }`. +//! Timestamp values beyond Date's precision/range use an RFC3339 wrapper. use napi::bindgen_prelude::{ Array, BigInt, Buffer, FromNapiValue, JsObjectValue, JsValue, Object, ToNapiValue, Uint8Array, @@ -31,6 +20,7 @@ use napi::{sys, Env, Error, Result, Status, ValueType}; use vitaminc_aead_value::Value; use vitaminc_protected::{Controlled, Protected}; +use crate::scalar; use crate::value::NapiValue; /// Maximum nesting depth accepted when converting a JS value tree (and when @@ -38,10 +28,6 @@ use crate::value::NapiValue; /// overflow the stack. pub(crate) const MAX_DEPTH: usize = 128; -/// The largest integer JS numbers represent exactly: 2⁵³ − 1 -/// (`Number.MAX_SAFE_INTEGER`). -const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1; - /// Property names that must never be read from or written onto a plain JS /// object: assigning them via `napi_set_property` triggers prototype-chain /// semantics (prototype pollution), and accepting them on input would let a @@ -245,23 +231,9 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { ValueType::Boolean => Ok(Value::Bool(bool::from_unknown(unknown)?)), ValueType::Number => Ok(Value::Float64(f64::from_unknown(unknown)?)), ValueType::BigInt => { + let env = Env::from_raw(unknown.value().env); let big = BigInt::from_unknown(unknown)?; - // Signed first: a BigInt that fits `i64` is an `Int64`. - let (signed, i64_lossless) = big.get_i64(); - if i64_lossless { - return Ok(Value::Int64(signed)); - } - // Otherwise try unsigned: `get_u64` reports lossless only when the - // value is non-negative and fits in a single 64-bit word, so a - // positive BigInt in (i64::MAX, u64::MAX] lands here as `UInt64`. - let (_sign, unsigned, u64_lossless) = big.get_u64(); - if u64_lossless { - return Ok(Value::UInt64(unsigned)); - } - Err(Error::new( - Status::InvalidArg, - "BigInt value does not fit in a 64-bit integer", - )) + scalar::bigint(big.sign_bit, &big.words).map_err(|e| conversion_error(env, e)) } ValueType::String => { // Fetch as UTF-16 (JS's native representation) and convert with @@ -316,14 +288,45 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { } Ok(Value::Array(items)) } else if unknown.is_date()? { - Err(Error::new( - Status::InvalidArg, - "Date values are not supported yet; pass a number or ISO string", - )) + let env = Env::from_raw(unknown.value().env); + let date = napi::JsDate::from_unknown(unknown)?; + scalar::date_millis(date.value_of()?).map_err(|e| conversion_error(env, e)) } else { let obj = Object::from_unknown(unknown)?; ensure_plain_object(&obj)?; let keys = own_enumerable_keys(&obj)?; + if keys.len() == 1 { + let parser = match keys[0].as_str() { + "date" => Some( + scalar::date + as fn(&str) -> std::result::Result, + ), + "timestamp" => Some( + scalar::timestamp + as fn(&str) -> std::result::Result, + ), + "decimal" => Some( + scalar::decimal + as fn(&str) -> std::result::Result, + ), + _ => None, + }; + if let Some(parse) = parser { + let input = get_property_unknown(&obj, &keys[0])?; + if keys[0] == "decimal" && input.get_type()? == ValueType::Number { + let number = f64::from_unknown(input)?; + let error = if number.is_finite() { + scalar::ConversionError::InvalidDecimal + } else { + scalar::ConversionError::NonFiniteDecimal + }; + return Err(conversion_error(Env::from_raw(obj.value().env), error)); + } + let text = String::from_unknown(input)?; + return parse(&text) + .map_err(|e| conversion_error(Env::from_raw(obj.value().env), e)); + } + } let mut entries = Vec::with_capacity(keys.len()); for key in keys { if forbidden_key(&key) { @@ -361,30 +364,33 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { // Both float widths surface as a JS `number`; `f32` widens exactly. Value::Float64(n) => unsafe { f64::to_napi_value(env, n) }, Value::Float32(f) => unsafe { f64::to_napi_value(env, f64::from(f)) }, - // 32-bit integers always fit `Number.MAX_SAFE_INTEGER`, so they are - // always a plain JS `number`. - Value::Int32(i) => unsafe { f64::to_napi_value(env, i as f64) }, - Value::UInt32(u) => unsafe { f64::to_napi_value(env, u as f64) }, - Value::Int64(i) => { - // Exactly representable → plain number (the common case, and - // what JS callers expect for e.g. a Python-written `30`). - // Beyond ±MAX_SAFE_INTEGER → BigInt, losslessly. `unsigned_abs` - // (not `abs`) so `i64::MIN` — whose magnitude has no `i64` - // representation — takes the BigInt branch instead of panicking - // (debug) or wrapping negative and losing precision (release). - if i.unsigned_abs() <= MAX_SAFE_INTEGER as u64 { - unsafe { f64::to_napi_value(env, i as f64) } - } else { - unsafe { BigInt::to_napi_value(env, BigInt::from(i)) } - } - } - Value::UInt64(u) => { - // Same rule as `Int64`: exactly representable → plain number, else - // BigInt. `MAX_SAFE_INTEGER` is non-negative so the cast is safe. - if u <= MAX_SAFE_INTEGER as u64 { - unsafe { f64::to_napi_value(env, u as f64) } + Value::Int8(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, + Value::UInt8(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, + Value::Int16(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, + Value::UInt16(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, + Value::Int32(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, + Value::UInt32(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, + Value::Int64(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, + Value::UInt64(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, + Value::Int128(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, + Value::UInt128(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, + Value::Date(v) => wrapper_to_js(env, "date", v.to_string()), + Value::Decimal(v) => wrapper_to_js(env, "decimal", v.to_string()), + Value::Timestamp(v) => { + if v.timestamp_subsec_nanos() < 1_000_000_000 + && v.timestamp_subsec_nanos() % 1_000_000 == 0 + && v.timestamp_millis().unsigned_abs() <= 8_640_000_000_000_000 + { + Ok(Env::from_raw(env) + .create_date(v.timestamp_millis() as f64)? + .value() + .value) } else { - unsafe { BigInt::to_napi_value(env, BigInt::from(u)) } + wrapper_to_js( + env, + "timestamp", + v.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true), + ) } } Value::String(s) => { @@ -433,6 +439,21 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { } } +fn conversion_error(env: Env, error: scalar::ConversionError) -> Error { + match env.throw_type_error(&error.to_string(), Some(error.code())) { + Ok(()) => Error::new(Status::PendingException, error.to_string()), + Err(error) => error, + } +} + +fn wrapper_to_js(env: sys::napi_env, key: &str, text: String) -> Result { + let raw_env = Env::from_raw(env); + let obj = Object::new(&raw_env)?; + let value = unsafe { String::to_napi_value(env, text) }?; + define_own_property(&obj, key, value)?; + unsafe { Object::to_napi_value(env, obj) } +} + /// Internal newtype so recursive positions (array elements, object values) /// can go through `ToNapiValue` without exposing a blanket recursive impl /// signature difference. @@ -452,7 +473,7 @@ impl ToNapiValue for NapiValue { #[cfg(test)] mod tests { - use super::{eager_capacity, forbidden_key, MAX_EAGER_CAPACITY, MAX_SAFE_INTEGER}; + use super::{eager_capacity, forbidden_key, MAX_EAGER_CAPACITY}; #[test] fn forbidden_keys_are_rejected() { @@ -464,12 +485,6 @@ mod tests { assert!(!forbidden_key("")); } - #[test] - fn max_safe_integer_matches_js() { - // Number.MAX_SAFE_INTEGER - assert_eq!(MAX_SAFE_INTEGER, 9_007_199_254_740_991); - } - #[test] fn eager_capacity_passes_small_lengths_through() { assert_eq!(eager_capacity(0), 0); diff --git a/packages/aead-napi/src/lib.rs b/packages/aead-napi/src/lib.rs index ac599782..cd986719 100644 --- a/packages/aead-napi/src/lib.rs +++ b/packages/aead-napi/src/lib.rs @@ -2,7 +2,9 @@ #![doc = include_str!("../README.md")] mod ciphertext; mod convert; +mod scalar; mod value; +pub use scalar::ConversionError; pub use ciphertext::JsCipherText; pub use value::NapiValue; diff --git a/packages/aead-napi/src/scalar.rs b/packages/aead-napi/src/scalar.rs new file mode 100644 index 00000000..08db117b --- /dev/null +++ b/packages/aead-napi/src/scalar.rs @@ -0,0 +1,296 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use rust_decimal::Decimal; +use vitaminc_aead_value::Value; + +/// Host scalar conversion errors. Node receives a TypeError with the same +/// stable code; errors contain no user plaintext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ConversionError { + #[error("BigInt is outside the signed/unsigned 128-bit range")] + IntegerRange, + #[error("invalid calendar date")] + InvalidDate, + #[error("invalid or out-of-range timestamp")] + InvalidTimestamp, + #[error("invalid or out-of-range decimal")] + InvalidDecimal, + #[error("decimal must be finite")] + NonFiniteDecimal, +} + +impl ConversionError { + pub fn code(self) -> &'static str { + match self { + Self::IntegerRange => "ERR_INTEGER_RANGE", + Self::InvalidDate => "ERR_INVALID_DATE", + Self::InvalidTimestamp => "ERR_INVALID_TIMESTAMP", + Self::InvalidDecimal => "ERR_INVALID_DECIMAL", + Self::NonFiniteDecimal => "ERR_NON_FINITE_DECIMAL", + } + } +} + +pub(crate) fn bigint(negative: bool, words: &[u64]) -> Result { + if words + .get(2..) + .is_some_and(|tail| tail.iter().any(|&word| word != 0)) + { + return Err(ConversionError::IntegerRange); + } + let magnitude = u128::from(words.first().copied().unwrap_or(0)) + .wrapping_add(u128::from(words.get(1).copied().unwrap_or(0)) << 64); + if negative && magnitude != 0 { + if magnitude > (1u128 << 127) { + return Err(ConversionError::IntegerRange); + } + Ok(signed((magnitude as i128).wrapping_neg())) + } else { + Ok(unsigned(magnitude)) + } +} + +fn signed(value: i128) -> Value { + if let Ok(v) = i8::try_from(value) { + Value::Int8(v) + } else if let Ok(v) = i16::try_from(value) { + Value::Int16(v) + } else if let Ok(v) = i32::try_from(value) { + Value::Int32(v) + } else if let Ok(v) = i64::try_from(value) { + Value::Int64(v) + } else { + Value::Int128(value) + } +} + +fn unsigned(value: u128) -> Value { + // Smallest width first, preferring signed when both signed and unsigned + // at that width fit. This rule is frozen for JS BigInt host conversion. + if let Ok(v) = i8::try_from(value) { + Value::Int8(v) + } else if let Ok(v) = u8::try_from(value) { + Value::UInt8(v) + } else if let Ok(v) = i16::try_from(value) { + Value::Int16(v) + } else if let Ok(v) = u16::try_from(value) { + Value::UInt16(v) + } else if let Ok(v) = i32::try_from(value) { + Value::Int32(v) + } else if let Ok(v) = u32::try_from(value) { + Value::UInt32(v) + } else if let Ok(v) = i64::try_from(value) { + Value::Int64(v) + } else if let Ok(v) = u64::try_from(value) { + Value::UInt64(v) + } else if let Ok(v) = i128::try_from(value) { + Value::Int128(v) + } else { + Value::UInt128(value) + } +} + +pub(crate) fn date(text: &str) -> Result { + let value = + NaiveDate::parse_from_str(text, "%Y-%m-%d").map_err(|_| ConversionError::InvalidDate)?; + if value.to_string() != text { + return Err(ConversionError::InvalidDate); + } + Ok(Value::Date(value)) +} + +pub(crate) fn timestamp(text: &str) -> Result { + DateTime::parse_from_rfc3339(text) + .map(|value| Value::Timestamp(value.with_timezone(&Utc))) + .map_err(|_| ConversionError::InvalidTimestamp) +} + +pub(crate) fn date_millis(ms: f64) -> Result { + // Chrono checks its own range below, which is narrower than JS Date's. + if !ms.is_finite() || ms.fract() != 0.0 { + return Err(ConversionError::InvalidTimestamp); + } + DateTime::from_timestamp_millis(ms as i64) + .map(Value::Timestamp) + .ok_or(ConversionError::InvalidTimestamp) +} + +pub(crate) fn decimal(text: &str) -> Result { + match text.to_ascii_lowercase().as_str() { + "nan" | "+nan" | "-nan" | "inf" | "+inf" | "-inf" | "infinity" | "+infinity" + | "-infinity" => return Err(ConversionError::NonFiniteDecimal), + _ => {} + } + // Fixed-point only, matching Go. Refuse separators/exponents rather than + // letting a library parser choose rounding or representation policy. + let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text); + let mut parts = unsigned.split('.'); + let integral = parts.next().unwrap_or(""); + let fraction = parts.next(); + if integral.is_empty() + || !integral.bytes().all(|c| c.is_ascii_digit()) + || fraction.is_some_and(|part| { + part.is_empty() || part.len() > 28 || !part.bytes().all(|c| c.is_ascii_digit()) + }) + || parts.next().is_some() + { + return Err(ConversionError::InvalidDecimal); + } + Decimal::from_str_exact(text) + .map(|mut value| { + // rust_decimal's parser canonicalizes negative zero; the binding + // preserves the sign bit just as the transport and Go model do. + value.set_sign_negative(text.starts_with('-')); + Value::Decimal(value) + }) + .map_err(|_| ConversionError::InvalidDecimal) +} + +#[cfg(test)] +mod tests { + use super::*; + use vitaminc_aead_value::ValueKind; + + #[test] + fn bigint_boundaries_choose_the_smallest_kind() { + let positive = [ + (0, ValueKind::Int8), + (127, ValueKind::Int8), + (128, ValueKind::UInt8), + (255, ValueKind::UInt8), + (256, ValueKind::Int16), + (32767, ValueKind::Int16), + (32768, ValueKind::UInt16), + (65535, ValueKind::UInt16), + (65536, ValueKind::Int32), + (i32::MAX as u128, ValueKind::Int32), + (i32::MAX as u128 + 1, ValueKind::UInt32), + (u32::MAX as u128, ValueKind::UInt32), + (u32::MAX as u128 + 1, ValueKind::Int64), + (i64::MAX as u128, ValueKind::Int64), + (i64::MAX as u128 + 1, ValueKind::UInt64), + (u64::MAX as u128, ValueKind::UInt64), + (u64::MAX as u128 + 1, ValueKind::Int128), + (i128::MAX as u128, ValueKind::Int128), + (i128::MAX as u128 + 1, ValueKind::UInt128), + (u128::MAX, ValueKind::UInt128), + ]; + for (value, kind) in positive { + let words = [value as u64, (value >> 64) as u64]; + assert_eq!(bigint(false, &words).expect("in range").kind(), Some(kind)); + } + let negative = [ + (128u128, ValueKind::Int8), + (129, ValueKind::Int16), + (32768, ValueKind::Int16), + (32769, ValueKind::Int32), + (1 << 31, ValueKind::Int32), + ((1 << 31) + 1, ValueKind::Int64), + (1 << 63, ValueKind::Int64), + ((1 << 63) + 1, ValueKind::Int128), + (1 << 127, ValueKind::Int128), + ]; + for (magnitude, kind) in negative { + assert_eq!( + bigint(true, &[magnitude as u64, (magnitude >> 64) as u64]) + .expect("in range") + .kind(), + Some(kind) + ); + } + assert!(matches!( + bigint(true, &[0, 1 << 63]).expect("i128 min"), + Value::Int128(i128::MIN) + )); + assert!(matches!(bigint(false, &[]).expect("zero"), Value::Int8(0))); + assert!(matches!( + bigint(true, &[0]).expect("negative zero"), + Value::Int8(0) + )); + assert!(matches!( + bigint(false, &[1, 0, 0]).expect("leading zero"), + Value::Int8(1) + )); + assert_eq!( + bigint(true, &[1, 1 << 63]).err().expect("underflow"), + ConversionError::IntegerRange + ); + assert_eq!( + bigint(false, &[0, 0, 1]).err().expect("overflow"), + ConversionError::IntegerRange + ); + } + + #[test] + fn dates_and_timestamps_reject_invalid_or_lossy_inputs() { + assert!(date("2024-02-29").is_ok()); + assert!(date("0000-01-01").is_ok()); + for invalid in ["2023-02-29", "2024-2-9", "", "2024-01-01T00:00:00Z"] { + assert_eq!( + date(invalid).err().expect("invalid date"), + ConversionError::InvalidDate + ); + } + assert!(timestamp("2016-12-31T23:59:60.123456789Z").is_ok()); + assert_eq!( + timestamp("bad").err().expect("invalid timestamp"), + ConversionError::InvalidTimestamp + ); + assert!(date_millis(-1.0).is_ok()); + assert!(date_millis(0.0).is_ok()); + for invalid in [ + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + 0.5, + 8_640_000_000_000_001.0, + 8_640_000_000_000_000.0, + ] { + assert_eq!( + date_millis(invalid).err().expect("invalid timestamp"), + ConversionError::InvalidTimestamp + ); + } + } + + #[test] + fn decimal_is_exact_and_non_finite_errors_are_distinct() { + for text in ["NaN", "+nan", "-NaN", "inf", "+Infinity", "-Infinity"] { + assert_eq!( + decimal(text).err().expect("non finite"), + ConversionError::NonFiniteDecimal + ); + } + for text in [ + "", + "1e2", + "1_000", + "1.5_0", + ".1", + "1.", + "1.2.3", + " 1", + "1a", + "1.a", + "0.00000000000000000000000000001", + "79228162514264337593543950336", + ] { + assert_eq!( + decimal(text).err().expect("invalid decimal"), + ConversionError::InvalidDecimal + ); + } + assert!(matches!(decimal("1.50").expect("finite"), Value::Decimal(v) if v.scale() == 2)); + assert!(decimal("+1.50").is_ok()); + for error in [ + ConversionError::IntegerRange, + ConversionError::InvalidDate, + ConversionError::InvalidTimestamp, + ConversionError::InvalidDecimal, + ConversionError::NonFiniteDecimal, + ] { + assert!(error.code().starts_with("ERR_")); + assert!(!error.to_string().is_empty()); + } + } +} diff --git a/packages/aead-napi/src/value.rs b/packages/aead-napi/src/value.rs index bc691d74..2c650a8b 100644 --- a/packages/aead-napi/src/value.rs +++ b/packages/aead-napi/src/value.rs @@ -13,8 +13,7 @@ use vitaminc_aead_value::Value; /// with the bare [`Value`]. /// /// See the `vitaminc-aead-value` crate docs for the JS type mapping, -/// including the `Number`/`Int` split (JS numbers always encode as -/// `Number`; `BigInt` encodes as `Int`) and the `BigInt` decode rule. +/// including the Float64/BigInt split and the scalar wrapper types. pub struct NapiValue(pub Value); impl NapiValue { diff --git a/packages/aead-napi/tests/addon/.gitignore b/packages/aead-napi/tests/addon/.gitignore new file mode 100644 index 00000000..388c77b5 --- /dev/null +++ b/packages/aead-napi/tests/addon/.gitignore @@ -0,0 +1,2 @@ +/target +/*.node diff --git a/packages/aead-napi/tests/addon/Cargo.lock b/packages/aead-napi/tests/addon/Cargo.lock new file mode 100644 index 00000000..19c2ac65 --- /dev/null +++ b/packages/aead-napi/tests/addon/Cargo.lock @@ -0,0 +1,1049 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", + "ctutils", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "napi" +version = "3.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 3.0.6", +] + +[[package]] +name = "napi-sys" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orderable-bytes" +version = "0.1.1" +source = "git+https://github.com/cipherstash/ore.rs?rev=b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5#b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5" +dependencies = [ + "chrono", + "rust_decimal", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.16+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" +dependencies = [ + "winnow", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "vitaminc-aead" +version = "0.5.1" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-aead-napi" +version = "0.5.1" +dependencies = [ + "chrono", + "napi", + "rust_decimal", + "thiserror", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.1" +dependencies = [ + "chrono", + "orderable-bytes", + "rust_decimal", + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.1" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-napi-test-addon" +version = "0.0.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "vitaminc-aead-napi", + "vitaminc-aead-value", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.1" +dependencies = [ + "bitvec", + "digest", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.1" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.3", + "thiserror", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/aead-napi/tests/addon/Cargo.toml b/packages/aead-napi/tests/addon/Cargo.toml new file mode 100644 index 00000000..f7961fd7 --- /dev/null +++ b/packages/aead-napi/tests/addon/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "vitaminc-napi-test-addon" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = { version = "3", default-features = false, features = ["napi6"] } +napi-derive = "3" +vitaminc-aead-napi = { path = "../.." } +vitaminc-aead-value = { path = "../../../aead-value", features = ["chrono", "rust_decimal"] } +vitaminc-protected = { path = "../../../protected" } + +[build-dependencies] +napi-build = "2" diff --git a/packages/aead-napi/tests/addon/build.rs b/packages/aead-napi/tests/addon/build.rs new file mode 100644 index 00000000..0f1b0100 --- /dev/null +++ b/packages/aead-napi/tests/addon/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs new file mode 100644 index 00000000..f0895b3d --- /dev/null +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -0,0 +1,36 @@ +use napi::{ + bindgen_prelude::{Buffer, FromNapiValue, JsValue, ToNapiValue, Unknown}, + Env, +}; +use napi_derive::napi; +use vitaminc_aead_napi::NapiValue; +use vitaminc_aead_value::transport::{decode_value, encode_value, Reader}; +use vitaminc_protected::{Controlled, Protected}; + +#[napi] +pub fn encode(value: Unknown<'_>) -> napi::Result { + let raw = value.value(); + let value = unsafe { NapiValue::from_napi_value(raw.env, raw.value) }?; + let mut bytes = Protected::new(Vec::new()); + encode_value(value.0, bytes.risky_inner_mut()) + .map_err(|_| napi::Error::from_reason("transport encode failed"))?; + Ok(Buffer::from(bytes.risky_ref().to_vec())) +} + +#[napi] +pub fn decode(env: Env, bytes: Buffer) -> napi::Result> { + let value = decode_value(&mut Reader::new(&bytes)) + .map_err(|_| napi::Error::from_reason("transport decode failed"))?; + let raw = unsafe { NapiValue::to_napi_value(env.raw(), NapiValue(value)) }?; + unsafe { Unknown::from_napi_value(env.raw(), raw) } +} + +#[napi] +pub fn codec_round_trip(bytes: Buffer) -> napi::Result { + let value = decode_value(&mut Reader::new(&bytes)) + .map_err(|_| napi::Error::from_reason("transport decode failed"))?; + let mut encoded = Protected::new(Vec::new()); + encode_value(value, encoded.risky_inner_mut()) + .map_err(|_| napi::Error::from_reason("transport encode failed"))?; + Ok(Buffer::from(encoded.risky_ref().to_vec())) +} diff --git a/packages/aead-napi/tests/node.rs b/packages/aead-napi/tests/node.rs new file mode 100644 index 00000000..72f2cd4b --- /dev/null +++ b/packages/aead-napi/tests/node.rs @@ -0,0 +1,43 @@ +//! Exercise live N-API conversions, including in package-scoped mutation runs. +#[test] +fn node_value_conformance() { + use std::{path::Path, process::Command}; + let package = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = package + .parent() + .expect("packages") + .parent() + .expect("workspace"); + let addon = package.join("tests/addon/Cargo.toml"); + let target = workspace.join("target/napi-test-addon"); + let build = Command::new(env!("CARGO")) + .args(["build", "--locked", "--quiet", "--manifest-path"]) + .arg(addon) + .arg("--target-dir") + .arg(&target) + .output() + .expect("run Cargo for the test addon"); + assert!( + build.status.success(), + "addon build: {}", + String::from_utf8_lossy(&build.stderr) + ); + let library = target.join("debug").join(format!( + "{}vitaminc_napi_test_addon{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + )); + let module = target.join("addon.node"); + std::fs::copy(library, &module).expect("copy Node module"); + let result = Command::new("node") + .arg(package.join("tests/value-conformance.cjs")) + .arg(module) + .output() + .expect("Node.js is required for binding tests"); + assert!( + result.status.success(), + "Node conformance:\n{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); +} diff --git a/packages/aead-napi/tests/value-conformance.cjs b/packages/aead-napi/tests/value-conformance.cjs new file mode 100644 index 00000000..b595b90d --- /dev/null +++ b/packages/aead-napi/tests/value-conformance.cjs @@ -0,0 +1,49 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const addon = require(path.resolve(process.argv[2])); +const corpus = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../testdata/value-conformance.json'))); +function expected(row) { + if (/^(u?int)/.test(row.kind)) return BigInt(row.value); + switch (row.kind) { + case 'undefined': return undefined; + case 'bytes': return Buffer.from(row.value, 'hex'); + case 'date': return {date: row.value}; + case 'decimal': return {decimal: row.value}; + case 'timestamp': return row.js_date ? new Date(row.value) : {timestamp: row.value}; + default: return row.value; + } +} +for (const row of corpus.vectors) { + const wire = Buffer.from(row.transport, 'hex'); + assert.equal(addon.codecRoundTrip(wire).toString('hex'), row.transport, row.name); + const value = addon.decode(wire); + assert.deepEqual(value, expected(row), row.name); + assert.equal(addon.encode(value).toString('hex'), row.js_reencode ?? row.transport, row.name); +} +for (const row of corpus.malformed) assert.throws(() => addon.decode(Buffer.from(row.transport, 'hex')), undefined, row.name); +const boundaries = [ + [0n,12], [127n,12], [128n,13], [255n,13], [256n,14], [32767n,14], [32768n,15], [65535n,15], [65536n,4], + [(1n<<31n)-1n,4], [1n<<31n,6], [(1n<<32n)-1n,6], [1n<<32n,5], [(1n<<63n)-1n,5], [1n<<63n,7], + [(1n<<64n)-1n,7], [1n<<64n,16], [(1n<<127n)-1n,16], [1n<<127n,17], [(1n<<128n)-1n,17], + [-128n,12], [-129n,14], [-32768n,14], [-32769n,4], [-(1n<<31n),4], [-(1n<<31n)-1n,5], [-(1n<<63n),5], [-(1n<<63n)-1n,16], [-(1n<<127n),16], +]; +for (const [value, tag] of boundaries) { + const wire = addon.encode(value); + assert.equal(wire[0], tag); + assert.equal(addon.decode(wire), value); +} +for (const value of [1n<<128n, -(1n<<127n)-1n]) assert.throws(() => addon.encode(value), {name:'TypeError',code:'ERR_INTEGER_RANGE'}); +for (const decimal of ['NaN','Infinity','-Infinity','+inf',NaN,Infinity,-Infinity]) assert.throws(() => addon.encode({decimal}), {name:'TypeError',code:'ERR_NON_FINITE_DECIMAL'}); +assert.throws(() => addon.encode({decimal:'79228162514264337593543950336'}), {code:'ERR_INVALID_DECIMAL'}); +assert.throws(() => addon.encode({date:'2023-02-29'}), {code:'ERR_INVALID_DATE'}); +assert.throws(() => addon.encode(new Date(NaN)), {code:'ERR_INVALID_TIMESTAMP'}); +assert.deepEqual(addon.decode(addon.encode(new Date(-1))), new Date(-1)); +const nested = {items:[1n, {date:'2024-02-29'}, {decimal:'1.50'}, new Date(0)]}; +assert.deepEqual(addon.decode(addon.encode(nested)), nested); +assert.throws(() => addon.encode(new Map([['a',1]]))); +assert.throws(() => addon.encode([,1])); +assert.throws(() => addon.encode('\ud800')); +const ordinary = {date:'not a wrapper', extra:true}; +assert.deepEqual(addon.decode(addon.encode(ordinary)), ordinary); +console.log(`Node conformance: ${corpus.vectors.length} values, ${corpus.malformed.length} refusals, BigInt boundaries passed`); diff --git a/packages/aead-value/Cargo.toml b/packages/aead-value/Cargo.toml index 97cdbf50..ebab0973 100644 --- a/packages/aead-value/Cargo.toml +++ b/packages/aead-value/Cargo.toml @@ -36,6 +36,7 @@ features = ["std"] optional = true [dev-dependencies] +serde_json = "1" quickcheck = "1.1.0" quickcheck_macros = "1.2.0" vitaminc-encrypt = { path = "../encrypt" } diff --git a/packages/aead-value/README.md b/packages/aead-value/README.md index 22230766..3960a1d4 100644 --- a/packages/aead-value/README.md +++ b/packages/aead-value/README.md @@ -129,10 +129,14 @@ mapping. The defining rules: | `Null` | `null` | `None` | `nil` | | `Undefined` | `undefined` | decodes as `None` | decodes as `nil` | | `Bool` | `boolean` | `bool` | `bool` | -| `Int32` | decodes as `number` (never encoded — JS has no `int32`) | `int` | `int8`/`int16`/`int32` → encode; decodes as `int32` | -| `Int64` | encodes from `BigInt` that fits `i64`; decodes as `number` when within ±2⁵³, else `BigInt` | `int` | `int`/`int64` → encode; decodes as `int64` | -| `UInt32` | decodes as `number` (never encoded) | `int` | `uint8`/`uint16`/`uint32` → encode; decodes as `uint32` | -| `UInt64` | encodes from `BigInt` above `i64::MAX` that fits `u64`; decodes as `number` when ≤ 2⁵³−1, else `BigInt` | `int` | `uint`/`uint64`/`uintptr` → encode; decodes as `uint64` | +| `Int8` / `UInt8` | smallest fitting `BigInt`; decode as `BigInt` | `int` | `int8` / `uint8` | +| `Int16` / `UInt16` | smallest fitting `BigInt`; decode as `BigInt` | `int` | `int16` / `uint16` | +| `Int32` / `UInt32` | smallest fitting `BigInt`; decode as `BigInt` | `int` | `int32` / `uint32` | +| `Int64` / `UInt64` | smallest fitting `BigInt`; decode as `BigInt` | `int` | `int64` / `uint64`; `int` / `uint` / `uintptr` use 64 bits | +| `Int128` / `UInt128` | fitting `BigInt`; decode as `BigInt` | planned | `vcvalue.Int128` / `vcvalue.Uint128` | +| `Date` | `{ date: "YYYY-MM-DD" }` | planned | `vcvalue.Date` | +| `Timestamp` | `Date`; exact RFC3339 wrapper when Date would lose precision | planned | `time.Time`; `vcvalue.Timestamp` for leap seconds | +| `Decimal` | `{ decimal: "1.50" }` | planned | `vcvalue.Decimal` | | `Float32` | decodes as `number` (exact widening; never encoded) | `float` | `float32` | | `Float64` | `number` (always — integral JS numbers do **not** become an integer tag) | `float` | `float64` | | `String` | `string` | `str` | `string` | @@ -143,19 +147,20 @@ mapping. The defining rules: `Undefined` exists for JavaScript round-trip fidelity; languages without an analog decode it to their null value and never encode it. -**JavaScript.** A JS `number` always encodes as `Float64`, never an integer -tag — `42` and `42.0` are the same value in JS. Integer typing comes from -`BigInt`: one that fits `i64` encodes as `Int64`, one above `i64::MAX` that -fits `u64` encodes as `UInt64`, and anything larger is rejected. JS has no -32-bit numeric types, so it never *encodes* `Int32`/`UInt32`/`Float32`; on -decode it widens all of them to a JS `number` (a `Float32` widens exactly -via `f64::from(f32)`; 32-bit integers always fit `Number.MAX_SAFE_INTEGER`). - -**Go.** Go maps **exactly in both directions**, unlike JS which widens on -decode: `INT32`↔`int32`, `UINT32`↔`uint32`, `FLOAT32`↔`float32`, and the -64-bit tags to their 64-bit Go types. Narrower Go integer kinds encode up to -the matching width (e.g. `int16`→`INT32`), and `uint`/`uintptr` map to -`UINT64`. +**JavaScript.** A JS number always encodes as Float64. BigInt chooses the +smallest fitting integer width, preferring signed at the same width: `127n` +is Int8, `128n` is UInt8, `256n` is Int16. All integer kinds decode as BigInt, +including small integers received from Go. Values outside [-2^127, 2^128-1] +are refused. Float32 decodes as a number and therefore re-encodes as Float64. + +**Go.** Every fixed integer width maps to its matching kind; `int`, `uint` +and `uintptr` retain their existing 64-bit mapping. `time.Time` preserves its +instant and nanoseconds, while location and monotonic-clock metadata are not +part of the value model. Chrono leap seconds use an explicit Timestamp type. + +`testdata/value-conformance.json` is read directly by Rust, Node and Go tests, +including Go encryption/decryption through the embedded guest. It covers every +scalar tag; the broader guest-object/ciphertext corpus remains issue #332. ## Declaring a kind without a value diff --git a/packages/aead-value/tests/conformance.rs b/packages/aead-value/tests/conformance.rs new file mode 100644 index 00000000..b25c096f --- /dev/null +++ b/packages/aead-value/tests/conformance.rs @@ -0,0 +1,47 @@ +#![cfg(all(feature = "chrono", feature = "rust_decimal"))] + +use vitaminc_aead_value::{ + transport::{decode_value, encode_value, Reader}, + ValueKind, +}; + +fn unhex(text: &str) -> Vec { + text.as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).expect("ASCII"), 16).expect("hex")) + .collect() +} + +#[test] +fn shared_binding_corpus_covers_every_tag_and_kind() { + let corpus: serde_json::Value = + serde_json::from_str(include_str!("../../../testdata/value-conformance.json")) + .expect("corpus JSON"); + let mut tags = Vec::new(); + let mut kinds = Vec::new(); + for row in corpus["vectors"].as_array().expect("vectors") { + let bytes = unhex(row["transport"].as_str().expect("transport")); + tags.push(bytes[0]); + let value = decode_value(&mut Reader::new(&bytes)).expect("valid vector"); + if let Some(kind) = value.kind() { + kinds.push(kind); + } + let mut encoded = Vec::new(); + encode_value(value, &mut encoded).expect("encode"); + assert_eq!(encoded, bytes, "{}", row["name"]); + } + for tag in 0x00..=0x14 { + assert!(tags.contains(&tag), "missing scalar tag {tag:02x}"); + } + for kind in ValueKind::ALL { + assert!(kinds.contains(kind), "missing kind {kind}"); + } + for row in corpus["malformed"].as_array().expect("malformed") { + let bytes = unhex(row["transport"].as_str().expect("transport")); + assert!( + decode_value(&mut Reader::new(&bytes)).is_err(), + "{}", + row["name"] + ); + } +} diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 00000000..51975a5b --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,21 @@ +# Value conformance corpus + +`value-conformance.json` contains shared value-level transport vectors for +Rust, Node, the Go codec and the Go WASM encryption guest. Payload bytes are +frozen by `aead-value`'s scalar tag table: fixed-width integers and float bits +are little-endian, dates count days from CE, timestamps carry seconds plus +nanoseconds, and decimals retain their flags and 96-bit coefficient. + +Each valid vector gives a name, kind, expected host value and hexadecimal +transport bytes. Integer and decimal values are strings to avoid JSON number +rounding. Bytes are hexadecimal. `js_date` selects a native JavaScript Date; +other timestamps retain their nanoseconds in a wrapper. `malformed` vectors +must be refused by the decoders. + +Rust re-encodes every valid vector byte-for-byte. The optional `js_reencode` +and `go_reencode` fields record established host projections explicitly: +JavaScript Float32 becomes Number/Float64; Go undefined becomes nil/null. +New vectors should state any additional projection rather than skipping it. + +These are the scalar/value-level vectors needed by #375. The wider #332 +cross-language ciphertext, guest-object and SDK corpus remains separate work. diff --git a/testdata/value-conformance.json b/testdata/value-conformance.json new file mode 100644 index 00000000..18b345ee --- /dev/null +++ b/testdata/value-conformance.json @@ -0,0 +1,295 @@ +{ + "vectors": [ + { + "name": "null", + "kind": "null", + "value": null, + "transport": "00" + }, + { + "name": "undefined", + "kind": "undefined", + "value": null, + "transport": "01", + "go_reencode": "00" + }, + { + "name": "false", + "kind": "bool", + "value": false, + "transport": "02" + }, + { + "name": "true", + "kind": "bool", + "value": true, + "transport": "03" + }, + { + "name": "int8", + "kind": "int8", + "value": "-128", + "transport": "0c80" + }, + { + "name": "uint8", + "kind": "uint8", + "value": "255", + "transport": "0dff" + }, + { + "name": "int16", + "kind": "int16", + "value": "-32768", + "transport": "0e0080" + }, + { + "name": "uint16", + "kind": "uint16", + "value": "65535", + "transport": "0fffff" + }, + { + "name": "int32", + "kind": "int32", + "value": "-2147483648", + "transport": "0400000080" + }, + { + "name": "uint32", + "kind": "uint32", + "value": "4294967295", + "transport": "06ffffffff" + }, + { + "name": "int64", + "kind": "int64", + "value": "-9223372036854775808", + "transport": "050000000000000080" + }, + { + "name": "uint64", + "kind": "uint64", + "value": "18446744073709551615", + "transport": "07ffffffffffffffff" + }, + { + "name": "int128", + "kind": "int128", + "value": "-170141183460469231731687303715884105728", + "transport": "1000000000000000000000000000000080" + }, + { + "name": "uint128", + "kind": "uint128", + "value": "340282366920938463463374607431768211455", + "transport": "11ffffffffffffffffffffffffffffffff" + }, + { + "name": "float32", + "kind": "float32", + "value": 1.5, + "transport": "080000c03f", + "js_reencode": "09000000000000f83f" + }, + { + "name": "float64", + "kind": "float64", + "value": -1.5, + "transport": "09000000000000f8bf" + }, + { + "name": "text", + "kind": "string", + "value": "é", + "transport": "0a02000000c3a9" + }, + { + "name": "text-nul", + "kind": "string", + "value": "a\u0000b", + "transport": "0a03000000610062" + }, + { + "name": "bytes", + "kind": "bytes", + "value": "00ff07", + "transport": "0b0300000000ff07" + }, + { + "name": "date", + "kind": "date", + "value": "2024-02-29", + "transport": "1281460b00" + }, + { + "name": "date-year-zero", + "kind": "date", + "value": "0000-01-01", + "transport": "1293feffff" + }, + { + "name": "timestamp", + "kind": "timestamp", + "value": "1970-01-01T00:00:00.123Z", + "transport": "130000000000000000c0d45407", + "js_date": true + }, + { + "name": "timestamp-nanos", + "kind": "timestamp", + "value": "1969-12-31T23:59:59.999999999Z", + "transport": "13ffffffffffffffffffc99a3b", + "js_date": false + }, + { + "name": "timestamp-leap", + "kind": "timestamp", + "value": "1970-01-01T00:00:60.123456789Z", + "transport": "133b000000000000001597f642", + "js_date": false + }, + { + "name": "decimal", + "kind": "decimal", + "value": "1.50", + "transport": "1400000200960000000000000000000000" + }, + { + "name": "decimal-min", + "kind": "decimal", + "value": "-79228162514264337593543950335", + "transport": "1400000080ffffffffffffffffffffffff" + }, + { + "name": "decimal-small", + "kind": "decimal", + "value": "0.0000000000000000000000000001", + "transport": "1400001c00010000000000000000000000" + }, + { + "name": "empty-array", + "kind": "array", + "value": [], + "transport": "f000000000" + }, + { + "name": "empty-object", + "kind": "object", + "value": {}, + "transport": "f100000000" + }, + { + "name": "decimal-negative-zero", + "kind": "decimal", + "value": "-0.00", + "transport": "1400000280000000000000000000000000" + } + ], + "malformed": [ + { + "name": "unknown-tag", + "transport": "ef" + }, + { + "name": "invalid-utf8", + "transport": "0a01000000ff" + }, + { + "name": "trailing", + "transport": "0000" + }, + { + "name": "invalid-date", + "transport": "12ffffff7f" + }, + { + "name": "invalid-timestamp-nanos", + "transport": "13000000000000000000ca9a3b" + }, + { + "name": "invalid-timestamp-range", + "transport": "13ffffffffffffff7f00000000" + }, + { + "name": "decimal-reserved-bits", + "transport": "1401000000000000000000000000000000" + }, + { + "name": "decimal-scale", + "transport": "1400001d00000000000000000000000000" + }, + { + "name": "tag-0c-width-0", + "transport": "0c" + }, + { + "name": "tag-0c-width-2", + "transport": "0c0000" + }, + { + "name": "tag-0d-width-0", + "transport": "0d" + }, + { + "name": "tag-0d-width-2", + "transport": "0d0000" + }, + { + "name": "tag-0e-width-1", + "transport": "0e00" + }, + { + "name": "tag-0e-width-3", + "transport": "0e000000" + }, + { + "name": "tag-0f-width-1", + "transport": "0f00" + }, + { + "name": "tag-0f-width-3", + "transport": "0f000000" + }, + { + "name": "tag-10-width-15", + "transport": "10000000000000000000000000000000" + }, + { + "name": "tag-10-width-17", + "transport": "100000000000000000000000000000000000" + }, + { + "name": "tag-11-width-15", + "transport": "11000000000000000000000000000000" + }, + { + "name": "tag-11-width-17", + "transport": "110000000000000000000000000000000000" + }, + { + "name": "tag-12-width-3", + "transport": "12000000" + }, + { + "name": "tag-12-width-5", + "transport": "120000000000" + }, + { + "name": "tag-13-width-11", + "transport": "130000000000000000000000" + }, + { + "name": "tag-13-width-13", + "transport": "1300000000000000000000000000" + }, + { + "name": "tag-14-width-15", + "transport": "14000000000000000000000000000000" + }, + { + "name": "tag-14-width-17", + "transport": "140000000000000000000000000000000000" + } + ] +} From 6498f33012ea9ba9a367ac24e3aacec5482252b3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:25:38 +1100 Subject: [PATCH 04/28] feat(prf)!: make Value equality terms an opt-in `value` feature vitaminc-prf (and the facade's prf/hmac features) no longer pull in vitaminc-aead-value, its Unicode tables and orderable-bytes unless the new `value` feature is enabled; `chrono` and `rust_decimal` imply it. This also breaks the aead (dev) -> prf -> aead-value -> aead dependency cycle. CI now tests prf alone without the feature, since the workspace run unifies hmac's dev-dependency features, and no longer runs the aead-value all-features suite twice. BREAKING CHANGE: PrfError is now #[non_exhaustive], and its Canonical variant exists only with the `value` feature. Matches on PrfError need a wildcard arm. Refs #380 --- .github/workflows/test.yml | 5 ++++- packages/hmac/Cargo.toml | 1 + packages/prf/Cargo.toml | 8 +++++--- packages/prf/src/error.rs | 5 +++++ packages/prf/src/lib.rs | 2 ++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ee3459c..1c8df016 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,10 +97,13 @@ jobs: cargo test -p vitaminc-aead-value --all-features - name: test (canonical equality features) + # The workspace run unifies hmac's dev-dependency features, so test + # prf alone without `value` as well as with each optional feature. run: | + cargo test -p vitaminc-prf && cargo test -p vitaminc-prf --features chrono && cargo test -p vitaminc-prf --features rust_decimal && - cargo test -p vitaminc-prf -p vitaminc-aead-value --all-features && + cargo test -p vitaminc-prf --all-features && cargo test -p vitaminc-hmac --test value_equality - name: test (vitaminc-encrypt, RustCrypto backend on native) diff --git a/packages/hmac/Cargo.toml b/packages/hmac/Cargo.toml index bff7994b..e9220521 100644 --- a/packages/hmac/Cargo.toml +++ b/packages/hmac/Cargo.toml @@ -19,6 +19,7 @@ vitaminc-protected = { version = "0.5.1", path = "../protected" } zeroize = { workspace = true } [dev-dependencies] +vitaminc-prf = { path = "../prf", features = ["value", "chrono", "rust_decimal"] } vitaminc-aead-value = { path = "../aead-value", features = ["canonical", "chrono", "rust_decimal"] } chrono = { version = "0.4.44", default-features = false, features = ["std"] } rust_decimal = { version = "1.41", default-features = false, features = ["std"] } diff --git a/packages/prf/Cargo.toml b/packages/prf/Cargo.toml index afec1b63..d202c72e 100644 --- a/packages/prf/Cargo.toml +++ b/packages/prf/Cargo.toml @@ -13,11 +13,13 @@ categories.workspace = true [features] default = [] -chrono = ["vitaminc-aead-value/chrono"] -rust_decimal = ["vitaminc-aead-value/rust_decimal"] +# `PrfValue` for `&vitaminc_aead_value::Value` (canonical equality terms). +value = ["dep:vitaminc-aead-value"] +chrono = ["value", "vitaminc-aead-value/chrono"] +rust_decimal = ["value", "vitaminc-aead-value/rust_decimal"] [dependencies] -vitaminc-aead-value = { version = "0.5.1", path = "../aead-value", features = ["canonical"] } +vitaminc-aead-value = { version = "0.5.1", path = "../aead-value", features = ["canonical"], optional = true } mutants = "0.0.4" thiserror = { workspace = true } vitaminc-context = { version = "0.5.1", path = "../context" } diff --git a/packages/prf/src/error.rs b/packages/prf/src/error.rs index 785836df..1289ba42 100644 --- a/packages/prf/src/error.rs +++ b/packages/prf/src/error.rs @@ -29,8 +29,13 @@ pub enum PrfVisitorError { /// Builder and visitor failures describe malformed local structure. Backend /// failures describe execution of an otherwise valid program (for example, a /// remote batch request being rejected). +/// +/// Non-exhaustive: optional features add variants. #[derive(Debug, PartialEq, Eq, Error)] +#[non_exhaustive] pub enum PrfError { + /// A `Value` with no equality term (requires the `value` feature). + #[cfg(feature = "value")] #[error("cannot derive an equality term: {0}")] Canonical(#[from] crate::CanonicalError), #[error("failed to build PRF operation: {0}")] diff --git a/packages/prf/src/lib.rs b/packages/prf/src/lib.rs index b10356a0..66a8c8f8 100644 --- a/packages/prf/src/lib.rs +++ b/packages/prf/src/lib.rs @@ -7,9 +7,11 @@ mod error; mod impls; mod ready; mod traits; +#[cfg(feature = "value")] mod value; mod visitor; +#[cfg(feature = "value")] pub use vitaminc_aead_value::canonical::CanonicalError; #[cfg(test)] From 9675398b581434b1d5263ea63cbaaa74d1dd9210 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:25:40 +1100 Subject: [PATCH 05/28] fix(aead-value): match equal integers across widths in equality terms A JavaScript BigInt takes the smallest integer kind that fits, while Go and Rust usually write int64, so per-width domains meant the same number never matched across languages. Every integer kind now shares one domain, vitaminc/prf/value/integer-orderable/v1, and a 17-byte order-preserving encoding: a sign byte, then the value as a big-endian 128-bit word. Ciphertext keeps the original width. The canonical layer now returns each term's domain with its bytes from the exhaustive Value match, and equality_domain matches ValueKind exhaustively in its own crate, so a new kind cannot compile without a domain and prf no longer canonicalizes (including NFC) before a fallible domain lookup. NFC output is measured before allocating, so the protected buffer never reallocates and frees an unwiped partial copy when normalization expands text. The normalization iterator's own buffers are documented as unwiped. unicode-normalization moves to a caret requirement; only the Unicode 16 assignment table stays exactly pinned. Refs #380 --- packages/aead-value/Cargo.toml | 6 +- packages/aead-value/src/canonical.rs | 146 +++++++++++++++++---- packages/aead-value/src/canonical_tests.rs | 123 ++++++++++++++++- packages/hmac/tests/value_equality.rs | 66 +++++----- packages/prf/README.md | 32 +++-- packages/prf/src/value.rs | 68 ++++------ 6 files changed, 324 insertions(+), 117 deletions(-) diff --git a/packages/aead-value/Cargo.toml b/packages/aead-value/Cargo.toml index 97cdbf50..99668467 100644 --- a/packages/aead-value/Cargo.toml +++ b/packages/aead-value/Cargo.toml @@ -46,10 +46,14 @@ git = "https://github.com/cipherstash/ore.rs" rev = "b3b7800b9c0cd8f7c53c5382f8e018a4e8bcbbd5" optional = true +# NFC is stable for assigned code points, and the Unicode 16 assignment check +# below refuses everything else, so newer normalization releases are safe. [dependencies.unicode-normalization] -version = "=0.1.24" +version = "0.1.24" optional = true +# Exact pin: this table defines which code points are assigned in Unicode 16, +# and a newer table would widen the accepted input of a frozen domain. [dependencies.unicode-general-category] version = "=1.1.0" optional = true diff --git a/packages/aead-value/src/canonical.rs b/packages/aead-value/src/canonical.rs index c2ef28c5..1b2683a2 100644 --- a/packages/aead-value/src/canonical.rs +++ b/packages/aead-value/src/canonical.rs @@ -1,9 +1,11 @@ //! Canonical scalar plaintext for search terms, separate from reversible ciphertext. //! -//! Numeric encodings come from `orderable-bytes` at their natural widths. -//! Text uses Unicode 16 NFC. These bytes are protocol data: changing them -//! requires a new term domain. Text processing is variable-time; this module -//! makes no constant-time claim for Unicode normalization. +//! Every integer kind shares one 17-byte encoding and domain, so equal +//! numbers match whichever width wrote them. Other numeric encodings come +//! from `orderable-bytes` at their natural widths. Text uses Unicode 16 NFC. +//! These bytes and their [`domain`] labels are protocol data: changing either +//! requires a new domain. Text processing is variable-time; this module makes +//! no constant-time claim for Unicode normalization. use orderable_bytes::ToOrderableBytes; use unicode_general_category::{get_general_category, GeneralCategory}; @@ -42,44 +44,125 @@ impl std::fmt::Display for CanonicalError { impl std::error::Error for CanonicalError {} -/// Encode a scalar for equality. Every owned byte buffer remains protected. +/// Frozen PRF domain labels for equality terms, one per [`equality_domain`]. +pub mod domain { + /// Every integer kind, `int8` through `uint128`. + pub const INTEGER: &str = "vitaminc/prf/value/integer-orderable/v1"; + /// `float32`. + pub const FLOAT32: &str = "vitaminc/prf/value/float32-orderable/v1"; + /// `float64`. + pub const FLOAT64: &str = "vitaminc/prf/value/float64-orderable/v1"; + /// `date`. + pub const DATE: &str = "vitaminc/prf/value/date-orderable/v1"; + /// `timestamp`, truncated to microseconds. + pub const TIMESTAMP: &str = "vitaminc/prf/value/timestamp-micros-orderable/v1"; + /// `decimal`, with scale and signed zero normalized. + pub const DECIMAL: &str = "vitaminc/prf/value/decimal-orderable/v1"; + /// `string`, as Unicode 16 NFC. + pub const TEXT: &str = "vitaminc/prf/value/text-nfc/unicode-16/v1"; + /// `bytes`. + pub const BYTES: &str = "vitaminc/prf/value/bytes/v1"; +} + +/// The canonical bytes for one equality term and the domain they belong to. +#[derive(Debug)] +pub struct EqualityInput { + /// The frozen domain label, one of the [`domain`] constants. + pub domain: &'static str, + /// The canonical plaintext, protected. + pub bytes: Protected>, +} + +/// The equality domain for a kind, or `None` for a kind without terms. +/// +/// This match is exhaustive inside `ValueKind`'s own crate, so adding a kind +/// fails to compile until it is given a domain or explicitly refused. +pub const fn equality_domain(kind: ValueKind) -> Option<&'static str> { + match kind { + ValueKind::Int8 + | ValueKind::UInt8 + | ValueKind::Int16 + | ValueKind::UInt16 + | ValueKind::Int32 + | ValueKind::UInt32 + | ValueKind::Int64 + | ValueKind::UInt64 + | ValueKind::Int128 + | ValueKind::UInt128 => Some(domain::INTEGER), + ValueKind::Float32 => Some(domain::FLOAT32), + ValueKind::Float64 => Some(domain::FLOAT64), + ValueKind::Date => Some(domain::DATE), + ValueKind::Timestamp => Some(domain::TIMESTAMP), + ValueKind::Decimal => Some(domain::DECIMAL), + ValueKind::String => Some(domain::TEXT), + ValueKind::Bytes => Some(domain::BYTES), + ValueKind::Bool | ValueKind::Array | ValueKind::Object => None, + } +} + +/// Encode a scalar for equality, with the domain its term belongs to. +/// Every owned byte buffer remains protected. /// -/// Float zero signs and NaN payloads are folded. Timestamps truncate their +/// Integers of every width share [`domain::INTEGER`]: a sign byte (`0x00` +/// negative, `0x01` otherwise) then the value as a big-endian 128-bit +/// two's-complement word, which orders correctly across `i128::MIN` to +/// `u128::MAX`. Float zero signs and NaN payloads are folded. Timestamps truncate their /// fractional second to microseconds (also before the epoch and during leap /// seconds), retaining the upstream 12-byte seconds/nanoseconds layout. /// Decimal encoding itself normalizes scale, including signed zero, without /// a variable-iteration call to `Decimal::normalize`. -pub fn equality_bytes(value: &Value) -> Result>, CanonicalError> { +pub fn equality_input(value: &Value) -> Result { // This match lives alongside Value so additions cannot silently fall into // a downstream non_exhaustive wildcard. Both term layers reuse it. - Ok(match value { - Value::Int8(v) => fixed(v), - Value::UInt8(v) => fixed(v), - Value::Int16(v) => fixed(v), - Value::UInt16(v) => fixed(v), - Value::Int32(v) => fixed(v), - Value::UInt32(v) => fixed(v), - Value::Int64(v) => fixed(v), - Value::UInt64(v) => fixed(v), - Value::Int128(v) => fixed(v), - Value::UInt128(v) => fixed(v), - Value::Float32(v) => fixed(&canonical_f32(*v)), - Value::Float64(v) => fixed(&canonical_f64(*v)), + let (domain, bytes) = match value { + Value::Int8(v) => (domain::INTEGER, signed((*v).into())), + Value::UInt8(v) => (domain::INTEGER, unsigned((*v).into())), + Value::Int16(v) => (domain::INTEGER, signed((*v).into())), + Value::UInt16(v) => (domain::INTEGER, unsigned((*v).into())), + Value::Int32(v) => (domain::INTEGER, signed((*v).into())), + Value::UInt32(v) => (domain::INTEGER, unsigned((*v).into())), + Value::Int64(v) => (domain::INTEGER, signed((*v).into())), + Value::UInt64(v) => (domain::INTEGER, unsigned((*v).into())), + Value::Int128(v) => (domain::INTEGER, signed(*v)), + Value::UInt128(v) => (domain::INTEGER, unsigned(*v)), + Value::Float32(v) => (domain::FLOAT32, fixed(&canonical_f32(*v))), + Value::Float64(v) => (domain::FLOAT64, fixed(&canonical_f64(*v))), #[cfg(feature = "chrono")] - Value::Date(v) => fixed(v), + Value::Date(v) => (domain::DATE, fixed(v)), #[cfg(feature = "chrono")] - Value::Timestamp(v) => timestamp(v), + Value::Timestamp(v) => (domain::TIMESTAMP, timestamp(v)), #[cfg(feature = "rust_decimal")] - Value::Decimal(v) => fixed(v), - Value::String(v) => text_nfc(v.risky_ref())?, - Value::Bytes(v) => Protected::new(v.risky_ref().to_orderable_bytes().to_vec()), + Value::Decimal(v) => (domain::DECIMAL, fixed(v)), + Value::String(v) => (domain::TEXT, text_nfc(v.risky_ref())?), + Value::Bytes(v) => ( + domain::BYTES, + Protected::new(v.risky_ref().to_orderable_bytes().to_vec()), + ), Value::Bool(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Bool)), Value::Array(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Array)), Value::Object(_) => return Err(CanonicalError::UnsupportedKind(ValueKind::Object)), Value::Null | Value::Undefined | Value::Passthrough(_) => { return Err(CanonicalError::UnsupportedValue) } - }) + }; + Ok(EqualityInput { domain, bytes }) +} + +fn signed(value: i128) -> Protected> { + // The arithmetic shift is all ones for a negative value, zero otherwise. + integer((!(value >> 127) & 1) as u8, value as u128) +} + +fn unsigned(value: u128) -> Protected> { + integer(1, value) +} + +fn integer(sign: u8, word: u128) -> Protected> { + let word = Protected::new(word.to_be_bytes()); + let mut bytes = Protected::new(Vec::with_capacity(17)); + bytes.risky_inner_mut().push(sign); + bytes.risky_inner_mut().extend_from_slice(word.risky_ref()); + bytes } fn fixed(value: &T) -> Protected> @@ -117,6 +200,12 @@ fn timestamp(value: &chrono::DateTime) -> Protected> { } /// NFC in the pinned Unicode version, without case or accent folding. +/// +/// The result is written into one protected buffer sized exactly up front, +/// so it never reallocates and frees an unwiped partial copy. The +/// `unicode-normalization` iterator keeps its own small buffers of pending +/// code points (inline for four, spilling to the heap for longer combining +/// sequences); those are not wiped and are out of this crate's control. pub fn text_nfc(bytes: &[u8]) -> Result>, CanonicalError> { let text = std::str::from_utf8(bytes).map_err(|_| CanonicalError::InvalidText)?; if text @@ -125,7 +214,10 @@ pub fn text_nfc(bytes: &[u8]) -> Result>, CanonicalError> { { return Err(CanonicalError::UnassignedCodePoint); } - let mut result = Protected::new(Vec::with_capacity(bytes.len())); + // NFC can grow UTF-8 (U+0344 is two bytes, its NFC form four), so measure + // the output first rather than sizing from the input. + let len = text.nfc().map(char::len_utf8).sum(); + let mut result = Protected::new(Vec::with_capacity(len)); for c in text.nfc() { let mut encoded = Protected::new([0u8; 4]); result diff --git a/packages/aead-value/src/canonical_tests.rs b/packages/aead-value/src/canonical_tests.rs index e3fb2bd1..beb7e3d2 100644 --- a/packages/aead-value/src/canonical_tests.rs +++ b/packages/aead-value/src/canonical_tests.rs @@ -2,15 +2,132 @@ use super::*; use quickcheck_macros::quickcheck; fn bytes(value: Value) -> Vec { - equality_bytes(&value) + equality_input(&value) .expect("valid test value") + .bytes .risky_ref() .clone() } +fn integer_bytes(sign: u8, word: u128) -> Vec { + let mut expected = vec![sign]; + expected.extend_from_slice(&word.to_be_bytes()); + expected +} + +#[test] +fn integers_share_one_domain_whatever_their_width() { + let five = [ + Value::Int8(5), + Value::UInt8(5), + Value::Int16(5), + Value::UInt16(5), + Value::Int32(5), + Value::UInt32(5), + Value::Int64(5), + Value::UInt64(5), + Value::Int128(5), + Value::UInt128(5), + ]; + for value in five { + assert_eq!(bytes(value), integer_bytes(1, 5)); + } + let minus_seven = [ + Value::Int8(-7), + Value::Int16(-7), + Value::Int32(-7), + Value::Int64(-7), + Value::Int128(-7), + ]; + for value in minus_seven { + assert_eq!(bytes(value), integer_bytes(0, -7i128 as u128)); + } + assert_eq!(bytes(Value::Int128(i128::MIN)), integer_bytes(0, 1 << 127)); + assert_eq!(bytes(Value::Int128(-1)), integer_bytes(0, u128::MAX)); + assert_eq!(bytes(Value::Int128(0)), integer_bytes(1, 0)); + assert_eq!( + bytes(Value::UInt128(u128::MAX)), + integer_bytes(1, u128::MAX) + ); + let ascending = [ + Value::Int128(i128::MIN), + Value::Int64(i64::MIN), + Value::Int8(-1), + Value::UInt8(0), + Value::Int8(i8::MAX), + Value::UInt64(u64::MAX), + Value::Int128(i128::MAX), + Value::UInt128(u128::MAX), + ]; + for pair in ascending.windows(2) { + assert!(bytes(pair[0].clone()) < bytes(pair[1].clone())); + } +} + +#[quickcheck] +fn signed_and_unsigned_integers_agree_when_equal(value: u64) -> bool { + bytes(Value::UInt64(value)) == bytes(Value::Int128(value.into())) + && bytes(Value::UInt128(value.into())) == bytes(Value::Int128(value.into())) +} + +#[quickcheck] +fn integer_order_agrees_with_numeric_order(a: i64, b: u64) -> bool { + let (wide_a, wide_b) = (i128::from(a), i128::from(b)); + bytes(Value::Int64(a)).cmp(&bytes(Value::UInt64(b))) == wide_a.cmp(&wide_b) + && bytes(Value::Int128(wide_a * wide_b)).cmp(&bytes(Value::Int64(a))) + == (wide_a * wide_b).cmp(&wide_a) +} + +#[test] +fn every_value_reports_its_kinds_domain() { + #[allow(unused_mut)] // optional features extend the list + let mut values = vec![ + Value::Int8(1), + Value::UInt8(1), + Value::Int16(1), + Value::UInt16(1), + Value::Int32(1), + Value::UInt32(1), + Value::Int64(1), + Value::UInt64(1), + Value::Int128(1), + Value::UInt128(1), + Value::Float32(1.0), + Value::Float64(1.0), + Value::String("a".into()), + Value::Bytes(Protected::new(vec![1])), + ]; + #[cfg(feature = "chrono")] + values.extend([ + Value::Date(chrono::NaiveDate::MIN), + Value::Timestamp(chrono::DateTime::UNIX_EPOCH), + ]); + #[cfg(feature = "rust_decimal")] + values.push(Value::Decimal(rust_decimal::Decimal::ONE)); + for value in values { + let kind = value.kind().expect("scalar"); + let input = equality_input(&value).expect("valid test value"); + assert_eq!(Some(input.domain), equality_domain(kind), "{kind}"); + } + for &kind in ValueKind::ALL { + let refused = matches!(kind, ValueKind::Bool | ValueKind::Array | ValueKind::Object); + assert_eq!(equality_domain(kind).is_none(), refused, "{kind}"); + } +} + +#[test] +fn nfc_expansion_fills_an_exactly_sized_buffer() { + // U+0344 is two UTF-8 bytes; its NFC form U+0308 U+0301 is four. + let normalized = text_nfc("\u{344}".as_bytes()).expect("valid test value"); + assert_eq!(normalized.risky_ref(), "\u{308}\u{301}".as_bytes()); + assert_eq!(normalized.risky_ref().capacity(), 4); +} + #[test] fn unicode_version_and_equivalence_are_frozen() { - assert_eq!(unicode_normalization::UNICODE_VERSION, (16, 0, 0)); + // Normalization stability makes newer NFC tables safe for text that is + // assigned in Unicode 16; the assignment table is the exact pin. + assert!(unicode_normalization::UNICODE_VERSION >= (16, 0, 0)); assert_eq!(unicode_general_category::UNICODE_VERSION, (16, 0, 0)); assert_eq!(bytes(Value::String("e\u{301}".into())), "é".as_bytes()); assert_ne!( @@ -61,7 +178,7 @@ fn every_non_scalar_is_explicitly_refused() { ]; for (value, error) in cases { assert_eq!( - equality_bytes(&value).expect_err("unsupported input"), + equality_input(&value).expect_err("unsupported input"), error ); assert!(!error.to_string().is_empty()); diff --git a/packages/hmac/tests/value_equality.rs b/packages/hmac/tests/value_equality.rs index 3a4b39fa..b0e2785d 100644 --- a/packages/hmac/tests/value_equality.rs +++ b/packages/hmac/tests/value_equality.rs @@ -1,6 +1,8 @@ //! Vectors independently calculated with Python hashlib/hmac and explicit //! big-endian canonical payloads; key = 0x0b * 32, empty context, PAE framing. -use vitaminc_aead_value::{canonical::equality_bytes, Value, ValueKind}; +//! Every integer width shares one domain and payload, so each integer kind +//! carries the same vector for the same number. +use vitaminc_aead_value::{canonical::equality_input, Value, ValueKind}; use vitaminc_hmac::HmacSha256Prf; use vitaminc_prf::{CanonicalError, PrfEncoding, PrfError, PrfKeyInit, PrfValue}; use vitaminc_protected::{Controlled, Protected}; @@ -15,63 +17,63 @@ fn every_equality_domain_has_a_known_answer() { let vectors = [ ( Value::Int8(-7), - "vitaminc/prf/value/int8-orderable/v1", - "79", - "45b646e792baac429bc9b0909bfee1f5d830340c2689dce4a157f7cd3f5c52f3", + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", ), ( Value::Int16(-7), - "vitaminc/prf/value/int16-orderable/v1", - "7ff9", - "910b81e2c549234d1853e3d638b211586992e5ec46004f2d99d2287201dd6b64", + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", ), ( Value::Int32(-7), - "vitaminc/prf/value/int32-orderable/v1", - "7ffffff9", - "c8e0b6c9ea09379eebe54ca54775961eedba9a140a60d5e1cb2a2e74e6971152", + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", ), ( Value::Int64(-7), - "vitaminc/prf/value/int64-orderable/v1", - "7ffffffffffffff9", - "230a90e2b90dca97dcb96b6f8949fc481eac133c6009026a41b8d69c4df984a2", + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", ), ( Value::Int128(-7), - "vitaminc/prf/value/int128-orderable/v1", - "7ffffffffffffffffffffffffffffff9", - "86ea7b332d238ddeaba562eb280075e1345eb9835f25a2ec0d4e239a9a341951", + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", ), ( Value::UInt8(7), - "vitaminc/prf/value/uint8-orderable/v1", - "07", - "93bf0e77c1b9e2859c6307cea9a3c36a0eec73bc0930927bce0d147d627fab34", + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", ), ( Value::UInt16(7), - "vitaminc/prf/value/uint16-orderable/v1", - "0007", - "75adfa1dd83181469e110035f65cc39710887aa9dbc674b5f9f938bd69934ddc", + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", ), ( Value::UInt32(7), - "vitaminc/prf/value/uint32-orderable/v1", - "00000007", - "d1348bee44399b980d31a146f062a6398a4c12ac118d33a80fe4fede2c970e7c", + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", ), ( Value::UInt64(7), - "vitaminc/prf/value/uint64-orderable/v1", - "0000000000000007", - "7fdc5f704d19e6a9dbcc43943379e4c7f6322146f2e78efd1799b130d772cda9", + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", ), ( Value::UInt128(7), - "vitaminc/prf/value/uint128-orderable/v1", - "00000000000000000000000000000007", - "41d5f93103768546432733266bf168544b82cc329c97b91a96cf70a1db3e78d9", + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", ), ( Value::Float32(1.5), @@ -122,7 +124,7 @@ fn every_equality_domain_has_a_known_answer() { covered.push(kind); assert_eq!(PrfEncoding::for_value_kind(kind).unwrap().as_str(), domain); assert_eq!( - hex(equality_bytes(&value).unwrap().risky_ref()), + hex(equality_input(&value).unwrap().bytes.risky_ref()), payload, "{kind}" ); diff --git a/packages/prf/README.md b/packages/prf/README.md index 9a17172d..e554a360 100644 --- a/packages/prf/README.md +++ b/packages/prf/README.md @@ -111,20 +111,28 @@ scope the value above resolves through ### Equality terms from `Value` -`PrfValue` is implemented for `&vitaminc_aead_value::Value`. It borrows the -source and derives one scalar equality term. Booleans, containers, null, -undefined and passthrough return `PrfError::Canonical`; text with code points -unassigned in Unicode 16 is also refused. `chrono` and `rust_decimal` features -enable the corresponding value variants. +With the `value` feature, `PrfValue` is implemented for +`&vitaminc_aead_value::Value`. It borrows the source and derives one scalar +equality term. Booleans, containers, null, undefined and passthrough return +`PrfError::Canonical`; text with code points unassigned in Unicode 16 is also +refused. The `chrono` and `rust_decimal` features enable `value` and the +corresponding value variants. Without `value`, this crate does not depend on +`vitaminc-aead-value` or its Unicode tables. The shared `vitaminc_aead_value::canonical` module owns the exhaustive value -dispatch. It uses `orderable-bytes` at each scalar's natural width: signed -integers are biased big-endian, floats fold signed zero and every NaN to one -positive quiet NaN, timestamps truncate fractional nanoseconds to microseconds -while retaining the 12-byte seconds/nanoseconds layout, and decimal encodings -normalize scale and signed zero. Text equality is Unicode 16 NFC without -accent or case folding. Unicode normalization and assignment tables are pinned. -Ciphertext retains the original value, including scale and sub-microsecond data. +dispatch and returns each term's domain with its bytes. Every integer kind, +`int8` through `uint128`, shares one domain and a 17-byte encoding (a sign +byte, then a big-endian 128-bit two's-complement word), so equal numbers give +equal terms whatever width wrote them: a JavaScript `BigInt` stored as `int8` +matches the same number stored as `int64` from Go. Other scalars use +`orderable-bytes` at their natural width: floats fold signed zero and every +NaN to one positive quiet NaN, timestamps truncate fractional nanoseconds to +microseconds while retaining the 12-byte seconds/nanoseconds layout, and +decimal encodings normalize scale and signed zero. Text equality is Unicode 16 +NFC without accent or case folding; the Unicode assignment table is pinned +exactly, and NFC stability keeps newer normalization tables compatible. +Ciphertext retains the original value, including its integer width, scale and +sub-microsecond data. `PrfEncoding::for_value_kind` exposes the new, versioned domains. Existing primitive `PrfValue` implementations retain their established little-endian or diff --git a/packages/prf/src/value.rs b/packages/prf/src/value.rs index bc54884a..8bf1cba1 100644 --- a/packages/prf/src/value.rs +++ b/packages/prf/src/value.rs @@ -1,34 +1,21 @@ //! Equality terms for the language-neutral value model. -use vitaminc_aead_value::{canonical::equality_bytes, Value, ValueKind}; +use vitaminc_aead_value::{ + canonical::{equality_domain, equality_input, EqualityInput}, + Value, ValueKind, +}; use crate::{IntoPrfContext, Prf, PrfEncoding, PrfError, PrfValue, PrfVisitor}; impl PrfEncoding { - /// The canonical equality domain for a value kind. Legacy primitive - /// `PrfValue` encodings retain their existing domains and bytes. + /// The canonical equality domain for a value kind. Every integer kind + /// shares one domain. Legacy primitive `PrfValue` encodings retain their + /// existing domains and bytes. pub const fn for_value_kind(kind: ValueKind) -> Option { - let name = match kind { - ValueKind::Int8 => "vitaminc/prf/value/int8-orderable/v1", - ValueKind::UInt8 => "vitaminc/prf/value/uint8-orderable/v1", - ValueKind::Int16 => "vitaminc/prf/value/int16-orderable/v1", - ValueKind::UInt16 => "vitaminc/prf/value/uint16-orderable/v1", - ValueKind::Int32 => "vitaminc/prf/value/int32-orderable/v1", - ValueKind::UInt32 => "vitaminc/prf/value/uint32-orderable/v1", - ValueKind::Int64 => "vitaminc/prf/value/int64-orderable/v1", - ValueKind::UInt64 => "vitaminc/prf/value/uint64-orderable/v1", - ValueKind::Int128 => "vitaminc/prf/value/int128-orderable/v1", - ValueKind::UInt128 => "vitaminc/prf/value/uint128-orderable/v1", - ValueKind::Float32 => "vitaminc/prf/value/float32-orderable/v1", - ValueKind::Float64 => "vitaminc/prf/value/float64-orderable/v1", - ValueKind::Date => "vitaminc/prf/value/date-orderable/v1", - ValueKind::Timestamp => "vitaminc/prf/value/timestamp-micros-orderable/v1", - ValueKind::Decimal => "vitaminc/prf/value/decimal-orderable/v1", - ValueKind::String => "vitaminc/prf/value/text-nfc/unicode-16/v1", - ValueKind::Bytes => "vitaminc/prf/value/bytes/v1", - _ => return None, - }; - Some(Self::new(name)) + match equality_domain(kind) { + Some(domain) => Some(Self::new(domain)), + None => None, + } } } @@ -41,18 +28,15 @@ impl PrfValue for &Value { { // The exhaustive Value dispatch lives in the shared canonical layer, // inside Value's crate, where non_exhaustive cannot hide a new kind. - let bytes = match equality_bytes(self) { - Ok(bytes) => bytes, - Err(error) => return prf.failure(PrfError::Canonical(error)), - }; - match self.kind().and_then(PrfEncoding::for_value_kind) { - Some(encoding) => prf.prf_bytes_vec( + // It returns each term's domain with its bytes. + match equality_input(self) { + Ok(EqualityInput { domain, bytes }) => prf.prf_bytes_vec( bytes, - encoding, + PrfEncoding::new(domain), context.into_prf_context().into_owned(), visitor, ), - None => prf.failure(PrfError::Canonical(crate::CanonicalError::UnsupportedValue)), + Err(error) => prf.failure(PrfError::Canonical(error)), } } } @@ -65,18 +49,18 @@ mod tests { #[test] fn value_domains_are_frozen_and_complete() { let expected = [ - (ValueKind::Int8, "vitaminc/prf/value/int8-orderable/v1"), - (ValueKind::UInt8, "vitaminc/prf/value/uint8-orderable/v1"), - (ValueKind::Int16, "vitaminc/prf/value/int16-orderable/v1"), - (ValueKind::UInt16, "vitaminc/prf/value/uint16-orderable/v1"), - (ValueKind::Int32, "vitaminc/prf/value/int32-orderable/v1"), - (ValueKind::UInt32, "vitaminc/prf/value/uint32-orderable/v1"), - (ValueKind::Int64, "vitaminc/prf/value/int64-orderable/v1"), - (ValueKind::UInt64, "vitaminc/prf/value/uint64-orderable/v1"), - (ValueKind::Int128, "vitaminc/prf/value/int128-orderable/v1"), + (ValueKind::Int8, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::UInt8, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::Int16, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::UInt16, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::Int32, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::UInt32, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::Int64, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::UInt64, "vitaminc/prf/value/integer-orderable/v1"), + (ValueKind::Int128, "vitaminc/prf/value/integer-orderable/v1"), ( ValueKind::UInt128, - "vitaminc/prf/value/uint128-orderable/v1", + "vitaminc/prf/value/integer-orderable/v1", ), ( ValueKind::Float32, From 73eb0d1f8ec01c3deb415cc3f25535c19680e265 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:30:55 +1100 Subject: [PATCH 06/28] fix(aead-napi): keep scalar wrappers lossless and their errors typed Timestamps with years outside 0000-9999 decrypt in ISO 8601 expanded form (+12000-..., -0005-...), which the RFC 3339 parser refused, so a decrypted { timestamp } could not be encrypted again. Accept the expanded form and pin both directions with corpus vectors. A wrapper whose payload is not a string ({ date: new Date() }, { decimal: 1n }, { timestamp: 1700000000000 }) now throws the wrapper's ERR_INVALID_* code instead of a generic N-API error. Parsing goes through one match on the key, without the per-parser fn casts. Drop the JS Date range check on decryption: chrono's range lies inside JS Date's, so it could never fail. Document that the wrapper keys are reserved, which makes decrypt-then-encrypt lossy for one-key objects named date, timestamp or decimal written by other languages. Refs #381 --- packages/aead-napi/src/convert.rs | 65 ++++++++++++------- packages/aead-napi/src/scalar.rs | 38 ++++++++++- .../aead-napi/tests/value-conformance.cjs | 17 +++++ testdata/README.md | 3 +- testdata/value-conformance.json | 14 ++++ 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index fb5e999a..4c79726f 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -10,7 +10,13 @@ //! integer width, preferring signed at a given width, through 128 bits. //! Every integer kind decodes as BigInt. Date maps to Timestamp; calendar //! dates use `{ date: "YYYY-MM-DD" }` and decimals `{ decimal: "1.50" }`. -//! Timestamp values beyond Date's precision/range use an RFC3339 wrapper. +//! Timestamps finer than Date's millisecond precision, and leap seconds, +//! use a `{ timestamp }` wrapper: RFC 3339, or an ISO 8601 expanded year +//! outside 0000-9999. +//! +//! The wrapper keys are reserved: a one-key object named `date`, +//! `timestamp` or `decimal` is always read as a wrapper, including one +//! decrypted from data another language wrote as an ordinary object. use napi::bindgen_prelude::{ Array, BigInt, Buffer, FromNapiValue, JsObjectValue, JsValue, Object, ToNapiValue, Uint8Array, @@ -295,35 +301,25 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { let obj = Object::from_unknown(unknown)?; ensure_plain_object(&obj)?; let keys = own_enumerable_keys(&obj)?; - if keys.len() == 1 { - let parser = match keys[0].as_str() { + if let [key] = keys.as_slice() { + use scalar::ConversionError::{InvalidDate, InvalidDecimal, InvalidTimestamp}; + let parsed = match key.as_str() { "date" => Some( - scalar::date - as fn(&str) -> std::result::Result, + wrapper_text(&obj, key, InvalidDate)? + .and_then(|text| scalar::date(&text)), ), "timestamp" => Some( - scalar::timestamp - as fn(&str) -> std::result::Result, + wrapper_text(&obj, key, InvalidTimestamp)? + .and_then(|text| scalar::timestamp(&text)), ), "decimal" => Some( - scalar::decimal - as fn(&str) -> std::result::Result, + wrapper_text(&obj, key, InvalidDecimal)? + .and_then(|text| scalar::decimal(&text)), ), _ => None, }; - if let Some(parse) = parser { - let input = get_property_unknown(&obj, &keys[0])?; - if keys[0] == "decimal" && input.get_type()? == ValueType::Number { - let number = f64::from_unknown(input)?; - let error = if number.is_finite() { - scalar::ConversionError::InvalidDecimal - } else { - scalar::ConversionError::NonFiniteDecimal - }; - return Err(conversion_error(Env::from_raw(obj.value().env), error)); - } - let text = String::from_unknown(input)?; - return parse(&text) + if let Some(parsed) = parsed { + return parsed .map_err(|e| conversion_error(Env::from_raw(obj.value().env), e)); } } @@ -377,9 +373,10 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { Value::Date(v) => wrapper_to_js(env, "date", v.to_string()), Value::Decimal(v) => wrapper_to_js(env, "decimal", v.to_string()), Value::Timestamp(v) => { + // chrono's range (about ±8.21e15 ms) lies inside JS Date's + // (±8.64e15 ms), so only precision and leap seconds need a wrapper. if v.timestamp_subsec_nanos() < 1_000_000_000 && v.timestamp_subsec_nanos() % 1_000_000 == 0 - && v.timestamp_millis().unsigned_abs() <= 8_640_000_000_000_000 { Ok(Env::from_raw(env) .create_date(v.timestamp_millis() as f64)? @@ -439,6 +436,28 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { } } +/// The string payload of a one-key scalar wrapper. Any other payload +/// (`{ date: new Date() }`, `{ decimal: 1n }`, ...) is refused with the +/// wrapper's own typed error rather than a generic N-API one; a non-finite +/// number in `{ decimal }` keeps its distinct code. +fn wrapper_text( + obj: &Object, + key: &str, + invalid: scalar::ConversionError, +) -> Result> { + let input = get_property_unknown(obj, key)?; + Ok(match input.get_type()? { + ValueType::String => Ok(String::from_unknown(input)?), + ValueType::Number + if invalid == scalar::ConversionError::InvalidDecimal + && !f64::from_unknown(input)?.is_finite() => + { + Err(scalar::ConversionError::NonFiniteDecimal) + } + _ => Err(invalid), + }) +} + fn conversion_error(env: Env, error: scalar::ConversionError) -> Error { match env.throw_type_error(&error.to_string(), Some(error.code())) { Ok(()) => Error::new(Status::PendingException, error.to_string()), diff --git a/packages/aead-napi/src/scalar.rs b/packages/aead-napi/src/scalar.rs index 08db117b..67e68b1a 100644 --- a/packages/aead-napi/src/scalar.rs +++ b/packages/aead-napi/src/scalar.rs @@ -100,7 +100,15 @@ pub(crate) fn date(text: &str) -> Result { } pub(crate) fn timestamp(text: &str) -> Result { - DateTime::parse_from_rfc3339(text) + // RFC 3339 only spells years 0000-9999. Outside that range decryption + // emits an ISO 8601 expanded year (`+12000-…`, `-0005-…`), so accept that + // form too or a decrypted timestamp could not be encrypted again. + let parsed = if text.starts_with(['+', '-']) { + DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f%#z") + } else { + DateTime::parse_from_rfc3339(text) + }; + parsed .map(|value| Value::Timestamp(value.with_timezone(&Utc))) .map_err(|_| ConversionError::InvalidTimestamp) } @@ -232,6 +240,34 @@ mod tests { ); } assert!(timestamp("2016-12-31T23:59:60.123456789Z").is_ok()); + // Decryption spells years outside 0000-9999 in ISO 8601 expanded form; + // each must parse back to the same instant. + for value in [ + DateTime::::MIN_UTC, + DateTime::::MAX_UTC, + "+12000-01-01T00:00:00.000000001Z".parse().expect("valid"), + "-0005-03-01T12:00:00.000000001Z".parse().expect("valid"), + ] { + let text = value.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); + assert!( + matches!(timestamp(&text), Ok(Value::Timestamp(v)) if v == value), + "{text}" + ); + } + assert!(matches!( + timestamp("-0005-03-01T12:00:00+01:00"), + Ok(Value::Timestamp(v)) if v.to_rfc3339() == "-0005-03-01T11:00:00+00:00" + )); + for invalid in [ + "+12000-01-01", + "+12000-13-01T00:00:00Z", + "-0005-03-01T12:00:00", + ] { + assert_eq!( + timestamp(invalid).err().expect("invalid timestamp"), + ConversionError::InvalidTimestamp + ); + } assert_eq!( timestamp("bad").err().expect("invalid timestamp"), ConversionError::InvalidTimestamp diff --git a/packages/aead-napi/tests/value-conformance.cjs b/packages/aead-napi/tests/value-conformance.cjs index b595b90d..d604d3e6 100644 --- a/packages/aead-napi/tests/value-conformance.cjs +++ b/packages/aead-napi/tests/value-conformance.cjs @@ -44,6 +44,23 @@ assert.deepEqual(addon.decode(addon.encode(nested)), nested); assert.throws(() => addon.encode(new Map([['a',1]]))); assert.throws(() => addon.encode([,1])); assert.throws(() => addon.encode('\ud800')); +// Non-string wrapper payloads keep the wrapper's typed error code. +for (const [input, code] of [ + [{date: new Date(0)}, 'ERR_INVALID_DATE'], + [{date: {y: 2024}}, 'ERR_INVALID_DATE'], + [{timestamp: 1700000000000}, 'ERR_INVALID_TIMESTAMP'], + [{timestamp: null}, 'ERR_INVALID_TIMESTAMP'], + [{decimal: 1n}, 'ERR_INVALID_DECIMAL'], + [{decimal: 1.5}, 'ERR_INVALID_DECIMAL'], +]) assert.throws(() => addon.encode(input), {name: 'TypeError', code}, JSON.stringify(Object.keys(input))); +// Expanded-year timestamps decrypt as wrappers and encrypt again unchanged. +for (const timestamp of ['+12000-01-01T00:00:00.000000001Z', '-0005-03-01T12:00:00.000000001Z']) { + assert.deepEqual(addon.decode(addon.encode({timestamp})), {timestamp}); +} +// Reserved wrapper keys: a one-key object from another language reads as a +// wrapper, so this decrypt-then-encrypt is lossy by design (documented). +assert.deepEqual(addon.decode(addon.encode({date: '2024-01-01'})), {date: '2024-01-01'}); +assert.throws(() => addon.encode({date: 'tomorrow'}), {code: 'ERR_INVALID_DATE'}); const ordinary = {date:'not a wrapper', extra:true}; assert.deepEqual(addon.decode(addon.encode(ordinary)), ordinary); console.log(`Node conformance: ${corpus.vectors.length} values, ${corpus.malformed.length} refusals, BigInt boundaries passed`); diff --git a/testdata/README.md b/testdata/README.md index 51975a5b..c75b80a8 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -9,7 +9,8 @@ nanoseconds, and decimals retain their flags and 96-bit coefficient. Each valid vector gives a name, kind, expected host value and hexadecimal transport bytes. Integer and decimal values are strings to avoid JSON number rounding. Bytes are hexadecimal. `js_date` selects a native JavaScript Date; -other timestamps retain their nanoseconds in a wrapper. `malformed` vectors +other timestamps retain their nanoseconds in a wrapper, spelled RFC 3339 for +years 0000-9999 and with an ISO 8601 expanded year (`+12000-…`) otherwise. `malformed` vectors must be refused by the decoders. Rust re-encodes every valid vector byte-for-byte. The optional `js_reencode` diff --git a/testdata/value-conformance.json b/testdata/value-conformance.json index 18b345ee..7386ba4c 100644 --- a/testdata/value-conformance.json +++ b/testdata/value-conformance.json @@ -149,6 +149,20 @@ "transport": "133b000000000000001597f642", "js_date": false }, + { + "name": "timestamp-expanded-year", + "kind": "timestamp", + "value": "+12000-01-01T00:00:00.000000001Z", + "transport": "130001d6b14900000001000000", + "js_date": false + }, + { + "name": "timestamp-negative-year", + "kind": "timestamp", + "value": "-0005-03-01T12:00:00.000000001Z", + "transport": "1340a2727df1ffffff01000000", + "js_date": false + }, { "name": "decimal", "kind": "decimal", From 47176beee04fcfe96528162a56e2de69f198ce97 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 12:30:55 +1100 Subject: [PATCH 07/28] perf(go): look up scalar widths without allocating on decode decodeScalar built a tag-to-width map on every call. A switch keeps the decode path allocation-free. Refs #381 --- bindings/go/vcffi/scalars.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/bindings/go/vcffi/scalars.go b/bindings/go/vcffi/scalars.go index 000800a1..fa6f3c02 100644 --- a/bindings/go/vcffi/scalars.go +++ b/bindings/go/vcffi/scalars.go @@ -70,12 +70,7 @@ func (e Encoder) Decimal(v vcvalue.Decimal) { } func decodeScalar(r *reader, tag byte) (any, error) { - widths := map[byte]int{tagInt8: 1, tagUint8: 1, tagInt16: 2, tagUint16: 2, tagDate: 4, tagTimestamp: 12, tagInt128: 16, tagUint128: 16, tagDecimal: 16} - width, ok := widths[tag] - if !ok { - return nil, ErrMalformed - } - b, err := r.take(width) + b, err := r.take(scalarWidth(tag)) if err != nil { return nil, err } @@ -122,3 +117,21 @@ func decodeScalar(r *reader, tag byte) (any, error) { return nil, ErrMalformed } } + +// scalarWidth is the fixed payload width of an extended scalar tag, or zero +// for a tag decodeScalar does not handle. +func scalarWidth(tag byte) int { + switch tag { + case tagInt8, tagUint8: + return 1 + case tagInt16, tagUint16: + return 2 + case tagDate: + return 4 + case tagTimestamp: + return 12 + case tagInt128, tagUint128, tagDecimal: + return 16 + } + return 0 +} From d052293164f8596a1272e53c462061cd2f8cd9c6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 14:50:07 +1100 Subject: [PATCH 08/28] fix(aead-value): match equal floats across widths in equality terms Go can write float32 while JavaScript numbers are always float64, so per-width domains meant 1.5 from Go never matched 1.5 from JS. Both float kinds now share vitaminc/prf/value/float-orderable/v1: a float32 widens exactly to float64, then folds signed zero and NaN as before. Numbers that differ between widths, such as 0.1, still give different terms. The widening uses integer operations only, so subnormal inputs cannot take a slower hardware path. It is checked against the hardware conversion at every exponent and subnormal bit, by property test, and (on demand) for every f32 bit pattern. Refs #380 --- packages/aead-value/src/canonical.rs | 49 ++++++++----- packages/aead-value/src/canonical_tests.rs | 81 +++++++++++++++++++--- packages/hmac/tests/value_equality.rs | 13 ++-- packages/prf/README.md | 11 ++- packages/prf/src/value.rs | 10 +-- 5 files changed, 121 insertions(+), 43 deletions(-) diff --git a/packages/aead-value/src/canonical.rs b/packages/aead-value/src/canonical.rs index 1b2683a2..5b8a902d 100644 --- a/packages/aead-value/src/canonical.rs +++ b/packages/aead-value/src/canonical.rs @@ -1,8 +1,9 @@ //! Canonical scalar plaintext for search terms, separate from reversible ciphertext. //! -//! Every integer kind shares one 17-byte encoding and domain, so equal -//! numbers match whichever width wrote them. Other numeric encodings come -//! from `orderable-bytes` at their natural widths. Text uses Unicode 16 NFC. +//! Every integer kind shares one 17-byte encoding and domain, and both float +//! kinds share one 8-byte encoding and domain, so equal numbers match +//! whichever width wrote them. Other encodings come from `orderable-bytes` at +//! their natural widths. Text uses Unicode 16 NFC. //! These bytes and their [`domain`] labels are protocol data: changing either //! requires a new domain. Text processing is variable-time; this module makes //! no constant-time claim for Unicode normalization. @@ -48,10 +49,8 @@ impl std::error::Error for CanonicalError {} pub mod domain { /// Every integer kind, `int8` through `uint128`. pub const INTEGER: &str = "vitaminc/prf/value/integer-orderable/v1"; - /// `float32`. - pub const FLOAT32: &str = "vitaminc/prf/value/float32-orderable/v1"; - /// `float64`. - pub const FLOAT64: &str = "vitaminc/prf/value/float64-orderable/v1"; + /// `float32` and `float64`, as `float64`. + pub const FLOAT: &str = "vitaminc/prf/value/float-orderable/v1"; /// `date`. pub const DATE: &str = "vitaminc/prf/value/date-orderable/v1"; /// `timestamp`, truncated to microseconds. @@ -89,8 +88,7 @@ pub const fn equality_domain(kind: ValueKind) -> Option<&'static str> { | ValueKind::UInt64 | ValueKind::Int128 | ValueKind::UInt128 => Some(domain::INTEGER), - ValueKind::Float32 => Some(domain::FLOAT32), - ValueKind::Float64 => Some(domain::FLOAT64), + ValueKind::Float32 | ValueKind::Float64 => Some(domain::FLOAT), ValueKind::Date => Some(domain::DATE), ValueKind::Timestamp => Some(domain::TIMESTAMP), ValueKind::Decimal => Some(domain::DECIMAL), @@ -106,7 +104,10 @@ pub const fn equality_domain(kind: ValueKind) -> Option<&'static str> { /// Integers of every width share [`domain::INTEGER`]: a sign byte (`0x00` /// negative, `0x01` otherwise) then the value as a big-endian 128-bit /// two's-complement word, which orders correctly across `i128::MIN` to -/// `u128::MAX`. Float zero signs and NaN payloads are folded. Timestamps truncate their +/// `u128::MAX`. Both float widths share [`domain::FLOAT`]: a `float32` widens +/// exactly to `float64`, so it matches a `float64` only when the numbers are +/// equal (`float32` 1.5 matches, `float32` 0.1 does not match `float64` 0.1). +/// Float zero signs and NaN payloads are folded. Timestamps truncate their /// fractional second to microseconds (also before the epoch and during leap /// seconds), retaining the upstream 12-byte seconds/nanoseconds layout. /// Decimal encoding itself normalizes scale, including signed zero, without @@ -125,8 +126,8 @@ pub fn equality_input(value: &Value) -> Result { Value::UInt64(v) => (domain::INTEGER, unsigned((*v).into())), Value::Int128(v) => (domain::INTEGER, signed(*v)), Value::UInt128(v) => (domain::INTEGER, unsigned(*v)), - Value::Float32(v) => (domain::FLOAT32, fixed(&canonical_f32(*v))), - Value::Float64(v) => (domain::FLOAT64, fixed(&canonical_f64(*v))), + Value::Float32(v) => (domain::FLOAT, fixed(&canonical_f64(widen_f32(*v)))), + Value::Float64(v) => (domain::FLOAT, fixed(&canonical_f64(*v))), #[cfg(feature = "chrono")] Value::Date(v) => (domain::DATE, fixed(v)), #[cfg(feature = "chrono")] @@ -173,12 +174,26 @@ where Protected::new(bytes.risky_ref().as_ref().to_vec()) } -fn canonical_f32(value: f32) -> f32 { +/// `f64::from(f32)` in integer operations only. A hardware conversion can +/// take a slow path on subnormal inputs, which would leak timing; this keeps +/// the float path free of floating-point arithmetic. +fn widen_f32(value: f32) -> f64 { let bits = value.to_bits(); - let magnitude = bits & 0x7fff_ffff; - let nan = 0u32.wrapping_sub((magnitude > 0x7f80_0000) as u32); - let nonzero = 0u32.wrapping_sub((magnitude != 0) as u32); - f32::from_bits((bits & !nan).wrapping_add(0x7fc0_0000 & nan) & nonzero) + let sign = u64::from(bits >> 31) << 63; + let exponent = (bits >> 23) & 0xff; + let mantissa = u64::from(bits & 0x7f_ffff); + let zero_exponent = 0u64.wrapping_sub((exponent == 0) as u64); + let max_exponent = 0u64.wrapping_sub((exponent == 0xff) as u64); + let nonzero = 0u64.wrapping_sub((mantissa != 0) as u64); + // Normal, infinite and NaN: rebias the exponent (127 to 1023, or all + // ones stays all ones) and left-align the mantissa. + let rebiased = ((u64::from(exponent) + 896) & !max_exponent) | (0x7ff & max_exponent); + let normal = (rebiased << 52) | (mantissa << 29); + // Subnormal: the highest set bit becomes the implicit leading one. `| 1` + // keeps the shift in range for a zero mantissa, which is masked out. + let top = u64::from(63 - (mantissa | 1).leading_zeros()); + let subnormal = ((top + 874) << 52) | ((mantissa << (52 - top)) & ((1 << 52) - 1)); + f64::from_bits(sign | (normal & !zero_exponent) | (subnormal & zero_exponent & nonzero)) } fn canonical_f64(value: f64) -> f64 { diff --git a/packages/aead-value/src/canonical_tests.rs b/packages/aead-value/src/canonical_tests.rs index beb7e3d2..76dc86de 100644 --- a/packages/aead-value/src/canonical_tests.rs +++ b/packages/aead-value/src/canonical_tests.rs @@ -190,11 +190,11 @@ fn every_non_scalar_is_explicitly_refused() { #[test] fn floats_have_postgres_zero_and_nan_ordering() { assert_eq!(bytes(Value::Float64(-0.0)), bytes(Value::Float64(0.0))); - assert_eq!(bytes(Value::Float32(-0.0)), bytes(Value::Float32(0.0))); + assert_eq!(bytes(Value::Float32(-0.0)), bytes(Value::Float64(0.0))); for bits in [0x7fc0_0000, 0xff80_0001, 0xffff_ffff, 0x7f80_0001] { assert_eq!( bytes(Value::Float32(f32::from_bits(bits))), - [0xff, 0xc0, 0, 0] + [0xff, 0xf8, 0, 0, 0, 0, 0, 0] ); } for bits in [0x7ff8_0000_0000_0000, 0xfff0_0000_0000_0001, u64::MAX] { @@ -203,12 +203,18 @@ fn floats_have_postgres_zero_and_nan_ordering() { [0xff, 0xf8, 0, 0, 0, 0, 0, 0] ); } - assert_eq!(bytes(Value::Float32(1.5)), [0xbf, 0xc0, 0, 0]); - assert_eq!(bytes(Value::Float32(-1.5)), [0x40, 0x3f, 0xff, 0xff]); - assert_eq!(bytes(Value::Float32(f32::INFINITY)), [0xff, 0x80, 0, 0]); + assert_eq!(bytes(Value::Float32(1.5)), [0xbf, 0xf8, 0, 0, 0, 0, 0, 0]); + assert_eq!( + bytes(Value::Float32(-1.5)), + [0x40, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] + ); + assert_eq!( + bytes(Value::Float32(f32::INFINITY)), + [0xff, 0xf0, 0, 0, 0, 0, 0, 0] + ); assert_eq!( bytes(Value::Float32(f32::NEG_INFINITY)), - [0, 0x7f, 0xff, 0xff] + [0, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff] ); let ascending = [f64::NEG_INFINITY, -1.0, 0.0, 1.0, f64::INFINITY, f64::NAN]; for pair in ascending.windows(2) { @@ -224,10 +230,67 @@ fn f64_order_agrees_with_numeric_order(a: f64, b: f64) -> bool { == a.partial_cmp(&b).expect("valid test value") } +#[test] +fn float_widths_share_one_domain_for_equal_numbers() { + for value in [ + 0.0f32, + 1.5, + -0.25, + 16_777_216.0, + f32::MAX, + f32::MIN_POSITIVE, + ] { + assert_eq!( + bytes(Value::Float32(value)), + bytes(Value::Float64(value.into())) + ); + } + // 0.1 is a different number in each width, so the terms differ. + assert_ne!(bytes(Value::Float32(0.1)), bytes(Value::Float64(0.1))); +} + +fn widen_matches_hardware(bits: u32) -> bool { + let value = f32::from_bits(bits); + let (ours, hardware) = (widen_f32(value), f64::from(value)); + // NaN payload bits are folded later; only NaN-ness must agree for them. + if value.is_nan() { + ours.is_nan() && ours.is_sign_negative() == hardware.is_sign_negative() + } else { + ours.to_bits() == hardware.to_bits() + } +} + +#[test] +fn widening_matches_hardware_at_every_exponent_and_subnormal_bit() { + for sign in [0, 1u32 << 31] { + for exponent in 0..=0xffu32 { + for mantissa in [0, 1, 0x40_0000, 0x7f_ffff, 0x2a_aaaa] { + let bits = sign | (exponent << 23) | mantissa; + assert!(widen_matches_hardware(bits), "{bits:#010x}"); + } + } + for shift in 0..23 { + let bits = sign | 1 << shift; + assert!(widen_matches_hardware(bits), "{bits:#010x}"); + assert!( + widen_matches_hardware(bits | ((1 << shift) - 1)), + "{bits:#010x}" + ); + } + } +} + #[quickcheck] -fn f32_canonicalisation_is_idempotent(bits: u32) -> bool { - let value = canonical_f32(f32::from_bits(bits)); - canonical_f32(value).to_bits() == value.to_bits() +fn widening_matches_hardware_conversion(bits: u32) -> bool { + widen_matches_hardware(bits) +} + +/// Every f32 bit pattern; slow in debug builds, so run on demand with +/// `cargo test --release -p vitaminc-aead-value --features canonical -- --ignored`. +#[test] +#[ignore] +fn widening_matches_hardware_for_every_f32() { + assert!((0..=u32::MAX).all(widen_matches_hardware)); } #[quickcheck] diff --git a/packages/hmac/tests/value_equality.rs b/packages/hmac/tests/value_equality.rs index b0e2785d..89ad719b 100644 --- a/packages/hmac/tests/value_equality.rs +++ b/packages/hmac/tests/value_equality.rs @@ -1,7 +1,8 @@ //! Vectors independently calculated with Python hashlib/hmac and explicit //! big-endian canonical payloads; key = 0x0b * 32, empty context, PAE framing. //! Every integer width shares one domain and payload, so each integer kind -//! carries the same vector for the same number. +//! carries the same vector for the same number; both float widths likewise +//! share one domain, with float32 widened exactly to float64. use vitaminc_aead_value::{canonical::equality_input, Value, ValueKind}; use vitaminc_hmac::HmacSha256Prf; use vitaminc_prf::{CanonicalError, PrfEncoding, PrfError, PrfKeyInit, PrfValue}; @@ -77,15 +78,15 @@ fn every_equality_domain_has_a_known_answer() { ), ( Value::Float32(1.5), - "vitaminc/prf/value/float32-orderable/v1", - "bfc00000", - "b14abcd83e1c53639315fae63c96b98d93cfec5793929ff37376dff64b3f73e9", + "vitaminc/prf/value/float-orderable/v1", + "bff8000000000000", + "8a4e5233cd0f982178536465a0ced2b2d3451198a57da553970a17a0069be1e1", ), ( Value::Float64(-1.5), - "vitaminc/prf/value/float64-orderable/v1", + "vitaminc/prf/value/float-orderable/v1", "4007ffffffffffff", - "e31d40623c1470970496696ec78f168e100d0404e5e5f1da83b9d5d01402e48a", + "cf40d5d1d3da65f02bb0ceaabebce6c2569a129cb890e19a9b4dbacefb763adb", ), ( Value::Date(chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()), diff --git a/packages/prf/README.md b/packages/prf/README.md index e554a360..033334b0 100644 --- a/packages/prf/README.md +++ b/packages/prf/README.md @@ -124,9 +124,14 @@ dispatch and returns each term's domain with its bytes. Every integer kind, `int8` through `uint128`, shares one domain and a 17-byte encoding (a sign byte, then a big-endian 128-bit two's-complement word), so equal numbers give equal terms whatever width wrote them: a JavaScript `BigInt` stored as `int8` -matches the same number stored as `int64` from Go. Other scalars use -`orderable-bytes` at their natural width: floats fold signed zero and every -NaN to one positive quiet NaN, timestamps truncate fractional nanoseconds to +matches the same number stored as `int64` from Go. Both float kinds share one +domain: a `float32` widens exactly to `float64` (in integer operations, so +subnormals take no slow path), then signed zero and every NaN fold to one +positive quiet NaN. Equal numbers match across widths; `float32` 0.1 and +`float64` 0.1 are different numbers and do not. Integer and float domains are +distinct, so a JavaScript `number` 5 (`float64`) does not match an `int64` 5; +use `BigInt` for integer data. Other scalars use `orderable-bytes` at their +natural width: timestamps truncate fractional nanoseconds to microseconds while retaining the 12-byte seconds/nanoseconds layout, and decimal encodings normalize scale and signed zero. Text equality is Unicode 16 NFC without accent or case folding; the Unicode assignment table is pinned diff --git a/packages/prf/src/value.rs b/packages/prf/src/value.rs index 8bf1cba1..a80bff5d 100644 --- a/packages/prf/src/value.rs +++ b/packages/prf/src/value.rs @@ -62,14 +62,8 @@ mod tests { ValueKind::UInt128, "vitaminc/prf/value/integer-orderable/v1", ), - ( - ValueKind::Float32, - "vitaminc/prf/value/float32-orderable/v1", - ), - ( - ValueKind::Float64, - "vitaminc/prf/value/float64-orderable/v1", - ), + (ValueKind::Float32, "vitaminc/prf/value/float-orderable/v1"), + (ValueKind::Float64, "vitaminc/prf/value/float-orderable/v1"), (ValueKind::Date, "vitaminc/prf/value/date-orderable/v1"), ( ValueKind::Timestamp, From 26015d612a0ac1e3cc71b336b07715da117ea6cc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 14:50:13 +1100 Subject: [PATCH 09/28] docs(aead-napi): steer integer data to BigInt for equality terms A JS number is always Float64, and float and integer equality domains are distinct, so integer columns written by other languages only match BigInt. Refs #381 --- packages/aead-napi/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index a3d693f8..b5d5605b 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -17,7 +17,10 @@ cross-language leaf wire format. This crate adds only what is JS-specific: integer width through 128 bits, preferring signed at each width. Every integer kind decodes as `BigInt`; `Float32` widens exactly to `number`. Equality terms share one domain across integer widths, so a `BigInt` - written as `int8` matches the same number written as `int64` from Go. + written as `int8` matches the same number written as `int64` from Go; + both float widths likewise share one domain. Integers and floats stay + distinct, so a JS `number` 5 (`Float64`) does not match an `int64` 5: + use `BigInt` for integer data. - JS `Date` ↔ `Timestamp` at millisecond precision. Sub-millisecond timestamps and leap seconds decode as `{ timestamp: "RFC3339-with-nanoseconds" }`, also accepted on input, so no From cf58b08498c7f9434ef4f9b0cf065e45921bac59 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 14:55:32 +1100 Subject: [PATCH 10/28] test(aead-napi): move wrapper payload errors out of the N-API glue wrapper_text tripped the CRAP gate (CC 9, no coverage): it takes a live napi Object, so Rust coverage cannot reach it, and the Node test runs in a separately built addon. Move the error choice into the pure scalar::non_string_payload, unit-tested, and exempt the remaining glue by name like the other live N-API helpers. The Node conformance test still exercises it end to end, including under mutation testing. Refs #381 --- .cargo-crap.toml | 3 ++- packages/aead-napi/README.md | 12 +++++------ packages/aead-napi/src/convert.rs | 18 ++++++++--------- packages/aead-napi/src/scalar.rs | 33 +++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.cargo-crap.toml b/.cargo-crap.toml index a9520d9e..40fb4c50 100644 --- a/.cargo-crap.toml +++ b/.cargo-crap.toml @@ -53,7 +53,7 @@ exclude = ["tests/**", "src/test_support.rs"] # # * The `vitaminc-aead-napi` conversion functions and their JS-boundary # helpers (`own_enumerable_keys`, `get_property_unknown`, -# `ensure_plain_object`): each takes a live `napi_env`/`Unknown`/`Object`, +# `ensure_plain_object`, `wrapper_text`): each takes a live `napi_env`/`Unknown`/`Object`, # which only exists inside a running V8 isolate. `cargo test` has no Node # host (the `napi/noop` dev-dependency stubs the symbols so the crate # *links*, it does not make the calls work), so these cannot be reached @@ -75,4 +75,5 @@ allow = [ "define_own_property", "get_property_unknown", "ensure_plain_object", + "wrapper_text", ] diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index b5d5605b..238857ee 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -78,12 +78,12 @@ node packages/aead-napi/tests/kind_inventory.cjs target/debug/examples/libkind_i The JS-boundary conversion functions — `js_to_value`, `value_to_js`, `node_to_js`, `node_from_js` — and their helpers — `own_enumerable_keys`, -`get_property_unknown`, `ensure_plain_object`, `define_own_property` — take a -live `napi_env`, `Unknown`, or `Object`, which only exists inside a running V8 -isolate, so Rust unit tests cannot reach them. They remain exempted by name in -`.cargo-crap.toml` and `.cargo/mutants.toml`; new pure scalar conversion -helpers participate in coverage and mutation gates. The Node conformance test -runs in package-scoped mutation tests as well as CI. +`get_property_unknown`, `ensure_plain_object`, `define_own_property`, +`wrapper_text` — take a live `napi_env`, `Unknown`, or `Object`, which only +exists inside a running V8 isolate, so Rust unit tests cannot reach them. They +remain exempted by name in `.cargo-crap.toml` and `.cargo/mutants.toml`; new +pure scalar conversion helpers participate in coverage and mutation gates. The +Node conformance test runs in package-scoped mutation tests as well as CI. ## Safety notes diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index 4c79726f..adcc97d6 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -437,9 +437,9 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { } /// The string payload of a one-key scalar wrapper. Any other payload -/// (`{ date: new Date() }`, `{ decimal: 1n }`, ...) is refused with the -/// wrapper's own typed error rather than a generic N-API one; a non-finite -/// number in `{ decimal }` keeps its distinct code. +/// (`{ date: new Date() }`, `{ decimal: 1n }`, ...) is refused with a typed +/// error chosen by [`scalar::non_string_payload`] rather than a generic +/// N-API one. fn wrapper_text( obj: &Object, key: &str, @@ -448,13 +448,11 @@ fn wrapper_text( let input = get_property_unknown(obj, key)?; Ok(match input.get_type()? { ValueType::String => Ok(String::from_unknown(input)?), - ValueType::Number - if invalid == scalar::ConversionError::InvalidDecimal - && !f64::from_unknown(input)?.is_finite() => - { - Err(scalar::ConversionError::NonFiniteDecimal) - } - _ => Err(invalid), + ValueType::Number => Err(scalar::non_string_payload( + invalid, + Some(f64::from_unknown(input)?), + )), + _ => Err(scalar::non_string_payload(invalid, None)), }) } diff --git a/packages/aead-napi/src/scalar.rs b/packages/aead-napi/src/scalar.rs index 67e68b1a..0008e9af 100644 --- a/packages/aead-napi/src/scalar.rs +++ b/packages/aead-napi/src/scalar.rs @@ -90,6 +90,19 @@ fn unsigned(value: u128) -> Value { } } +/// The error for a scalar wrapper whose payload is not a string: the +/// wrapper's own `invalid` error, except that a non-finite number in +/// `{ decimal }` keeps its distinct code. `number` is the payload when it is a +/// JS number. +pub(crate) fn non_string_payload(invalid: ConversionError, number: Option) -> ConversionError { + match number { + Some(n) if invalid == ConversionError::InvalidDecimal && !n.is_finite() => { + ConversionError::NonFiniteDecimal + } + _ => invalid, + } +} + pub(crate) fn date(text: &str) -> Result { let value = NaiveDate::parse_from_str(text, "%Y-%m-%d").map_err(|_| ConversionError::InvalidDate)?; @@ -289,6 +302,26 @@ mod tests { } } + #[test] + fn non_string_wrapper_payloads_keep_the_wrappers_code() { + use ConversionError::*; + for invalid in [InvalidDate, InvalidTimestamp, InvalidDecimal] { + assert_eq!(non_string_payload(invalid, None), invalid); + assert_eq!(non_string_payload(invalid, Some(1.5)), invalid); + } + for number in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!( + non_string_payload(InvalidDecimal, Some(number)), + NonFiniteDecimal + ); + assert_eq!(non_string_payload(InvalidDate, Some(number)), InvalidDate); + assert_eq!( + non_string_payload(InvalidTimestamp, Some(number)), + InvalidTimestamp + ); + } + } + #[test] fn decimal_is_exact_and_non_finite_errors_are_distinct() { for text in ["NaN", "+nan", "-NaN", "inf", "+Infinity", "-Infinity"] { From 3a25b64331870b253200ab1be15e042714268913 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 15:06:27 +1100 Subject: [PATCH 11/28] test: check shared corpus values, not only byte round trips Rust and Go only checked that decoding then encoding a corpus vector gave the same bytes, so a bug that decodes and encodes symmetrically wrong (a date read and written a day off, say) passed. Only Node compared the decoded value with the vector's `value` field. Rust and Go now build the expected host value from `kind` and `value` with their standard parsers, not the transport decoder, and check both directions: decoding gives that value, and encoding it from scratch gives the bytes. Node now also encodes its expected value, not only the decoded one. An unknown kind fails until each helper learns to build it. Refs #381 --- bindings/go/vcffi/conformance_test.go | 131 +++++++++++++++++- .../aead-napi/tests/value-conformance.cjs | 5 +- packages/aead-value/tests/conformance.rs | 94 ++++++++++++- testdata/README.md | 6 + 4 files changed, 229 insertions(+), 7 deletions(-) diff --git a/bindings/go/vcffi/conformance_test.go b/bindings/go/vcffi/conformance_test.go index 3e2e39ae..0567d0f3 100644 --- a/bindings/go/vcffi/conformance_test.go +++ b/bindings/go/vcffi/conformance_test.go @@ -3,10 +3,120 @@ package vcffi import ( "encoding/hex" "encoding/json" + "fmt" "os" + "reflect" + "regexp" + "strconv" "testing" + "time" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) +// rfc3339ish matches the corpus timestamp spelling, including leap seconds +// and ISO 8601 expanded years, which time.Parse cannot read. +var rfc3339ish = regexp.MustCompile(`^([+-]?\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$`) + +// expected builds the Go value a vector's kind and value fields describe, +// without the transport decoder, so a bug that decodes and encodes +// symmetrically wrong cannot pass. +func expected(kind string, raw json.RawMessage) (any, error) { + var text string + _ = json.Unmarshal(raw, &text) + integer := func(bits int) (int64, error) { return strconv.ParseInt(text, 10, bits) } + natural := func(bits int) (uint64, error) { return strconv.ParseUint(text, 10, bits) } + switch kind { + case "null", "undefined": + return nil, nil + case "bool": + var v bool + return v, json.Unmarshal(raw, &v) + case "int8": + v, err := integer(8) + return int8(v), err + case "uint8": + v, err := natural(8) + return uint8(v), err + case "int16": + v, err := integer(16) + return int16(v), err + case "uint16": + v, err := natural(16) + return uint16(v), err + case "int32": + v, err := integer(32) + return int32(v), err + case "uint32": + v, err := natural(32) + return uint32(v), err + case "int64": + return integer(64) + case "uint64": + return natural(64) + case "int128": + return vcvalue.ParseInt128(text) + case "uint128": + return vcvalue.ParseUint128(text) + case "float32": + var v float32 + return v, json.Unmarshal(raw, &v) + case "float64": + var v float64 + return v, json.Unmarshal(raw, &v) + case "string": + return text, nil + case "bytes": + return hex.DecodeString(text) + case "date": + t, err := time.Parse("2006-01-02", text) + return vcvalue.Date{Year: t.Year(), Month: t.Month(), Day: t.Day()}, err + case "timestamp": + return timestamp(text) + case "decimal": + return vcvalue.ParseDecimal(text) + case "array": + if string(raw) == "[]" { + return []any{}, nil + } + case "object": + if string(raw) == "{}" { + return vcvalue.Object{}, nil + } + } + return nil, fmt.Errorf("teach expected() the %s vector %s", kind, raw) +} + +func timestamp(text string) (any, error) { + m := rfc3339ish.FindStringSubmatch(text) + if m == nil { + return nil, fmt.Errorf("unparseable timestamp %q", text) + } + n := make([]int, 6) + for i := range n { + v, err := strconv.Atoi(m[i+1]) + if err != nil { + return nil, err + } + n[i] = v + } + nanos, _ := strconv.Atoi((m[7] + "000000000")[:9]) + if n[5] == 60 { + // chrono's leap second: second 59 with nanoseconds >= 1e9. + t := time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], 59, 0, time.UTC) + return vcvalue.Timestamp{Seconds: t.Unix(), Nanoseconds: uint32(1000000000 + nanos)}, nil + } + return time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], n[5], nanos, time.UTC), nil +} + +func sameValue(got, want any) bool { + if want, ok := want.(time.Time); ok { + got, ok := got.(time.Time) + return ok && got.Equal(want) + } + return reflect.DeepEqual(got, want) +} + func TestSharedValueConformance(t *testing.T) { data, err := os.ReadFile("../../../testdata/value-conformance.json") if err != nil { @@ -14,8 +124,9 @@ func TestSharedValueConformance(t *testing.T) { } var corpus struct { Vectors []struct { - Name, Transport string - GoReencode string `json:"go_reencode"` + Name, Kind, Transport string + Value json.RawMessage + GoReencode string `json:"go_reencode"` } Malformed []struct{ Name, Transport string } } @@ -32,16 +143,26 @@ func TestSharedValueConformance(t *testing.T) { if err != nil { t.Fatal(err) } - encoded, err := Marshal(value) + expect, err := expected(row.Kind, row.Value) if err != nil { t.Fatal(err) } + if !sameValue(value, expect) { + t.Fatalf("decoded %#v, want %#v", value, expect) + } want := row.Transport if row.GoReencode != "" { want = row.GoReencode } - if hex.EncodeToString(encoded) != want { - t.Fatalf("got %x, want %s (%#v)", encoded, want, value) + // Encode the decoded value and, independently, the expected one. + for _, v := range []any{value, expect} { + encoded, err := Marshal(v) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(encoded) != want { + t.Fatalf("got %x, want %s (%#v)", encoded, want, v) + } } }) } diff --git a/packages/aead-napi/tests/value-conformance.cjs b/packages/aead-napi/tests/value-conformance.cjs index d604d3e6..a56ea003 100644 --- a/packages/aead-napi/tests/value-conformance.cjs +++ b/packages/aead-napi/tests/value-conformance.cjs @@ -19,7 +19,10 @@ for (const row of corpus.vectors) { assert.equal(addon.codecRoundTrip(wire).toString('hex'), row.transport, row.name); const value = addon.decode(wire); assert.deepEqual(value, expected(row), row.name); - assert.equal(addon.encode(value).toString('hex'), row.js_reencode ?? row.transport, row.name); + // Encode the decoded value and, independently, the expected one. + for (const input of [value, expected(row)]) { + assert.equal(addon.encode(input).toString('hex'), row.js_reencode ?? row.transport, row.name); + } } for (const row of corpus.malformed) assert.throws(() => addon.decode(Buffer.from(row.transport, 'hex')), undefined, row.name); const boundaries = [ diff --git a/packages/aead-value/tests/conformance.rs b/packages/aead-value/tests/conformance.rs index b25c096f..340d443c 100644 --- a/packages/aead-value/tests/conformance.rs +++ b/packages/aead-value/tests/conformance.rs @@ -2,8 +2,9 @@ use vitaminc_aead_value::{ transport::{decode_value, encode_value, Reader}, - ValueKind, + Value, ValueKind, }; +use vitaminc_protected::{Controlled, Protected}; fn unhex(text: &str) -> Vec { text.as_bytes() @@ -12,6 +13,87 @@ fn unhex(text: &str) -> Vec { .collect() } +/// The value a vector's `kind` and `value` fields describe, built with +/// std, chrono and rust_decimal parsers rather than the transport decoder, +/// so a bug that decodes and encodes symmetrically wrong cannot pass. +fn expected(kind: &str, value: &serde_json::Value) -> Value { + fn int(value: &serde_json::Value) -> T { + let text = value.as_str().expect("integers are JSON strings"); + text.parse() + .unwrap_or_else(|_| panic!("{text} is out of range")) + } + let text = || value.as_str().expect("JSON string"); + match kind { + "null" => Value::Null, + "undefined" => Value::Undefined, + "bool" => Value::Bool(value.as_bool().expect("JSON bool")), + "int8" => Value::Int8(int(value)), + "uint8" => Value::UInt8(int(value)), + "int16" => Value::Int16(int(value)), + "uint16" => Value::UInt16(int(value)), + "int32" => Value::Int32(int(value)), + "uint32" => Value::UInt32(int(value)), + "int64" => Value::Int64(int(value)), + "uint64" => Value::UInt64(int(value)), + "int128" => Value::Int128(int(value)), + "uint128" => Value::UInt128(int(value)), + "float32" => Value::Float32(value.as_f64().expect("JSON number") as f32), + "float64" => Value::Float64(value.as_f64().expect("JSON number")), + "string" => Value::String(text().into()), + "bytes" => Value::Bytes(Protected::new(unhex(text()))), + "date" => { + Value::Date(chrono::NaiveDate::parse_from_str(text(), "%Y-%m-%d").expect("valid date")) + } + "timestamp" => Value::Timestamp(text().parse().expect("valid timestamp")), + "decimal" => { + let mut decimal = rust_decimal::Decimal::from_str_exact(text()).expect("valid decimal"); + // Parsing drops the sign of zero; the wire format keeps it. + decimal.set_sign_negative(text().starts_with('-')); + Value::Decimal(decimal) + } + "array" if value.as_array().is_some_and(Vec::is_empty) => Value::Array(vec![]), + "object" if value.as_object().is_some_and(|map| map.is_empty()) => Value::Object(vec![]), + _ => panic!("teach expected() the {kind} vector {value}"), + } +} + +/// Structural equality for the scalar shapes the corpus holds. `Value`'s +/// own `PartialEq` is crate-test-only, so compare here. Floats compare by bit +/// pattern and decimals by their serialized form, which includes scale. +fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Null, Value::Null) | (Value::Undefined, Value::Undefined) => true, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Int8(a), Value::Int8(b)) => a == b, + (Value::UInt8(a), Value::UInt8(b)) => a == b, + (Value::Int16(a), Value::Int16(b)) => a == b, + (Value::UInt16(a), Value::UInt16(b)) => a == b, + (Value::Int32(a), Value::Int32(b)) => a == b, + (Value::UInt32(a), Value::UInt32(b)) => a == b, + (Value::Int64(a), Value::Int64(b)) => a == b, + (Value::UInt64(a), Value::UInt64(b)) => a == b, + (Value::Int128(a), Value::Int128(b)) => a == b, + (Value::UInt128(a), Value::UInt128(b)) => a == b, + (Value::Float32(a), Value::Float32(b)) => a.to_bits() == b.to_bits(), + (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(), + (Value::String(a), Value::String(b)) => a.risky_ref() == b.risky_ref(), + (Value::Bytes(a), Value::Bytes(b)) => a.risky_ref() == b.risky_ref(), + (Value::Date(a), Value::Date(b)) => a == b, + (Value::Timestamp(a), Value::Timestamp(b)) => a == b, + (Value::Decimal(a), Value::Decimal(b)) => a.serialize() == b.serialize(), + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b)) + } + (Value::Object(a), Value::Object(b)) => { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|((ka, va), (kb, vb))| ka == kb && same(va, vb)) + } + _ => false, + } +} + #[test] fn shared_binding_corpus_covers_every_tag_and_kind() { let corpus: serde_json::Value = @@ -26,9 +108,19 @@ fn shared_binding_corpus_covers_every_tag_and_kind() { if let Some(kind) = value.kind() { kinds.push(kind); } + let want = expected(row["kind"].as_str().expect("kind"), &row["value"]); + assert!( + same(&value, &want), + "{}: decoded value differs from `value`", + row["name"] + ); let mut encoded = Vec::new(); encode_value(value, &mut encoded).expect("encode"); assert_eq!(encoded, bytes, "{}", row["name"]); + // Encode from the independently built value too, not only the decoded one. + let mut from_expected = Vec::new(); + encode_value(want, &mut from_expected).expect("encode"); + assert_eq!(from_expected, bytes, "{}", row["name"]); } for tag in 0x00..=0x14 { assert!(tags.contains(&tag), "missing scalar tag {tag:02x}"); diff --git a/testdata/README.md b/testdata/README.md index c75b80a8..c9fcabd7 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -13,6 +13,12 @@ other timestamps retain their nanoseconds in a wrapper, spelled RFC 3339 for years 0000-9999 and with an ISO 8601 expanded year (`+12000-…`) otherwise. `malformed` vectors must be refused by the decoders. +Rust, Go and Node each build the expected host value from `kind` and `value` +without their transport decoder, then check both directions: decoding the +bytes gives that value, and encoding that value gives the bytes. A bug that +decodes and encodes symmetrically wrong therefore still fails. Adding a kind +means teaching each language's `expected` helper to build it. + Rust re-encodes every valid vector byte-for-byte. The optional `js_reencode` and `go_reencode` fields record established host projections explicitly: JavaScript Float32 becomes Number/Float64; Go undefined becomes nil/null. From 7487f9fd94dac09f76e88f3157c0aded4bfe1d96 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 15:20:04 +1100 Subject: [PATCH 12/28] fix(go): keep float32 signaling NaN bits when encoding The reflection path read float32 values through rv.Float(), which widens to float64; the hardware widening sets the quiet bit of a signaling NaN, so Go re-encoded 0xff833030 as 0xffc33030 where Rust keeps the bits. Decrypting and re-encrypting such a value in Go changed it. Plain float32 values are now read directly. A named float32 type has no bit-exact reflect path without unsafe and still widens. Found by the stricter fuzz property; the minimized inputs are kept as regression seeds. Refs #381 --- bindings/go/vcffi/codec_test.go | 22 +++++++++++++++++++ bindings/go/vcffi/encoder.go | 19 +++++++++++++++- .../fuzz/FuzzUnmarshal/505ffa2ed15a4b90 | 2 ++ .../FuzzUnmarshalCipherText/b69fb6cf28e5d74f | 2 ++ 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 bindings/go/vcffi/testdata/fuzz/FuzzUnmarshal/505ffa2ed15a4b90 create mode 100644 bindings/go/vcffi/testdata/fuzz/FuzzUnmarshalCipherText/b69fb6cf28e5d74f diff --git a/bindings/go/vcffi/codec_test.go b/bindings/go/vcffi/codec_test.go index 660dae9e..c8fc6f1b 100644 --- a/bindings/go/vcffi/codec_test.go +++ b/bindings/go/vcffi/codec_test.go @@ -1,6 +1,7 @@ package vcffi import ( + "bytes" "encoding/binary" "errors" "math" @@ -1010,3 +1011,24 @@ func TestFramingTags(t *testing.T) { } } } + +// A signaling NaN must keep its bits: widening it to float64 on the way to +// the encoder would set the quiet bit (found by FuzzUnmarshal). +func TestFloat32SignalingNaNKeepsItsBits(t *testing.T) { + for _, bits := range []uint32{0xff833030, 0x7fba3030, 0x7f800001} { + wire := binary.LittleEndian.AppendUint32([]byte{tagFloat32}, bits) + value, err := Unmarshal(wire) + if err != nil { + t.Fatal(err) + } + for _, v := range []any{value, []any{value}, struct{ F float32 }{value.(float32)}} { + encoded, err := Marshal(v) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(encoded, wire) { + t.Fatalf("%08x re-encoded as %x", bits, encoded) + } + } + } +} diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index 5b2dd000..32d55679 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -584,7 +584,15 @@ func encodeReflect(enc Encoder, rv reflect.Value) { // UInt64 (no fit-check: a bare uint always maps to UINT64 now). enc.UInt64(rv.Uint()) case reflect.Float32: - enc.Float32(float32(rv.Float())) + // rv.Float() widens to float64, and the hardware widening sets the + // quiet bit of a signaling NaN. Read a plain float32 directly so its + // bits survive; a named float32 type has no bit-exact reflect path + // without unsafe, so only it falls back to the widening. + if f, ok := interfaceOf(rv).(float32); ok { + enc.Float32(f) + } else { + enc.Float32(float32(rv.Float())) + } case reflect.Float64: enc.Float64(rv.Float()) case reflect.String: @@ -688,3 +696,12 @@ func encodeStruct(enc Encoder, rv reflect.Value) { } _ = m.End() } + +// interfaceOf is rv.Interface(), or nil where reflection forbids it +// (values reached through unexported fields). +func interfaceOf(rv reflect.Value) any { + if !rv.CanInterface() { + return nil + } + return rv.Interface() +} diff --git a/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshal/505ffa2ed15a4b90 b/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshal/505ffa2ed15a4b90 new file mode 100644 index 00000000..06201ec7 --- /dev/null +++ b/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshal/505ffa2ed15a4b90 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("\b00\x83\xff") diff --git a/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshalCipherText/b69fb6cf28e5d74f b/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshalCipherText/b69fb6cf28e5d74f new file mode 100644 index 00000000..806ae1fe --- /dev/null +++ b/bindings/go/vcffi/testdata/fuzz/FuzzUnmarshalCipherText/b69fb6cf28e5d74f @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("\x05\b00\xba\x7f") From a07e2a5fa698f88d8183fdb195ba142c5f4dba98 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 15:20:05 +1100 Subject: [PATCH 13/28] test(go): fuzz the codec for exact re-encoding, nightly The fuzz targets only required accepted input to re-encode without error, and their seeds predated the extended scalar tags. They now require the same bytes back (Undefined becoming Null is the one allowed difference), and a fixed point on a second pass, and seed from every shared corpus vector, bare and nested in arrays, passthrough and ciphertext. PR CI still runs the seeds only. A new nightly workflow fuzzes each target for five minutes and uploads any failing input. Refs #381 --- .github/workflows/fuzz.yml | 51 +++++++++++++++++++++++++ bindings/go/vcffi/README.md | 9 ++++- bindings/go/vcffi/fuzz_test.go | 69 +++++++++++++++++++++++++++++++--- 3 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..11b614b2 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,51 @@ +name: "Fuzz" +on: + # PR CI runs the fuzz targets over their seed corpora only (go.yml). This + # job spends real time mutating inputs, off the PR path so a slow or + # unlucky run never blocks a merge. + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + fuzztime: + description: "Time per fuzz target (Go duration)" + default: "5m" + +defaults: + run: + shell: bash -l {0} + +jobs: + go: + runs-on: ubuntu-latest + name: "🐹 Go codec fuzzing" + strategy: + fail-fast: false + matrix: + # `go test -fuzz` runs one target per invocation. + target: [FuzzUnmarshal, FuzzUnmarshalCipherText] + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version-file: bindings/go/vcencrypt/go.mod + cache-dependency-path: | + bindings/go/vcencrypt/go.sum + bindings/go/vcencrypt/examples/go.sum + + - name: fuzz ${{ matrix.target }} + working-directory: bindings/go/vcffi + env: + FUZZTIME: ${{ inputs.fuzztime || '5m' }} + run: CGO_ENABLED=0 go test -run='^$' -fuzz="^${{ matrix.target }}\$" -fuzztime="$FUZZTIME" . + + - name: upload failing input + # A failure writes the minimized input under testdata/fuzz. Commit it + # there to keep it as a regression seed for every PR run. + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fuzz-${{ matrix.target }} + path: bindings/go/vcffi/testdata/fuzz diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index 7224975b..1d714a9a 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -47,8 +47,13 @@ keep single. recursion depth, length prefixes and item counts are bounded, strings are UTF-8 validated, duplicate map keys are rejected, and every malformed input fails with `ErrMalformed` rather than panicking or over-allocating. The fuzz -targets in this package pin that contract, and CI runs the whole suite on -32-bit GOARCH, where the u32-bound guards are load-bearing. +targets in this package pin that contract, and also require anything accepted +to re-encode to the same bytes (Undefined becoming Null is the one allowed +difference). PR CI runs them over their seeds, which include every shared +corpus vector, and the whole suite on 32-bit GOARCH, where the u32-bound +guards are load-bearing. `.github/workflows/fuzz.yml` fuzzes them nightly; +commit any failing input it reports under `testdata/fuzz` as a regression +seed. `ErrMalformed` covers hostile bytes, not caller mistakes: a `LeafSet` that is partially wired, or that does not materialize a kind the buffer carries, diff --git a/bindings/go/vcffi/fuzz_test.go b/bindings/go/vcffi/fuzz_test.go index c8dd4eaf..69a3fd33 100644 --- a/bindings/go/vcffi/fuzz_test.go +++ b/bindings/go/vcffi/fuzz_test.go @@ -1,15 +1,56 @@ package vcffi import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" "testing" ) // Unmarshal and UnmarshalCipherText are the package's entire hostile-input // surface: arbitrary bytes from the wasm boundary or a database column. The // fuzz targets assert the decoders never panic, and that anything they accept -// re-encodes without panicking — errors are fine, crashes are not. Run in CI -// as ordinary tests over the seed corpus; run `go test -fuzz` locally for -// exploration. +// re-encodes to the same bytes (see requireCanonical) — errors are fine, +// crashes and drift are not. Run in CI as ordinary tests over the seed corpus, +// nightly as real fuzzing (.github/workflows/fuzz.yml), and with +// `go test -fuzz` locally for exploration. + +// addCorpusSeeds seeds a fuzz target with every valid and malformed vector in +// the shared value corpus, so the fuzzer starts from each scalar tag. +func addCorpusSeeds(f *testing.F, wrap func([]byte) []byte) { + data, err := os.ReadFile("../../../testdata/value-conformance.json") + if err != nil { + f.Fatal(err) + } + var corpus struct{ Vectors, Malformed []struct{ Transport string } } + if err := json.Unmarshal(data, &corpus); err != nil { + f.Fatal(err) + } + for _, row := range append(corpus.Vectors, corpus.Malformed...) { + raw, err := hex.DecodeString(row.Transport) + if err != nil { + f.Fatal(err) + } + f.Add(wrap(raw)) + } +} + +// requireCanonical fails unless out re-encodes data exactly. The one allowed +// difference is Go's documented projection of Undefined to nil: both are +// one-byte tags, so a lossless re-encode keeps the length and may differ +// only where an Undefined tag (0x01) became Null (0x00). +func requireCanonical(t *testing.T, data, out []byte) { + t.Helper() + if len(out) != len(data) { + t.Fatalf("accepted %x re-encoded with a different length: %x", data, out) + } + for i := range data { + if data[i] != out[i] && (data[i] != 0x01 || out[i] != 0x00) { + t.Fatalf("accepted %x re-encoded differently at byte %d: %x", data, i, out) + } + } +} func FuzzUnmarshal(f *testing.F) { // Seeds: one of each node kind, plus known-hostile shapes. @@ -21,15 +62,29 @@ func FuzzUnmarshal(f *testing.F) { f.Add([]byte{0xF2, 0x05}) // Passthrough(Int64) truncated f.Add([]byte{0xF0, 0xFF, 0xFF, 0xFF, 0xFF}) // hostile count f.Add([]byte{0xF1, 2, 0, 0, 0, 1, 0, 0, 0, 'a', 0x00}) // short object + addCorpusSeeds(f, func(raw []byte) []byte { return raw }) + // Extended scalars nested in containers and passthrough. + addCorpusSeeds(f, func(raw []byte) []byte { return append([]byte{0xF0, 1, 0, 0, 0}, raw...) }) + addCorpusSeeds(f, func(raw []byte) []byte { return append([]byte{0xF2}, raw...) }) f.Fuzz(func(t *testing.T, data []byte) { v, err := Unmarshal(data) if err != nil { return } - if _, err := Marshal(v); err != nil { + out, err := Marshal(v) + if err != nil { t.Fatalf("accepted decode failed to re-encode: %v (value %#v)", err, v) } + requireCanonical(t, data, out) + // The re-encoding holds no Undefined, so it must be a fixed point. + again, err := Unmarshal(out) + if err != nil { + t.Fatalf("re-encoding %x does not decode: %v", out, err) + } + if twice, err := Marshal(again); err != nil || !bytes.Equal(twice, out) { + t.Fatalf("re-encoding %x is not stable: %x (%v)", out, twice, err) + } }) } @@ -42,14 +97,18 @@ func FuzzUnmarshalCipherText(f *testing.F) { f.Add([]byte{0x07, 1, 0, 0, 0, 0xAB}) // ctEmptyMap f.Add([]byte{0x05, 0x00}) // ctPassthrough(Null) f.Add([]byte{0x03, 0xFF, 0xFF, 0xFF, 0xFF}) // hostile count + // Extended scalars as clear passthrough nodes inside a ciphertext. + addCorpusSeeds(f, func(raw []byte) []byte { return append([]byte{0x05}, raw...) }) f.Fuzz(func(t *testing.T, data []byte) { v, err := UnmarshalCipherText(VCValueLeaves(), data) if err != nil { return } - if _, err := MarshalCipherText(VCValueLeaves(), v); err != nil { + out, err := MarshalCipherText(VCValueLeaves(), v) + if err != nil { t.Fatalf("accepted ciphertext decode failed to re-encode: %v (value %#v)", err, v) } + requireCanonical(t, data, out) }) } From 401107a027510f8a364560b54494ce5037e44c5d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 15:22:35 +1100 Subject: [PATCH 14/28] test(aead-value): property-test the value codec The codec had only hand-written cases. Add quickcheck properties over random value trees covering every variant (raw float bits, so NaN payloads and signaling NaNs; leap seconds; every decimal scale): - every value round-trips through encode and decode; - any bytes the decoder accepts re-encode to exactly those bytes, both for random bytes and for valid encodings corrupted by one or two edits, of which about a fifth are still accepted and so checked. Refs #381 --- packages/aead-value/src/transport.rs | 4 + .../aead-value/src/transport_properties.rs | 176 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 packages/aead-value/src/transport_properties.rs diff --git a/packages/aead-value/src/transport.rs b/packages/aead-value/src/transport.rs index 53831fff..a9f5d555 100644 --- a/packages/aead-value/src/transport.rs +++ b/packages/aead-value/src/transport.rs @@ -1206,3 +1206,7 @@ mod tests { assert_eq!(encode_ciphertext_boxed(ct, &mut out), Err(CodecError)); } } + +#[cfg(test)] +#[path = "transport_properties.rs"] +mod properties; diff --git a/packages/aead-value/src/transport_properties.rs b/packages/aead-value/src/transport_properties.rs new file mode 100644 index 00000000..44c2df5d --- /dev/null +++ b/packages/aead-value/src/transport_properties.rs @@ -0,0 +1,176 @@ +//! Property tests for the value codec: random trees round-trip, and any +//! bytes the decoder accepts re-encode to exactly those bytes. + +use quickcheck::{Arbitrary, Gen}; +use quickcheck_macros::quickcheck; +use vitaminc_protected::Protected; + +use super::*; + +/// A random `Value` tree covering every variant, depth-bounded so the +/// decoder's nesting limit is never the reason a property fails. +#[derive(Clone, Debug)] +struct AnyValue(Value); + +impl Arbitrary for AnyValue { + fn arbitrary(g: &mut Gen) -> Self { + AnyValue(value(g, 3)) + } +} + +fn value(g: &mut Gen, depth: usize) -> Value { + let containers = if depth == 0 { 0 } else { 3 }; + match u8::arbitrary(g) % (20 + containers) { + 0 => Value::Null, + 1 => Value::Undefined, + 2 => Value::Bool(bool::arbitrary(g)), + 3 => Value::Int8(i8::arbitrary(g)), + 4 => Value::UInt8(u8::arbitrary(g)), + 5 => Value::Int16(i16::arbitrary(g)), + 6 => Value::UInt16(u16::arbitrary(g)), + 7 => Value::Int32(i32::arbitrary(g)), + 8 => Value::UInt32(u32::arbitrary(g)), + 9 => Value::Int64(i64::arbitrary(g)), + 10 => Value::UInt64(u64::arbitrary(g)), + 11 => Value::Int128(i128::arbitrary(g)), + 12 => Value::UInt128(u128::arbitrary(g)), + // Raw bit patterns, so NaN payloads and signaling NaNs are covered. + 13 => Value::Float32(f32::from_bits(u32::arbitrary(g))), + 14 => Value::Float64(f64::from_bits(u64::arbitrary(g))), + 15 => Value::String(String::arbitrary(g).into()), + 16 => Value::Bytes(Protected::new(Vec::arbitrary(g))), + 17 => date(g), + 18 => timestamp(g), + 19 => decimal(g), + 20 => Value::Array( + (0..u8::arbitrary(g) % 4) + .map(|_| value(g, depth - 1)) + .collect(), + ), + 21 => { + // The decoder rejects duplicate keys, so generate distinct ones. + let mut keys: Vec = Vec::arbitrary(g); + keys.sort(); + keys.dedup(); + keys.truncate(4); + Value::Object(keys.into_iter().map(|k| (k, value(g, depth - 1))).collect()) + } + _ => Value::Passthrough(Box::new(value(g, depth - 1))), + } +} + +#[cfg(feature = "chrono")] +fn date(g: &mut Gen) -> Value { + use chrono::Datelike; + let (min, max) = ( + chrono::NaiveDate::MIN.num_days_from_ce(), + chrono::NaiveDate::MAX.num_days_from_ce(), + ); + let days = min + (u32::arbitrary(g) % (max - min) as u32) as i32; + Value::Date(chrono::NaiveDate::from_num_days_from_ce_opt(days).expect("in range")) +} + +#[cfg(feature = "chrono")] +fn timestamp(g: &mut Gen) -> Value { + use chrono::DateTime; + let (min, max) = ( + DateTime::::MIN_UTC.timestamp(), + DateTime::::MAX_UTC.timestamp(), + ); + let seconds = min + (u64::arbitrary(g) % (max - min) as u64) as i64; + // Occasionally a leap second: nanoseconds >= 1e9 on second 59. + let leap = u8::arbitrary(g) % 4 == 0; + let (seconds, nanos) = if leap { + ( + seconds - seconds.rem_euclid(60) + 59, + 1_000_000_000 + u32::arbitrary(g) % 1_000_000_000, + ) + } else { + (seconds, u32::arbitrary(g) % 1_000_000_000) + }; + DateTime::from_timestamp(seconds, nanos) + .map(Value::Timestamp) + .unwrap_or(Value::Null) +} + +#[cfg(feature = "rust_decimal")] +fn decimal(g: &mut Gen) -> Value { + Value::Decimal(rust_decimal::Decimal::from_parts( + u32::arbitrary(g), + u32::arbitrary(g), + u32::arbitrary(g), + bool::arbitrary(g), + u32::arbitrary(g) % 29, + )) +} + +#[cfg(not(feature = "chrono"))] +fn date(_: &mut Gen) -> Value { + Value::Null +} + +#[cfg(not(feature = "chrono"))] +fn timestamp(_: &mut Gen) -> Value { + Value::Null +} + +#[cfg(not(feature = "rust_decimal"))] +fn decimal(_: &mut Gen) -> Value { + Value::Null +} + +fn encode(value: Value) -> Vec { + let mut out = Vec::new(); + encode_value(value, &mut out).expect("generated values encode"); + out +} + +/// Decode, and if the decoder accepts, require the exact bytes back. +fn accepted_input_is_canonical(bytes: &[u8]) -> bool { + match decode_value(&mut Reader::new(bytes)) { + Ok(value) => encode(value) == bytes, + Err(_) => true, + } +} + +#[quickcheck] +fn every_value_round_trips(value: AnyValue) -> bool { + let bytes = encode(value.0.clone()); + decode_value(&mut Reader::new(&bytes)).is_ok_and(|decoded| decoded == value.0) +} + +#[quickcheck] +fn accepted_bytes_re_encode_exactly(bytes: Vec) -> bool { + accepted_input_is_canonical(&bytes) +} + +/// Random bytes rarely get past the first tag, so also start from a valid +/// encoding and corrupt it with one or two edits (overwrite, insert, delete +/// or truncate): most are refused, and the rest must still be canonical. +fn corrupted_encoding_is_refused_or_canonical( + value: AnyValue, + first: (u8, usize, u8), + second: Option<(u8, usize, u8)>, +) -> bool { + let mut bytes = encode(value.0); + for (op, at, byte) in std::iter::once(first).chain(second) { + let at = at % (bytes.len() + 1); + match op % 4 { + 0 if at < bytes.len() => bytes[at] = byte, + 1 => bytes.insert(at, byte), + 2 if at < bytes.len() => { + bytes.remove(at); + } + 3 => bytes.truncate(at), + _ => {} + } + } + accepted_input_is_canonical(&bytes) +} + +#[test] +fn corrupted_encodings_are_refused_or_canonical() { + quickcheck::QuickCheck::new() + .tests(1000) + .quickcheck(corrupted_encoding_is_refused_or_canonical as fn(_, _, _) -> bool); +} From 84c16272214436c342451fc216e2caea73f24941 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 17:44:09 +1100 Subject: [PATCH 15/28] ci(mutants): skip equivalent | to ^ mutants in widen_f32 widen_f32 assembles an f64 by OR-ing bit fields that never share a set bit, so swapping | for ^ cannot change any result and no test can catch it. Exclude exactly that swap in that function, and move the one | whose operands do overlap (mantissa | 1) into highest_set_bit so it stays gated. --- .cargo/mutants.toml | 8 ++++++++ packages/aead-value/src/canonical.rs | 15 ++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 4c616ebe..93d47c79 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -57,6 +57,14 @@ exclude_re = [ "define_own_property", "get_property_unknown", "ensure_plain_object", + # Equivalent mutants, not missing tests: `widen_f32` (vitaminc-aead-value + # canonical.rs) assembles an f64 by OR-ing bit fields that are disjoint by + # construction — complementary masks, or fields shifted into separate bit + # ranges — and for operands with no common set bit `a | b == a ^ b`, so no + # input can tell the two apart. Only that one operator swap in that one + # function is skipped; its other mutants (including `mantissa | 1`, where + # the operands do overlap) stay gated. + "replace \\| with \\^ in widen_f32", ] # Property tests (quickcheck) and AEAD round-trips run slower under mutation than diff --git a/packages/aead-value/src/canonical.rs b/packages/aead-value/src/canonical.rs index 5b8a902d..9a0276df 100644 --- a/packages/aead-value/src/canonical.rs +++ b/packages/aead-value/src/canonical.rs @@ -189,13 +189,22 @@ fn widen_f32(value: f32) -> f64 { // ones stays all ones) and left-align the mantissa. let rebiased = ((u64::from(exponent) + 896) & !max_exponent) | (0x7ff & max_exponent); let normal = (rebiased << 52) | (mantissa << 29); - // Subnormal: the highest set bit becomes the implicit leading one. `| 1` - // keeps the shift in range for a zero mantissa, which is masked out. - let top = u64::from(63 - (mantissa | 1).leading_zeros()); + // Subnormal: the highest set bit becomes the implicit leading one. + let top = highest_set_bit(mantissa); let subnormal = ((top + 874) << 52) | ((mantissa << (52 - top)) & ((1 << 52) - 1)); f64::from_bits(sign | (normal & !zero_exponent) | (subnormal & zero_exponent & nonzero)) } +/// Index of the highest set bit of a subnormal f32 mantissa. `| 1` keeps the +/// result in range for a zero mantissa, which `widen_f32` masks out. +/// +/// Kept out of `widen_f32` so the `.cargo/mutants.toml` exclusion of that +/// function's equivalent `|`-to-`^` mutants cannot reach this `|`, whose +/// operands overlap and whose mutant a test does catch. +fn highest_set_bit(mantissa: u64) -> u64 { + u64::from(63 - (mantissa | 1).leading_zeros()) +} + fn canonical_f64(value: f64) -> f64 { let bits = value.to_bits(); let magnitude = bits & 0x7fff_ffff_ffff_ffff; From edf3966725b3e273cabe382bc2cd3da4c731dda3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 17:52:21 +1100 Subject: [PATCH 16/28] ci(fuzz): open an issue when nightly fuzzing fails A scheduled run has no PR to fail, so a crash it found would only be visible to someone browsing the Actions tab. Record failures on a single open issue labelled fuzz, which notifies repository watchers and stays visible until closed. --- .github/workflows/fuzz.yml | 35 +++++++++++++++++++++++++++++++++++ bindings/go/vcffi/README.md | 6 +++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 11b614b2..9fef6596 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -15,6 +15,9 @@ defaults: run: shell: bash -l {0} +permissions: + contents: read + jobs: go: runs-on: ubuntu-latest @@ -49,3 +52,35 @@ jobs: with: name: fuzz-${{ matrix.target }} path: bindings/go/vcffi/testdata/fuzz + + report: + # A scheduled run has no PR to turn red, so a failure would otherwise sit + # unseen in the Actions tab. Record it on one standing issue instead: it + # notifies repository watchers and stays visible until someone closes it. + # Manual runs skip this; whoever started one is already watching it. + needs: go + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + name: "📣 Report fuzz failure" + permissions: + issues: write + steps: + - name: open or update the fuzz failure issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh label create fuzz --color B60205 \ + --description "Failure found by the nightly fuzz workflow" --force + body="The nightly fuzz run failed: $RUN_URL + + The failing input is attached to that run as a \`fuzz-\` + artifact. Commit it under \`bindings/go/vcffi/testdata/fuzz\` so every + PR run replays it, fix the bug, then close this issue." + issue=$(gh issue list --label fuzz --state open --limit 1 --json number --jq '.[0].number') + if [ -n "$issue" ]; then + gh issue comment "$issue" --body "$body" + else + gh issue create --title "Nightly fuzzing found a failure" --label fuzz --body "$body" + fi diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index 1d714a9a..fd3dc799 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -51,9 +51,9 @@ targets in this package pin that contract, and also require anything accepted to re-encode to the same bytes (Undefined becoming Null is the one allowed difference). PR CI runs them over their seeds, which include every shared corpus vector, and the whole suite on 32-bit GOARCH, where the u32-bound -guards are load-bearing. `.github/workflows/fuzz.yml` fuzzes them nightly; -commit any failing input it reports under `testdata/fuzz` as a regression -seed. +guards are load-bearing. `.github/workflows/fuzz.yml` fuzzes them nightly +and records a failure on an open issue labelled `fuzz`; commit the failing +input attached to that run under `testdata/fuzz` as a regression seed. `ErrMalformed` covers hostile bytes, not caller mistakes: a `LeafSet` that is partially wired, or that does not materialize a kind the buffer carries, From 1b10b09ac200780a6aa64bf423445bd19bb0dfc8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 18:36:25 +1100 Subject: [PATCH 17/28] test(vcencrypt): fuzz Go and Rust value decoders against each other Each decoder's own fuzz targets only prove it agrees with itself. These targets feed the same bytes to vcffi and to the Rust decoder in the wasm guest, and fail when one accepts what the other rejects, or when both accept but re-encode differently. No guest export is added. Calling vc_encrypt or vc_decrypt with a never-issued handle reports Rust's accept or reject verdict, because both decode before the handle lookup. Rust's re-encoding comes from decrypting a ciphertext that is a single passthrough node, which avoids AES and the cipher's sealing rules. Refs #383 --- .../go/vcencrypt/differential_fuzz_test.go | 263 ++++++++++++++++++ .../FuzzDifferentialValue/27c1e73ef072e6f1 | 2 + 2 files changed, 265 insertions(+) create mode 100644 bindings/go/vcencrypt/differential_fuzz_test.go create mode 100644 bindings/go/vcencrypt/testdata/fuzz/FuzzDifferentialValue/27c1e73ef072e6f1 diff --git a/bindings/go/vcencrypt/differential_fuzz_test.go b/bindings/go/vcencrypt/differential_fuzz_test.go new file mode 100644 index 00000000..6e250f65 --- /dev/null +++ b/bindings/go/vcencrypt/differential_fuzz_test.go @@ -0,0 +1,263 @@ +package vcencrypt + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/tetratelabs/wazero/api" +) + +// Differential fuzzing: the same bytes go to Go's decoder (vcffi) and to the +// Rust decoder inside the embedded guest, and the two must agree. Each side's +// own fuzz targets prove it is consistent with itself; only a differential +// target finds the bugs where both are self-consistent but disagree, such as +// one side quieting a signaling NaN the other keeps. +// +// The guest has no decode-only export, and a test-only one would ship in the +// production module. Instead the targets lean on the order its transform +// calls work in: vc_encrypt and vc_decrypt decode their input BEFORE looking +// up the cipher handle. Called with a handle that was never issued, they +// answer ErrEncoding when Rust rejects the bytes and ErrBadHandle when it +// accepts them, without touching AES. TestUnissuedHandleReportsDecoding pins +// that order; everything below depends on it. + +// unissuedHandle is never a live session: the guest numbers handles from 1, +// because a zero high half of a packed result means "error". +const unissuedHandle = 0 + +// maxDifferentialInput bounds an input so one large, slow guest call cannot +// dominate a fuzzing run. Decoder bugs show up in small inputs. +const maxDifferentialInput = 4 << 10 + +var differentialAAD = []byte("vcencrypt differential fuzzing") + +// differentialGuest opens one guest instance and cipher for a fuzz target. +// Go runs the code before f.Fuzz once in every fuzzing worker process, so +// each worker reuses one instance across its iterations. +func differentialGuest(f *testing.F) (*Client, *Cipher) { + ctx := context.Background() + client, err := NewClient(ctx) + if err != nil { + f.Fatalf("NewClient: %v", err) + } + f.Cleanup(func() { _ = client.Close(ctx) }) + cipher, err := client.NewCipher(ctx, bytes.Repeat([]byte{0x2a}, KeySize)) + if err != nil { + f.Fatalf("NewCipher: %v", err) + } + return client, cipher +} + +// rustAccepts reports whether the guest's decoder accepts data as the input +// of fn (vc_encrypt for a value tree, vc_decrypt for a ciphertext tree). +func rustAccepts(c *Client, fn api.Function, data []byte) (bool, error) { + _, err := c.call(context.Background(), fn, unissuedHandle, differentialAAD, data) + switch { + case errors.Is(err, ErrBadHandle): + return true, nil + case errors.Is(err, ErrEncoding): + return false, nil + case err == nil: + return false, errors.New("guest succeeded under an unissued handle") + default: + return false, err + } +} + +// rustReencode decrypts a ciphertext tree under a live cipher and returns the +// guest's transport encoding of the result. +func rustReencode(c *Client, cipher *Cipher, ct []byte) ([]byte, error) { + return c.call(context.Background(), c.decrypt, cipher.handle, differentialAAD, ct) +} + +// corpusTransports returns the transport bytes of every valid and malformed +// vector in the shared value corpus. +func corpusTransports(f *testing.F) [][]byte { + data, err := os.ReadFile("../../../testdata/value-conformance.json") + if err != nil { + f.Fatal(err) + } + var corpus struct{ Vectors, Malformed []struct{ Transport string } } + if err := json.Unmarshal(data, &corpus); err != nil { + f.Fatal(err) + } + var out [][]byte + for _, row := range append(corpus.Vectors, corpus.Malformed...) { + raw, err := hex.DecodeString(row.Transport) + if err != nil { + f.Fatal(err) + } + out = append(out, raw) + } + return out +} + +// sameModuloUndefined fails unless goOut is rustOut with some Undefined tags +// (0x01) turned into Null (0x00): Go projects Undefined to nil by design, +// and both are one-byte tags, so the lengths must match. +func sameModuloUndefined(t *testing.T, data, rustOut, goOut []byte) { + t.Helper() + if len(goOut) != len(rustOut) { + t.Fatalf("input %x: Rust re-encodes as %x, Go as %x", data, rustOut, goOut) + } + for i := range rustOut { + if rustOut[i] != goOut[i] && (rustOut[i] != 0x01 || goOut[i] != 0x00) { + t.Fatalf("input %x: Rust re-encodes as %x, Go as %x (byte %d)", data, rustOut, goOut, i) + } + } +} + +func FuzzDifferentialValue(f *testing.F) { + client, cipher := differentialGuest(f) + for _, raw := range corpusTransports(f) { + f.Add(raw) + f.Add(append([]byte{0xF0, 1, 0, 0, 0}, raw...)) // in an array + f.Add(append([]byte{0xF2}, raw...)) // as passthrough + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxDifferentialInput { + return + } + goValue, goErr := vcffi.Unmarshal(data) + rustOK, err := rustAccepts(client, client.encrypt, data) + if err != nil { + t.Fatalf("input %x: guest decode failed abnormally: %v", data, err) + } + if rustOK != (goErr == nil) { + t.Fatalf("input %x: Rust accepts=%v, Go accepts=%v (Go error: %v)", data, rustOK, goErr == nil, goErr) + } + if !rustOK { + return + } + + // Both accept: compare what each makes of the bytes. Rust's view comes + // from decrypting a ciphertext that is a single passthrough node + // (ctPassthrough, 0x05) carrying the input: the guest decodes the + // value and re-encodes it as a Value passthrough (0xF2), with no AES + // and none of the cipher's rules about what may be sealed (encrypting + // would refuse, by design, a container holding only passthrough + // values). + wrapped := append([]byte{0x05}, data...) + rustOut, err := rustReencode(client, cipher, wrapped) + if errors.Is(err, ErrEncoding) { + // The wrapper adds a level of nesting, so an input at the depth + // limit can be refused only when wrapped. Go must agree. + if _, goErr := vcffi.UnmarshalCipherText(vcffi.VCValueLeaves(), wrapped); goErr == nil { + t.Fatalf("input %x: Rust rejects it as a passthrough node, Go accepts", data) + } + return + } + if err != nil { + t.Fatalf("input %x: guest re-encode failed: %v", data, err) + } + // Rust has no Undefined projection, so its re-encoding is exact. + if !bytes.Equal(rustOut, append([]byte{0xF2}, data...)) { + t.Fatalf("input %x: Rust re-encodes the passthrough as %x", data, rustOut) + } + rustOut = rustOut[1:] + goOut, err := vcffi.Marshal(goValue) + if err != nil { + t.Fatalf("input %x: Go decoded %#v but cannot re-encode it: %v", data, goValue, err) + } + sameModuloUndefined(t, data, rustOut, goOut) + }) +} + +func FuzzDifferentialCipherText(f *testing.F) { + client, cipher := differentialGuest(f) + ctx := context.Background() + for _, raw := range corpusTransports(f) { + f.Add(append([]byte{0x05}, raw...)) // as a clear passthrough node + // Real ciphertext of each valid vector, so the fuzzer starts from + // well-formed sealed leaves of every kind. + if ct, err := client.call(ctx, client.encrypt, cipher.handle, differentialAAD, raw); err == nil { + f.Add(ct) + } + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxDifferentialInput { + return + } + _, goErr := vcffi.UnmarshalCipherText(vcffi.VCValueLeaves(), data) + rustOK, err := rustAccepts(client, client.decrypt, data) + if err != nil { + t.Fatalf("input %x: guest decode failed abnormally: %v", data, err) + } + // Fuzzed seals never authenticate, so there is no plaintext to + // compare: acceptance is the whole check. + if rustOK != (goErr == nil) { + t.Fatalf("input %x: Rust accepts=%v, Go accepts=%v (Go error: %v)", data, rustOK, goErr == nil, goErr) + } + }) +} + +// TestUnissuedHandleReportsDecoding pins the call order the differential +// targets read Rust's verdict from: decode first, handle lookup second. If +// the guest ever looked the handle up first, every input would come back +// ErrBadHandle and read as accepted, so the targets would compare nothing. +func TestUnissuedHandleReportsDecoding(t *testing.T) { + c := newRawClient(t) + valid := []byte{0x00} // Null + malformed := []byte{0x0A, 9, 0, 0} // truncated String + for _, tc := range []struct { + name string + fn api.Function + data []byte + want error + }{ + {"encrypt valid", c.encrypt, valid, ErrBadHandle}, + {"encrypt malformed", c.encrypt, malformed, ErrEncoding}, + {"decrypt valid", c.decrypt, []byte{0x05, 0x00}, ErrBadHandle}, // passthrough Null + {"decrypt malformed", c.decrypt, malformed, ErrEncoding}, + } { + _, err := c.call(context.Background(), tc.fn, unissuedHandle, differentialAAD, tc.data) + if !errors.Is(err, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, err, tc.want) + } + } +} + +// BenchmarkDifferential measures the two tiers' cost per input: the +// accept/reject check every input pays, and the re-encode only accepted +// inputs pay. It sizes the nightly fuzz time. +func BenchmarkDifferential(b *testing.B) { + ctx := context.Background() + client, err := NewClient(ctx) + if err != nil { + b.Fatal(err) + } + defer func() { _ = client.Close(ctx) }() + cipher, err := client.NewCipher(ctx, bytes.Repeat([]byte{0x2a}, KeySize)) + if err != nil { + b.Fatal(err) + } + // An object of a few mixed scalars: a typical small accepted input. + data, err := vcffi.Marshal(map[string]any{"a": int64(7), "b": "hello", "c": []any{true, 1.5}}) + if err != nil { + b.Fatal(err) + } + + b.Run("accept-check", func(b *testing.B) { + for b.Loop() { + if ok, err := rustAccepts(client, client.encrypt, data); !ok || err != nil { + b.Fatal(ok, err) + } + } + }) + b.Run("re-encode", func(b *testing.B) { + wrapped := append([]byte{0x05}, data...) + for b.Loop() { + if _, err := rustReencode(client, cipher, wrapped); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/bindings/go/vcencrypt/testdata/fuzz/FuzzDifferentialValue/27c1e73ef072e6f1 b/bindings/go/vcencrypt/testdata/fuzz/FuzzDifferentialValue/27c1e73ef072e6f1 new file mode 100644 index 00000000..5c565677 --- /dev/null +++ b/bindings/go/vcencrypt/testdata/fuzz/FuzzDifferentialValue/27c1e73ef072e6f1 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("\xf0\x01\x00\x00\x00\xf2\x01") From fc15187c57c5601069d613840bca3c7f46632804 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 18:36:26 +1100 Subject: [PATCH 18/28] ci(fuzz): run the differential targets nightly against a fresh guest The committed wasm guest is rebuilt by hand and can lag its source, so the nightly job rebuilds it before fuzzing, comparing Go with the Rust on the same commit. Document the approach, and mark the guest's decode-before-handle-lookup order as relied on. Closes #383 --- .github/workflows/fuzz.yml | 35 +++++++++++++++++++++----- bindings/go/vcencrypt/README.md | 34 +++++++++++++++++++++++++ bindings/go/vcencrypt/guest/src/abi.rs | 7 ++++-- bindings/go/vcffi/README.md | 2 ++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 9fef6596..5cd62309 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -25,12 +25,34 @@ jobs: strategy: fail-fast: false matrix: - # `go test -fuzz` runs one target per invocation. - target: [FuzzUnmarshal, FuzzUnmarshalCipherText] + # `go test -fuzz` runs one target per invocation. The differential + # targets compare vcffi against the Rust decoder in the wasm guest, + # so they live with the guest in vcencrypt. + include: + - { target: FuzzUnmarshal, dir: bindings/go/vcffi } + - { target: FuzzUnmarshalCipherText, dir: bindings/go/vcffi } + - { target: FuzzDifferentialValue, dir: bindings/go/vcencrypt } + - { target: FuzzDifferentialCipherText, dir: bindings/go/vcencrypt } steps: - uses: actions/checkout@v7 + # The committed guest binary is rebuilt by hand, so it can lag the + # Rust it was built from. A differential run must compare Go against + # the Rust on this commit, so rebuild it in place first. + - name: setup rust stable + if: matrix.dir == 'bindings/go/vcencrypt' + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1 + + - name: rebuild the wasm guest + if: matrix.dir == 'bindings/go/vcencrypt' + working-directory: bindings/go/vcencrypt/guest + run: | + cargo build --release --target wasm32-wasip1 + cp target/wasm32-wasip1/release/vitaminc_wasi_guest.wasm ../wasm/vitaminc_guest.wasm + - uses: actions/setup-go@v7 with: go-version-file: bindings/go/vcencrypt/go.mod @@ -39,7 +61,7 @@ jobs: bindings/go/vcencrypt/examples/go.sum - name: fuzz ${{ matrix.target }} - working-directory: bindings/go/vcffi + working-directory: ${{ matrix.dir }} env: FUZZTIME: ${{ inputs.fuzztime || '5m' }} run: CGO_ENABLED=0 go test -run='^$' -fuzz="^${{ matrix.target }}\$" -fuzztime="$FUZZTIME" . @@ -51,7 +73,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: fuzz-${{ matrix.target }} - path: bindings/go/vcffi/testdata/fuzz + path: ${{ matrix.dir }}/testdata/fuzz report: # A scheduled run has no PR to turn red, so a failure would otherwise sit @@ -76,8 +98,9 @@ jobs: body="The nightly fuzz run failed: $RUN_URL The failing input is attached to that run as a \`fuzz-\` - artifact. Commit it under \`bindings/go/vcffi/testdata/fuzz\` so every - PR run replays it, fix the bug, then close this issue." + artifact. Commit it under that target's \`testdata/fuzz\` (in + \`bindings/go/vcffi\` or \`bindings/go/vcencrypt\`) so every PR run + replays it, fix the bug, then close this issue." issue=$(gh issue list --label fuzz --state open --limit 1 --json number --jq '.[0].number') if [ -n "$issue" ]; then gh issue comment "$issue" --body "$body" diff --git a/bindings/go/vcencrypt/README.md b/bindings/go/vcencrypt/README.md index e3dde0b7..faf0d57a 100644 --- a/bindings/go/vcencrypt/README.md +++ b/bindings/go/vcencrypt/README.md @@ -216,6 +216,40 @@ cargo run --example gen_fixture # only if the fixture value/codec changed cd .. && CGO_ENABLED=0 go test ./... ``` +## Differential fuzzing + +`differential_fuzz_test.go` feeds the same bytes to Go's decoder (`vcffi`) +and to the Rust decoder inside this guest, and fails when they disagree. +Each side's own fuzz targets already prove it round-trips its own input; +these find inputs where both are self-consistent but differ from each other. + +- **`FuzzDifferentialValue`** checks that both sides accept or reject the + same value bytes. When both accept, it checks that Rust re-encodes the + input exactly and that Go's re-encoding matches, allowing only Go's + `undefined` to `null` projection. +- **`FuzzDifferentialCipherText`** checks that both sides accept or reject + the same ciphertext bytes. Fuzzed seals never authenticate, so there is no + plaintext to compare. + +The guest has no decode-only export, and adding a test-only one would ship it +in the production module. The targets rely on how the existing exports +behave instead: + +- `vc_encrypt` and `vc_decrypt` decode their input before they look up the + cipher handle. Called with a handle that was never issued, they return + `ErrEncoding` when Rust rejects the bytes and `ErrBadHandle` when it + accepts them, with no AES work. `TestUnissuedHandleReportsDecoding` pins + that order. +- Rust's re-encoding comes from decrypting a ciphertext that is a single + passthrough node carrying the input. That path involves no AES and none of + the cipher's rules about what may be sealed. Encrypting would not work + here: for example, the cipher refuses a container whose children are all + passthrough values. + +PR CI runs both targets over their seeds against the committed guest. The +nightly fuzz workflow (`.github/workflows/fuzz.yml`) rebuilds the guest from +source first, so it always compares Go with the Rust on the same commit. + ## Trade-offs (accepted for the spike) - On wasm32 `vitaminc-encrypt` uses its pure-Rust RustCrypto backend: no diff --git a/bindings/go/vcencrypt/guest/src/abi.rs b/bindings/go/vcencrypt/guest/src/abi.rs index 95e1fc52..20426ea1 100644 --- a/bindings/go/vcencrypt/guest/src/abi.rs +++ b/bindings/go/vcencrypt/guest/src/abi.rs @@ -300,7 +300,8 @@ pub unsafe extern "C" fn vc_encrypt_element( fn encrypt(handle: u32, aad: &[u8], val: &[u8], as_element: bool) -> Result, u32> { // Decode the input value first so garbage bytes surface as an encoding - // error even for an unknown handle. + // error even for an unknown handle. vcencrypt's differential fuzz targets + // rely on this order (`differential_fuzz_test.go`). let value = codec::decode_value(&mut codec::Reader::new(val)).map_err(|_| STATUS_ENCODING)?; with_cipher(handle, |cipher| { let aad = Context::from_encoded(aad); @@ -376,7 +377,9 @@ pub unsafe extern "C" fn vc_decrypt_element( } fn decrypt(handle: u32, aad: &[u8], ct: &[u8], as_element: bool) -> Result, u32> { - // Decode + re-home to the Box passthrough payload type the decipher expects. + // Decode + re-home to the Box passthrough payload type the decipher + // expects. Decoding before the handle lookup is relied on by vcencrypt's + // differential fuzz targets, as in `encrypt`. let ct: AesCipherText = codec::decode_ciphertext_boxed(&mut codec::Reader::new(ct)).map_err(|_| STATUS_ENCODING)?; with_cipher(handle, |cipher| { diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index fd3dc799..a855ec11 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -54,6 +54,8 @@ corpus vector, and the whole suite on 32-bit GOARCH, where the u32-bound guards are load-bearing. `.github/workflows/fuzz.yml` fuzzes them nightly and records a failure on an open issue labelled `fuzz`; commit the failing input attached to that run under `testdata/fuzz` as a regression seed. +vcencrypt's differential fuzz targets check the same decoders against the +Rust ones in its wasm guest (see its README). `ErrMalformed` covers hostile bytes, not caller mistakes: a `LeafSet` that is partially wired, or that does not materialize a kind the buffer carries, From 3bcc8c9e6a5779e592bfd34a3cd29c16eb0b9d0d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 18:49:13 +1100 Subject: [PATCH 19/28] fix(vcffi): keep signaling NaN bits of named float32 types A named float32 type still went through rv.Float(), whose widening to float64 sets the quiet bit. Converting to the built-in float32 copies the bits instead, since reflect special-cases float32 to float32 (Go issue 36400), so named types now round-trip like plain float32. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- bindings/go/vcffi/codec_test.go | 9 ++++++++- bindings/go/vcffi/encoder.go | 11 +++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/bindings/go/vcffi/codec_test.go b/bindings/go/vcffi/codec_test.go index c8fc6f1b..c80704a2 100644 --- a/bindings/go/vcffi/codec_test.go +++ b/bindings/go/vcffi/codec_test.go @@ -1014,6 +1014,9 @@ func TestFramingTags(t *testing.T) { // A signaling NaN must keep its bits: widening it to float64 on the way to // the encoder would set the quiet bit (found by FuzzUnmarshal). +// namedFloat32 has no Encryptable method, so it reaches the reflect path. +type namedFloat32 float32 + func TestFloat32SignalingNaNKeepsItsBits(t *testing.T) { for _, bits := range []uint32{0xff833030, 0x7fba3030, 0x7f800001} { wire := binary.LittleEndian.AppendUint32([]byte{tagFloat32}, bits) @@ -1021,7 +1024,11 @@ func TestFloat32SignalingNaNKeepsItsBits(t *testing.T) { if err != nil { t.Fatal(err) } - for _, v := range []any{value, []any{value}, struct{ F float32 }{value.(float32)}} { + f := value.(float32) + for _, v := range []any{ + value, []any{value}, struct{ F float32 }{f}, + namedFloat32(f), []namedFloat32{namedFloat32(f)}, struct{ F namedFloat32 }{namedFloat32(f)}, + } { encoded, err := Marshal(v) if err != nil { t.Fatal(err) diff --git a/bindings/go/vcffi/encoder.go b/bindings/go/vcffi/encoder.go index 32d55679..6f95bc2e 100644 --- a/bindings/go/vcffi/encoder.go +++ b/bindings/go/vcffi/encoder.go @@ -585,10 +585,11 @@ func encodeReflect(enc Encoder, rv reflect.Value) { enc.UInt64(rv.Uint()) case reflect.Float32: // rv.Float() widens to float64, and the hardware widening sets the - // quiet bit of a signaling NaN. Read a plain float32 directly so its - // bits survive; a named float32 type has no bit-exact reflect path - // without unsafe, so only it falls back to the widening. - if f, ok := interfaceOf(rv).(float32); ok { + // quiet bit of a signaling NaN. Converting to the built-in float32 + // instead copies the bits (reflect special-cases float32 to float32, + // Go issue 36400), so a named float32 type keeps them too. Only a + // value that cannot be read as an interface falls back to widening. + if f, ok := interfaceOf(rv.Convert(float32Type)).(float32); ok { enc.Float32(f) } else { enc.Float32(float32(rv.Float())) @@ -699,6 +700,8 @@ func encodeStruct(enc Encoder, rv reflect.Value) { // interfaceOf is rv.Interface(), or nil where reflection forbids it // (values reached through unexported fields). +var float32Type = reflect.TypeOf(float32(0)) + func interfaceOf(rv reflect.Value) any { if !rv.CanInterface() { return nil From 8b8102aacf5d73ecd0dd420c2b90a872c6ae42b4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 18:49:14 +1100 Subject: [PATCH 20/28] test(vcffi): only tolerate Undefined becoming Null at real Undefined tags The fuzz targets accepted a 0x01 to 0x00 change at any offset, so a corrupted payload byte, such as one inside Bytes, passed as the documented Undefined projection. Walk the accepted input to find its Undefined value-node tags, nested ones included, and require the re-encoding to change exactly those bytes. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- bindings/go/vcffi/fuzz_test.go | 124 +++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/bindings/go/vcffi/fuzz_test.go b/bindings/go/vcffi/fuzz_test.go index 69a3fd33..c7c69455 100644 --- a/bindings/go/vcffi/fuzz_test.go +++ b/bindings/go/vcffi/fuzz_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "os" + "slices" "testing" ) @@ -36,20 +37,95 @@ func addCorpusSeeds(f *testing.F, wrap func([]byte) []byte) { } } -// requireCanonical fails unless out re-encodes data exactly. The one allowed -// difference is Go's documented projection of Undefined to nil: both are -// one-byte tags, so a lossless re-encode keeps the length and may differ -// only where an Undefined tag (0x01) became Null (0x00). -func requireCanonical(t *testing.T, data, out []byte) { +// requireCanonical fails unless out re-encodes data exactly, apart from Go's +// documented projection of Undefined to nil: each offset in undefinedAt is an +// Undefined tag (0x01) that must come back as Null (0x00). Every other byte, +// including a 0x01 inside a payload, must be unchanged. +func requireCanonical(t *testing.T, data, out []byte, undefinedAt []int) { t.Helper() - if len(out) != len(data) { - t.Fatalf("accepted %x re-encoded with a different length: %x", data, out) + want := bytes.Clone(data) + for _, at := range undefinedAt { + want[at] = tagNull } - for i := range data { - if data[i] != out[i] && (data[i] != 0x01 || out[i] != 0x00) { - t.Fatalf("accepted %x re-encoded differently at byte %d: %x", data, i, out) + if !bytes.Equal(out, want) { + t.Fatalf("accepted %x re-encoded as %x, want %x", data, out, want) + } +} + +// valueUndefinedTags returns the offset of every Undefined value-node tag in +// an accepted value tree, nested ones included. It walks the transport format +// independently of the decoder, so a walk that falls out of step with it +// reports the wrong offsets and fails requireCanonical rather than passing. +func valueUndefinedTags(data []byte) []int { + r := &reader{buf: data} + var at []int + walkValue(r, &at) + return at +} + +func walkValue(r *reader, at *[]int) { + pos := r.pos + tag, _ := r.byteTag() + switch tag { + case tagUndefined: + *at = append(*at, pos) + case tagInt32, tagUint32, tagFloat32: + _, _ = r.take(4) + case tagInt64, tagUint64, tagFloat64: + _, _ = r.take(8) + case tagString, tagBytes: + n, _ := r.count() + _, _ = r.take(n) + case tagArray: + n, _ := r.count() + for range n { + walkValue(r, at) + } + case tagObject: + n, _ := r.count() + for range n { + _, _ = r.str() + walkValue(r, at) + } + case tagPassthrough: + walkValue(r, at) + default: + // Null, booleans and the fixed-width extended scalars. + _, _ = r.take(scalarWidth(tag)) + } +} + +// cipherUndefinedTags is valueUndefinedTags for an accepted ciphertext tree, +// where Undefined can only appear inside passthrough nodes. +func cipherUndefinedTags(data []byte) []int { + r := &reader{buf: data} + var at []int + var walk func() + walk = func() { + tag, _ := r.byteTag() + if _, ok := leafKind(tag); ok { + n, _ := r.count() + _, _ = r.take(n) + return + } + switch tag { + case ctSeq: + n, _ := r.count() + for range n { + walk() + } + case ctMap: + n, _ := r.count() + for range n { + _, _ = r.str() + walk() + } + case ctPassthrough: + walkValue(r, &at) } } + walk() + return at } func FuzzUnmarshal(f *testing.F) { @@ -76,7 +152,7 @@ func FuzzUnmarshal(f *testing.F) { if err != nil { t.Fatalf("accepted decode failed to re-encode: %v (value %#v)", err, v) } - requireCanonical(t, data, out) + requireCanonical(t, data, out, valueUndefinedTags(data)) // The re-encoding holds no Undefined, so it must be a fixed point. again, err := Unmarshal(out) if err != nil { @@ -109,6 +185,30 @@ func FuzzUnmarshalCipherText(f *testing.F) { if err != nil { t.Fatalf("accepted ciphertext decode failed to re-encode: %v (value %#v)", err, v) } - requireCanonical(t, data, out) + requireCanonical(t, data, out, cipherUndefinedTags(data)) }) } + +// The walkers decide which bytes may legitimately change, so pin that they +// find nested Undefined tags and nothing else. +func TestUndefinedTagWalkers(t *testing.T) { + for _, tc := range []struct { + name string + walk func([]byte) []int + data []byte + want []int + }{ + {"bare", valueUndefinedTags, []byte{0x01}, []int{0}}, + {"bytes payload of 0x01", valueUndefinedTags, []byte{0x0B, 1, 0, 0, 0, 0x01}, nil}, + {"array", valueUndefinedTags, []byte{0xF0, 2, 0, 0, 0, 0x0B, 1, 0, 0, 0, 0x01, 0x01}, []int{11}}, + {"object", valueUndefinedTags, []byte{0xF1, 1, 0, 0, 0, 1, 0, 0, 0, 0x01, 0x01}, []int{10}}, + {"passthrough", valueUndefinedTags, []byte{0xF2, 0x01}, []int{1}}, + {"extended scalar", valueUndefinedTags, []byte{0xF0, 2, 0, 0, 0, 0x0D, 0x01, 0x01}, []int{7}}, + {"sealed leaf of 0x01", cipherUndefinedTags, []byte{0x01, 1, 0, 0, 0, 0x01}, nil}, + {"ciphertext passthrough", cipherUndefinedTags, []byte{0x03, 1, 0, 0, 0, 0x05, 0xF0, 1, 0, 0, 0, 0x01}, []int{11}}, + } { + if got := tc.walk(tc.data); !slices.Equal(got, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} From 615083997b377edffa31cbbedfd2e52b7c3fd6cc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 18:52:14 +1100 Subject: [PATCH 21/28] test(vcencrypt): only tolerate Undefined becoming Null at real Undefined tags The differential comparison accepted a 0x01 to 0x00 change at any offset, so Rust Int32(1) against Go Int32(0) passed as the Undefined projection. Walk Rust's re-encoding to find its Undefined value-node tags and require Go's to differ at exactly those bytes. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- .../go/vcencrypt/differential_fuzz_test.go | 107 ++++++++++++++++-- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/bindings/go/vcencrypt/differential_fuzz_test.go b/bindings/go/vcencrypt/differential_fuzz_test.go index 6e250f65..d1bf32fd 100644 --- a/bindings/go/vcencrypt/differential_fuzz_test.go +++ b/bindings/go/vcencrypt/differential_fuzz_test.go @@ -3,10 +3,12 @@ package vcencrypt import ( "bytes" "context" + "encoding/binary" "encoding/hex" "encoding/json" "errors" "os" + "slices" "testing" "github.com/cipherstash/vitaminc/bindings/go/vcffi" @@ -98,19 +100,82 @@ func corpusTransports(f *testing.F) [][]byte { return out } -// sameModuloUndefined fails unless goOut is rustOut with some Undefined tags -// (0x01) turned into Null (0x00): Go projects Undefined to nil by design, -// and both are one-byte tags, so the lengths must match. -func sameModuloUndefined(t *testing.T, data, rustOut, goOut []byte) { +// requireGoMatchesRust fails unless goOut is rustOut with exactly its +// Undefined value-node tags (0x01) turned into Null (0x00): Go projects +// Undefined to nil by design. Every other byte, including a 0x01 inside a +// payload, must match. +func requireGoMatchesRust(t *testing.T, data, rustOut, goOut []byte) { t.Helper() - if len(goOut) != len(rustOut) { - t.Fatalf("input %x: Rust re-encodes as %x, Go as %x", data, rustOut, goOut) + want := bytes.Clone(rustOut) + for _, at := range undefinedTags(rustOut) { + want[at] = 0x00 } - for i := range rustOut { - if rustOut[i] != goOut[i] && (rustOut[i] != 0x01 || goOut[i] != 0x00) { - t.Fatalf("input %x: Rust re-encodes as %x, Go as %x (byte %d)", data, rustOut, goOut, i) + if !bytes.Equal(goOut, want) { + t.Fatalf("input %x: Rust re-encodes as %x, Go as %x, want %x", data, rustOut, goOut, want) + } +} + +// undefinedTags returns the offset of every Undefined value-node tag in an +// accepted value tree, nested ones included. vcffi's fuzz tests have the same +// walker, but this is a separate Go module and cannot import test code. It +// follows the transport format independently of either decoder, so a walk +// that falls out of step reports the wrong offsets and fails the comparison +// rather than passing it. +func undefinedTags(data []byte) []int { + pos := 0 + var at []int + take := func(n int) { + pos = min(pos+n, len(data)) + } + count := func() int { + if len(data)-pos < 4 { + pos = len(data) + return 0 + } + n := int(binary.LittleEndian.Uint32(data[pos:])) + pos += 4 + return n + } + var walk func() + walk = func() { + if pos >= len(data) { + return + } + tag := data[pos] + pos++ + switch tag { + case 0x01: // Undefined + at = append(at, pos-1) + case 0x0C, 0x0D: // Int8, UInt8 + take(1) + case 0x0E, 0x0F: // Int16, UInt16 + take(2) + case 0x04, 0x06, 0x08, 0x12: // Int32, UInt32, Float32, Date + take(4) + case 0x05, 0x07, 0x09: // Int64, UInt64, Float64 + take(8) + case 0x13: // Timestamp + take(12) + case 0x10, 0x11, 0x14: // Int128, UInt128, Decimal + take(16) + case 0x0A, 0x0B: // String, Bytes + take(count()) + case 0xF0: // Array + for range count() { + walk() + } + case 0xF1: // Object + for range count() { + take(count()) + walk() + } + case 0xF2: // Passthrough + walk() } + // Null, False and True have no payload. } + walk() + return at } func FuzzDifferentialValue(f *testing.F) { @@ -166,7 +231,7 @@ func FuzzDifferentialValue(f *testing.F) { if err != nil { t.Fatalf("input %x: Go decoded %#v but cannot re-encode it: %v", data, goValue, err) } - sameModuloUndefined(t, data, rustOut, goOut) + requireGoMatchesRust(t, data, rustOut, goOut) }) } @@ -261,3 +326,25 @@ func BenchmarkDifferential(b *testing.B) { } }) } + +// The walker decides which bytes may differ, so pin that it finds nested +// Undefined tags and nothing else. +func TestUndefinedTags(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + want []int + }{ + {"bare", []byte{0x01}, []int{0}}, + {"int32 payload of 1", []byte{0x04, 1, 0, 0, 0}, nil}, + {"bytes payload of 0x01", []byte{0x0B, 1, 0, 0, 0, 0x01}, nil}, + {"array", []byte{0xF0, 2, 0, 0, 0, 0x0B, 1, 0, 0, 0, 0x01, 0x01}, []int{11}}, + {"object key of 0x01", []byte{0xF1, 1, 0, 0, 0, 1, 0, 0, 0, 0x01, 0x01}, []int{10}}, + {"passthrough", []byte{0xF2, 0x01}, []int{1}}, + {"extended scalars", []byte{0xF0, 3, 0, 0, 0, 0x0D, 0x01, 0x13, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0x01}, []int{20}}, + } { + if got := undefinedTags(tc.data); !slices.Equal(got, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} From 505a805833c84124bf928098b39bffb60c8dcc00 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 20:35:47 +1100 Subject: [PATCH 22/28] test(aead-napi): cover object handling at the N-API boundary in Node The live Node suite only covered scalar conversions. Add a suite for which keys are read (own, enumerable, string only, prototype pollution ignored), how output properties are written (own data properties, no inherited setter runs), NUL and numeric keys, and the ciphertext node projection, which the test addon now exposes as a round trip. The addon itself no longer needs unsafe. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- packages/aead-napi/tests/addon/src/lib.rs | 28 +++---- packages/aead-napi/tests/node.rs | 24 +++--- packages/aead-napi/tests/object-safety.cjs | 89 ++++++++++++++++++++++ 3 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 packages/aead-napi/tests/object-safety.cjs diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs index f0895b3d..90cd8677 100644 --- a/packages/aead-napi/tests/addon/src/lib.rs +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -1,16 +1,11 @@ -use napi::{ - bindgen_prelude::{Buffer, FromNapiValue, JsValue, ToNapiValue, Unknown}, - Env, -}; +use napi::bindgen_prelude::Buffer; use napi_derive::napi; -use vitaminc_aead_napi::NapiValue; +use vitaminc_aead_napi::{JsCipherText, NapiValue}; use vitaminc_aead_value::transport::{decode_value, encode_value, Reader}; use vitaminc_protected::{Controlled, Protected}; #[napi] -pub fn encode(value: Unknown<'_>) -> napi::Result { - let raw = value.value(); - let value = unsafe { NapiValue::from_napi_value(raw.env, raw.value) }?; +pub fn encode(value: NapiValue) -> napi::Result { let mut bytes = Protected::new(Vec::new()); encode_value(value.0, bytes.risky_inner_mut()) .map_err(|_| napi::Error::from_reason("transport encode failed"))?; @@ -18,11 +13,10 @@ pub fn encode(value: Unknown<'_>) -> napi::Result { } #[napi] -pub fn decode(env: Env, bytes: Buffer) -> napi::Result> { - let value = decode_value(&mut Reader::new(&bytes)) - .map_err(|_| napi::Error::from_reason("transport decode failed"))?; - let raw = unsafe { NapiValue::to_napi_value(env.raw(), NapiValue(value)) }?; - unsafe { Unknown::from_napi_value(env.raw(), raw) } +pub fn decode(bytes: Buffer) -> napi::Result { + decode_value(&mut Reader::new(&bytes)) + .map(NapiValue) + .map_err(|_| napi::Error::from_reason("transport decode failed")) } #[napi] @@ -34,3 +28,11 @@ pub fn codec_round_trip(bytes: Buffer) -> napi::Result { .map_err(|_| napi::Error::from_reason("transport encode failed"))?; Ok(Buffer::from(encoded.risky_ref().to_vec())) } + +/// Read a ciphertext node tree into Rust and project it back to JS. +#[napi(ts_return_type = "unknown")] +pub fn ciphertext_round_trip( + #[napi(ts_arg_type = "unknown")] ct: JsCipherText, NapiValue>, +) -> JsCipherText, NapiValue> { + ct +} diff --git a/packages/aead-napi/tests/node.rs b/packages/aead-napi/tests/node.rs index 72f2cd4b..ae0bcc77 100644 --- a/packages/aead-napi/tests/node.rs +++ b/packages/aead-napi/tests/node.rs @@ -29,15 +29,17 @@ fn node_value_conformance() { )); let module = target.join("addon.node"); std::fs::copy(library, &module).expect("copy Node module"); - let result = Command::new("node") - .arg(package.join("tests/value-conformance.cjs")) - .arg(module) - .output() - .expect("Node.js is required for binding tests"); - assert!( - result.status.success(), - "Node conformance:\n{}\n{}", - String::from_utf8_lossy(&result.stdout), - String::from_utf8_lossy(&result.stderr) - ); + for suite in ["tests/value-conformance.cjs", "tests/object-safety.cjs"] { + let result = Command::new("node") + .arg(package.join(suite)) + .arg(&module) + .output() + .expect("Node.js is required for binding tests"); + assert!( + result.status.success(), + "{suite}:\n{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + } } diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs new file mode 100644 index 00000000..cb441d90 --- /dev/null +++ b/packages/aead-napi/tests/object-safety.cjs @@ -0,0 +1,89 @@ +// Object handling at the N-API boundary: which keys are read, how output +// properties are written, and the ciphertext node projection. Run by +// tests/node.rs with the test addon's path. +const assert = require('node:assert/strict'); +const path = require('node:path'); +const addon = require(path.resolve(process.argv[2])); + +const roundTrip = (value) => addon.decode(addon.encode(value)); + +// Only own, enumerable, string keys are read. +const mixed = { own: 2 }; +Object.defineProperty(mixed, 'hidden', { value: 3, enumerable: false }); +mixed[Symbol('s')] = 4; +assert.deepEqual(Object.keys(roundTrip(mixed)), ['own']); +const bare = Object.create(null); +bare.own = 1; +assert.deepEqual(Object.keys(roundTrip(bare)), ['own']); +// An object with any other prototype is refused rather than read. +assert.throws(() => addon.encode(Object.create({ inherited: 1 }))); + +// A polluted Object.prototype contributes no keys. +Object.prototype.polluted = 'x'; +try { + assert.deepEqual(Object.keys(roundTrip({ a: 1 })), ['a']); +} finally { + delete Object.prototype.polluted; +} + +// An own property explicitly set to undefined round-trips. +const explicit = roundTrip({ x: undefined }); +assert.ok(Object.hasOwn(explicit, 'x')); +assert.equal(explicit.x, undefined); + +// Keys holding NUL, and numeric keys, keep their exact text. +assert.deepEqual(Object.keys(roundTrip({ 'a\0b': 1, 7: 2 })), ['7', 'a\0b']); + +// Output properties are own data properties, defined without running a +// polluted inherited setter. +let captured = 'untouched'; +Object.defineProperty(Object.prototype, 'leak', { + set(v) { captured = v; }, + configurable: true, +}); +try { + const out = roundTrip({ leak: 'secret' }); + assert.equal(captured, 'untouched'); + assert.ok(Object.hasOwn(out, 'leak')); + assert.equal(out.leak, 'secret'); +} finally { + delete Object.prototype.leak; +} + +// Prototype-touching keys are refused. +assert.throws(() => addon.encode(JSON.parse('{"__proto__": 1}'))); +assert.throws(() => addon.encode({ constructor: 1 })); + +// Scalar wrappers are written as own properties too. +assert.deepEqual(roundTrip({ date: '2026-10-09' }), { date: '2026-10-09' }); + +// The ciphertext node projection survives a trip through Rust. +const leaf = Buffer.from('a1b2', 'hex'); +const ct = { + t: 'map', + v: { + a: { t: 'ct', v: leaf }, + 'k\0': { t: 'seq', v: [{ t: 'none', v: leaf }, { t: 'pt', v: 5n }] }, + e: { t: 'eseq', v: leaf }, + m: { t: 'emap', v: leaf }, + u: { t: 'pt', v: undefined }, + }, +}; +assert.deepEqual(addon.ciphertextRoundTrip(ct), ct); + +// A ciphertext map reads own keys only and writes own properties. +const map = Object.create({ inherited: { t: 'ct', v: leaf } }); +map.own = { t: 'ct', v: leaf }; +map[Symbol('s')] = { t: 'ct', v: leaf }; +Object.defineProperty(Object.prototype, 'own', { + set(v) { captured = v; }, + configurable: true, +}); +try { + const out = addon.ciphertextRoundTrip({ t: 'map', v: map }); + assert.equal(captured, 'untouched'); + assert.deepEqual(Object.keys(out.v), ['own']); +} finally { + delete Object.prototype.own; +} +assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v: JSON.parse('{"__proto__": {"t":"ct","v":"x"}}') })); From 98d181f27c485c8f9e5037213fa1a254f55ba935 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 20:35:48 +1100 Subject: [PATCH 23/28] refactor(aead-napi): move conversions onto napi-rs's safe API All 39 unsafe blocks were Node-API calls, which Miri cannot run. Most had safe napi-rs equivalents: build JS values with into_unknown, read keys with get_all_property_names, read properties with get_property_unchecked, and define them with define_properties. Two unsafe blocks remain, inside the FromNapiValue impls napi-rs requires, and the crate now denies unsafe_op_in_unsafe_fn and undocumented unsafe blocks so each states why it is sound. Behaviour is unchanged: the object-safety suite passes before and after. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- packages/aead-napi/README.md | 10 +- packages/aead-napi/examples/kind_inventory.rs | 4 +- packages/aead-napi/src/ciphertext.rs | 47 ++-- packages/aead-napi/src/convert.rs | 218 ++++++------------ packages/aead-napi/src/lib.rs | 4 + 5 files changed, 101 insertions(+), 182 deletions(-) diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index 238857ee..6c400843 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -53,7 +53,10 @@ it is not itself loadable from Node. ## Testing the conversion layer `cargo test -p vitaminc-aead-napi` runs pure Rust boundary tests and builds a -small standalone addon to run `tests/value-conformance.cjs` inside Node. +small standalone addon to run two suites inside Node: +`tests/value-conformance.cjs` for scalar conversions, and +`tests/object-safety.cjs` for which object keys are read, how output +properties are written, and the ciphertext node projection. Node.js must be available on PATH. The addon is a separate Cargo workspace so the library's `napi/noop` dev feature cannot stub the real Node symbols. @@ -87,6 +90,11 @@ Node conformance test runs in package-scoped mutation tests as well as CI. ## Safety notes +- Unsafe code is limited to the conversion trait methods napi-rs requires + (`FromNapiValue`/`ToNapiValue` for `NapiValue` and `JsCipherText`). + Everything else uses napi-rs's safe API. The crate denies + `unsafe_op_in_unsafe_fn` and `clippy::undocumented_unsafe_blocks`, so each + unsafe block states why it is sound. - The original copies of encrypted values in the V8 heap are owned by the JS engine and cannot be wiped from Rust. - Property names that would touch the prototype chain (`__proto__`, diff --git a/packages/aead-napi/examples/kind_inventory.rs b/packages/aead-napi/examples/kind_inventory.rs index 9bcd63cb..64c7cb64 100644 --- a/packages/aead-napi/examples/kind_inventory.rs +++ b/packages/aead-napi/examples/kind_inventory.rs @@ -1,6 +1,6 @@ //! Test addon loaded by `tests/kind_inventory.cjs` in a live Node environment. -use napi::bindgen_prelude::{Object, ToNapiValue}; +use napi::bindgen_prelude::{JsValue, Object}; use napi::{sys, Env, Error, JsError, Result, Status}; use vitaminc_aead_napi::{NapiValue, Value}; use vitaminc_aead_value::ValueKind; @@ -61,7 +61,7 @@ fn convert_samples(env: sys::napi_env) -> Result { "passthrough", NapiValue(Value::Passthrough(Box::new(Value::Bool(true)))), )?; - unsafe { Object::to_napi_value(env, exports) } + Ok(exports.raw()) } /// Load the test samples through the same conversion used by consumer addons. diff --git a/packages/aead-napi/src/ciphertext.rs b/packages/aead-napi/src/ciphertext.rs index e0b286d5..979bad0f 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -26,7 +26,7 @@ //! have persisted it), but it is not a cross-language wire commitment; //! only the leaf byte format (see `vitaminc-aead-value`) is frozen. -use napi::bindgen_prelude::{Array, Buffer, FromNapiValue, Object, ToNapiValue, Unknown}; +use napi::bindgen_prelude::{Array, Buffer, FromNapiValue, JsValue, Object, ToNapiValue, Unknown}; use napi::{sys, Env, Error, Result, Status, ValueType}; use vitaminc_aead::CipherText; @@ -57,13 +57,12 @@ fn shape_error() -> Error { Error::new(Status::InvalidArg, "malformed ciphertext value") } -fn node_to_js(env: sys::napi_env, node: CipherText) -> Result +fn node_to_js(env: &Env, node: CipherText) -> Result> where Leaf: AsRef<[u8]>, P: ToNapiValue, { - let raw_env = Env::from_raw(env); - let mut obj = Object::new(&raw_env)?; + let mut obj = Object::new(env)?; match node { CipherText::Single(leaf) => { obj.set(T_KEY, T_CIPHERTEXT)?; @@ -83,15 +82,15 @@ where } CipherText::Sequence(items) => { obj.set(T_KEY, T_SEQ)?; - let mut arr = raw_env.create_array(items.len() as u32)?; + let mut arr = env.create_array(items.len() as u32)?; for (i, item) in items.into_iter().enumerate() { - arr.set(i as u32, NodeHandle(item))?; + arr.set(i as u32, node_to_js(env, item)?)?; } obj.set(V_KEY, arr)?; } CipherText::Map(entries) => { obj.set(T_KEY, T_MAP)?; - let map = Object::new(&raw_env)?; + let mut map = Object::new(env)?; for (key, value) in entries { // Map keys travel in the clear inside the stored ciphertext // and are therefore attacker-writable; never assign @@ -106,50 +105,28 @@ where // attacker-writable, so a polluted inherited setter must // never run — see `convert::define_own_property`. let js_value = node_to_js(env, value)?; - define_own_property(&map, &key, js_value)?; + define_own_property(&mut map, &key, &js_value)?; } obj.set(V_KEY, map)?; } CipherText::Passthrough(p) => { obj.set(T_KEY, T_PASSTHROUGH)?; - obj.set(V_KEY, PassthroughHandle(p))?; + obj.set(V_KEY, p)?; } } - unsafe { Object::to_napi_value(env, obj) } + Ok(obj) } const T_KEY: &str = "t"; const V_KEY: &str = "v"; -/// Recursion helper so nested nodes can be assigned through `ToNapiValue`. -struct NodeHandle(CipherText); - -impl ToNapiValue for NodeHandle -where - Leaf: AsRef<[u8]>, - P: ToNapiValue, -{ - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - node_to_js(env, val.0) - } -} - -/// Recursion helper for the passthrough payload. -struct PassthroughHandle

(P); - -impl ToNapiValue for PassthroughHandle

{ - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - unsafe { P::to_napi_value(env, val.0) } - } -} - impl ToNapiValue for JsCipherText where Leaf: AsRef<[u8]>, P: ToNapiValue, { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - node_to_js(env, val.0) + Ok(node_to_js(&Env::from_raw(env), val.0)?.raw()) } } @@ -238,6 +215,10 @@ where P: FromNapiValue, { unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result { + // SAFETY: napi-rs calls this with the `env` and `napi_val` of the + // current call, which is the contract `Object::from_napi_value` asks + // for; it checks the value is an object and errors otherwise. The + // handle does not outlive this call. let obj = unsafe { Object::from_napi_value(env, napi_val) }?; Ok(JsCipherText(node_from_js(&obj, 0)?)) } diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index adcc97d6..12c02854 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -19,10 +19,10 @@ //! decrypted from data another language wrote as an ordinary object. use napi::bindgen_prelude::{ - Array, BigInt, Buffer, FromNapiValue, JsObjectValue, JsValue, Object, ToNapiValue, Uint8Array, - Unknown, Utf16String, + Array, BigInt, Buffer, FromNapiValue, JsObjectValue, JsValue, KeyCollectionMode, KeyConversion, + KeyFilter, Null, Object, ToNapiValue, Uint8Array, Unknown, Utf16String, }; -use napi::{sys, Env, Error, Result, Status, ValueType}; +use napi::{sys, Env, Error, Property, PropertyAttributes, Result, Status, ValueType}; use vitaminc_aead_value::Value; use vitaminc_protected::{Controlled, Protected}; @@ -75,31 +75,21 @@ pub(crate) fn eager_capacity(reported_len: u32) -> usize { /// keys into the result, and inherited getters would run when the values /// are read. Own-only enumeration forecloses both. pub(crate) fn own_enumerable_keys(obj: &Object<'_>) -> Result> { - let raw = obj.value(); - let mut names = std::ptr::null_mut(); - let status = unsafe { - sys::napi_get_all_property_names( - raw.env, - raw.value, - sys::KeyCollectionMode::own_only, - sys::KeyFilter::enumerable | sys::KeyFilter::skip_symbols, - sys::KeyConversion::numbers_to_strings, - &mut names, - ) - }; - if status != sys::Status::napi_ok { - return Err(Error::new( - Status::GenericFailure, - "failed to enumerate object properties", - )); - } - let names = unsafe { Array::from_napi_value(raw.env, names) }?; - let mut keys = Vec::with_capacity(eager_capacity(names.len())); - for i in 0..names.len() { - let key = names.get::(i)?.ok_or_else(|| { - Error::new(Status::GenericFailure, "property name vanished during read") - })?; - keys.push(key); + // napi-rs takes a single key filter, so symbol keys are skipped below + // rather than by an `enumerable | skip_symbols` filter. + let names = obj.get_all_property_names( + KeyCollectionMode::OwnOnly, + KeyFilter::Enumerable, + KeyConversion::NumbersToStrings, + )?; + let len = names.get_array_length()?; + let mut keys = Vec::with_capacity(eager_capacity(len)); + for i in 0..len { + let name = names.get_element::(i)?; + if name.get_type()? == ValueType::Symbol { + continue; + } + keys.push(String::from_unknown(name)?); } Ok(keys) } @@ -111,31 +101,11 @@ pub(crate) fn own_enumerable_keys(obj: &Object<'_>) -> Result> { /// missing property also comes back as `undefined`, which is exactly JS /// property semantics. pub(crate) fn get_property_unknown<'e>(obj: &Object<'e>, key: &str) -> Result> { - let raw = obj.value(); - let mut js_key = std::ptr::null_mut(); - let status = unsafe { - sys::napi_create_string_utf8( - raw.env, - key.as_ptr().cast(), - key.len() as isize, - &mut js_key, - ) - }; - if status != sys::Status::napi_ok { - return Err(Error::new( - Status::GenericFailure, - "failed to create property key", - )); - } - let mut value = std::ptr::null_mut(); - let status = unsafe { sys::napi_get_property(raw.env, raw.value, js_key, &mut value) }; - if status != sys::Status::napi_ok { - return Err(Error::new( - Status::GenericFailure, - "failed to read object property", - )); - } - unsafe { Unknown::from_napi_value(raw.env, value) } + // The key goes over as a JS string with an explicit length, so a key + // holding NUL survives (`get_named_property` would refuse it). + let env = Env::from_raw(obj.value().env); + let js_key = key.into_unknown(&env)?; + obj.get_property_unchecked(js_key) } /// Accept only plain objects — prototype `Object.prototype` (compared @@ -184,47 +154,22 @@ fn ensure_plain_object(obj: &Object<'_>) -> Result<()> { /// property (drop). The intake side already treats prototype pollution as /// in-threat-model (`own_enumerable_keys`, `ensure_plain_object`); this /// closes the output side. -pub(crate) fn define_own_property( - obj: &Object<'_>, +pub(crate) fn define_own_property<'e, V: JsValue<'e>>( + obj: &mut Object<'_>, key: &str, - value: sys::napi_value, + value: &V, ) -> Result<()> { - let raw = obj.value(); - let mut js_key = std::ptr::null_mut(); - let status = unsafe { - sys::napi_create_string_utf8( - raw.env, - key.as_ptr().cast(), - key.len() as isize, - &mut js_key, - ) - }; - if status != sys::Status::napi_ok { - return Err(Error::new( - Status::GenericFailure, - "failed to create property key", - )); - } - let descriptor = sys::napi_property_descriptor { - utf8name: std::ptr::null(), - name: js_key, - method: None, - getter: None, - setter: None, - value, - attributes: sys::PropertyAttributes::writable - | sys::PropertyAttributes::enumerable - | sys::PropertyAttributes::configurable, - data: std::ptr::null_mut(), - }; - let status = unsafe { sys::napi_define_properties(raw.env, raw.value, 1, &descriptor) }; - if status != sys::Status::napi_ok { - return Err(Error::new( - Status::GenericFailure, - "failed to define object property", - )); - } - Ok(()) + let env = Env::from_raw(obj.value().env); + let property = Property::new() + // A JS string name, not a UTF-8 C string, so a key holding NUL works. + .with_name(&env, key)? + .with_value(value) + .with_property_attributes( + PropertyAttributes::Writable + | PropertyAttributes::Enumerable + | PropertyAttributes::Configurable, + ); + obj.define_properties(&[property]) } fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { @@ -345,31 +290,33 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { impl FromNapiValue for NapiValue { unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result { + // SAFETY: napi-rs calls this with the `env` and `napi_val` of the + // current call, which is the contract `Unknown::from_napi_value` + // asks for. Wrapping as `Unknown` accepts any value type, and the + // result does not outlive this call. let unknown = unsafe { Unknown::from_napi_value(env, napi_val) }?; js_to_value(unknown, 0).map(NapiValue) } } -fn value_to_js(env: sys::napi_env, value: Value) -> Result { +fn value_to_js(env: &Env, value: Value) -> Result> { match value { - Value::Null => unsafe { - napi::bindgen_prelude::Null::to_napi_value(env, napi::bindgen_prelude::Null) - }, - Value::Undefined => unsafe { <()>::to_napi_value(env, ()) }, - Value::Bool(b) => unsafe { bool::to_napi_value(env, b) }, + Value::Null => Null.into_unknown(env), + Value::Undefined => ().into_unknown(env), + Value::Bool(b) => b.into_unknown(env), // Both float widths surface as a JS `number`; `f32` widens exactly. - Value::Float64(n) => unsafe { f64::to_napi_value(env, n) }, - Value::Float32(f) => unsafe { f64::to_napi_value(env, f64::from(f)) }, - Value::Int8(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, - Value::UInt8(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, - Value::Int16(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, - Value::UInt16(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, - Value::Int32(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(i128::from(v))) }, - Value::UInt32(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(u128::from(v))) }, - Value::Int64(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, - Value::UInt64(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, - Value::Int128(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, - Value::UInt128(v) => unsafe { BigInt::to_napi_value(env, BigInt::from(v)) }, + Value::Float64(n) => n.into_unknown(env), + Value::Float32(f) => f64::from(f).into_unknown(env), + Value::Int8(v) => BigInt::from(i128::from(v)).into_unknown(env), + Value::UInt8(v) => BigInt::from(u128::from(v)).into_unknown(env), + Value::Int16(v) => BigInt::from(i128::from(v)).into_unknown(env), + Value::UInt16(v) => BigInt::from(u128::from(v)).into_unknown(env), + Value::Int32(v) => BigInt::from(i128::from(v)).into_unknown(env), + Value::UInt32(v) => BigInt::from(u128::from(v)).into_unknown(env), + Value::Int64(v) => BigInt::from(v).into_unknown(env), + Value::UInt64(v) => BigInt::from(v).into_unknown(env), + Value::Int128(v) => BigInt::from(v).into_unknown(env), + Value::UInt128(v) => BigInt::from(v).into_unknown(env), Value::Date(v) => wrapper_to_js(env, "date", v.to_string()), Value::Decimal(v) => wrapper_to_js(env, "decimal", v.to_string()), Value::Timestamp(v) => { @@ -378,10 +325,7 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { if v.timestamp_subsec_nanos() < 1_000_000_000 && v.timestamp_subsec_nanos() % 1_000_000 == 0 { - Ok(Env::from_raw(env) - .create_date(v.timestamp_millis() as f64)? - .value() - .value) + Ok(env.create_date(v.timestamp_millis() as f64)?.to_unknown()) } else { wrapper_to_js( env, @@ -395,26 +339,20 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { // and the JS-side conversion produce valid UTF-8). let utf8 = std::str::from_utf8(s.risky_ref()) .map_err(|_| Error::new(Status::GenericFailure, "invalid UTF-8 string value"))?; - unsafe { <&str>::to_napi_value(env, utf8) } + utf8.into_unknown(env) // `s` drops (and wipes the Rust copy) here; the JS copy is owned // by the engine. } - Value::Bytes(b) => { - let buf = Buffer::from(b.risky_ref().to_vec()); - unsafe { Buffer::to_napi_value(env, buf) } - } + Value::Bytes(b) => Buffer::from(b.risky_ref().to_vec()).into_unknown(env), Value::Array(items) => { - let raw_env = Env::from_raw(env); - let mut arr = raw_env.create_array(items.len() as u32)?; + let mut arr = env.create_array(items.len() as u32)?; for (i, item) in items.into_iter().enumerate() { - let js = ValueHandle(item); - arr.set(i as u32, js)?; + arr.set(i as u32, value_to_js(env, item)?)?; } - unsafe { Array::to_napi_value(env, arr) } + arr.into_unknown(env) } Value::Object(entries) => { - let raw_env = Env::from_raw(env); - let obj = Object::new(&raw_env)?; + let mut obj = Object::new(env)?; for (key, value) in entries { // Keys decrypted from a ciphertext are attacker-influenced // in principle; never assign prototype-polluting names. @@ -422,9 +360,9 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { return Err(forbidden_key_error(&key)); } let js_value = value_to_js(env, value)?; - define_own_property(&obj, &key, js_value)?; + define_own_property(&mut obj, &key, &js_value)?; } - unsafe { Object::to_napi_value(env, obj) } + obj.into_unknown(env) } // A passthrough subtree projects onto its plain JS value: the marking // exists to steer encryption, and once decrypted the field is an @@ -463,28 +401,16 @@ fn conversion_error(env: Env, error: scalar::ConversionError) -> Error { } } -fn wrapper_to_js(env: sys::napi_env, key: &str, text: String) -> Result { - let raw_env = Env::from_raw(env); - let obj = Object::new(&raw_env)?; - let value = unsafe { String::to_napi_value(env, text) }?; - define_own_property(&obj, key, value)?; - unsafe { Object::to_napi_value(env, obj) } -} - -/// Internal newtype so recursive positions (array elements, object values) -/// can go through `ToNapiValue` without exposing a blanket recursive impl -/// signature difference. -struct ValueHandle(Value); - -impl ToNapiValue for ValueHandle { - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - value_to_js(env, val.0) - } +fn wrapper_to_js<'e>(env: &'e Env, key: &str, text: String) -> Result> { + let mut obj = Object::new(env)?; + let value = text.into_unknown(env)?; + define_own_property(&mut obj, key, &value)?; + obj.into_unknown(env) } impl ToNapiValue for NapiValue { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - value_to_js(env, val.0) + Ok(value_to_js(&Env::from_raw(env), val.0)?.raw()) } } diff --git a/packages/aead-napi/src/lib.rs b/packages/aead-napi/src/lib.rs index cd986719..9bb15c0e 100644 --- a/packages/aead-napi/src/lib.rs +++ b/packages/aead-napi/src/lib.rs @@ -1,4 +1,8 @@ #![deny(clippy::unwrap_used, clippy::todo, unused_imports)] +// The only unsafe left is where napi-rs's conversion traits require it. +// Each block has to say why it is sound, and unsafe calls inside those trait +// methods still need their own block. +#![deny(unsafe_op_in_unsafe_fn, clippy::undocumented_unsafe_blocks)] #![doc = include_str!("../README.md")] mod ciphertext; mod convert; From de1fc15a5a38ee033a307347fa3f0dac008f9781 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 20:49:56 +1100 Subject: [PATCH 24/28] test(aead-napi): gate the N-API conversions on mutation testing The conversion functions were exempt from the mutants gate because no Rust test could reach them. tests/node.rs now drives them through Node, rebuilding the test addon from the mutated source, so drop the exemption. Of 77 mutants, 14 survived at first; new tests cover the nesting limits in both directions and for ciphertext trees, own property attributes, a millisecond-aligned leap second, and passthrough values. The property attributes are built with union rather than |, so no equivalent | to ^ mutant arises. All mutants are now caught or unviable. The CRAP exemption stays: llvm-cov cannot see into the addon. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- .cargo-crap.toml | 16 +++++----- .cargo/mutants.toml | 19 ------------ packages/aead-napi/README.md | 10 +++--- packages/aead-napi/src/convert.rs | 11 ++++--- packages/aead-napi/tests/addon/src/lib.rs | 7 +++++ packages/aead-napi/tests/object-safety.cjs | 36 ++++++++++++++++++++++ 6 files changed, 62 insertions(+), 37 deletions(-) diff --git a/.cargo-crap.toml b/.cargo-crap.toml index 40fb4c50..41811ec7 100644 --- a/.cargo-crap.toml +++ b/.cargo-crap.toml @@ -54,16 +54,14 @@ exclude = ["tests/**", "src/test_support.rs"] # * The `vitaminc-aead-napi` conversion functions and their JS-boundary # helpers (`own_enumerable_keys`, `get_property_unknown`, # `ensure_plain_object`, `wrapper_text`): each takes a live `napi_env`/`Unknown`/`Object`, -# which only exists inside a running V8 isolate. `cargo test` has no Node -# host (the `napi/noop` dev-dependency stubs the symbols so the crate -# *links*, it does not make the calls work), so these cannot be reached -# from a Rust unit test at any coverage level. They are named individually +# which only exists inside a running V8 isolate. The Node suites run by +# `tests/node.rs` do exercise them, but inside a separately built test +# addon loaded by `node`, which llvm-cov does not instrument; the +# `napi/noop` dev-dependency only lets the crate itself *link* for unit +# tests. So llvm-cov always reads them as 0%. They are named individually # rather than by file so the rest of those modules — `forbidden_key`, -# `eager_capacity` and friends — stays scored. -# -# NOTE: this is a deferral, not a dismissal. The conversion layer still -# needs JS-level tests driving a built addon; see the tracking note in -# packages/aead-napi/README.md. +# `eager_capacity` and friends — stays scored. The mutation gate does not +# exempt them: it rebuilds that addon from the mutated source. allow = [ "**/random-derives/**", "**/protected-derive/**", diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 93d47c79..5d328f82 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -37,26 +37,7 @@ exclude_globs = [ "packages/protected/src/locked/proofs.rs", ] -# Skip mutants inside the `vitaminc-aead-napi` conversion functions and their -# JS-boundary helpers (`own_enumerable_keys`, `get_property_unknown`, -# `ensure_plain_object`). Each takes a live `napi_env`/`Unknown`/`Object`, -# which only exists inside a running V8 isolate, so no Rust unit test can reach -# them: every mutant there survives because the function is unreachable from -# `cargo test`, not because a test is missing. Matched by name (regex against -# `cargo mutants --list`) rather than by file, so the testable parts of those -# modules — `forbidden_key`, `eager_capacity` and friends — stay gated. -# -# NOTE: a deferral, not a dismissal. This layer needs JS-level tests driving a -# built addon; see the tracking note in packages/aead-napi/README.md. exclude_re = [ - "js_to_value", - "value_to_js", - "node_to_js", - "node_from_js", - "own_enumerable_keys", - "define_own_property", - "get_property_unknown", - "ensure_plain_object", # Equivalent mutants, not missing tests: `widen_f32` (vitaminc-aead-value # canonical.rs) assembles an f64 by OR-ing bit fields that are disjoint by # construction — complementary masks, or fields shifted into separate bit diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index 6c400843..dded9135 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -83,10 +83,12 @@ The JS-boundary conversion functions — `js_to_value`, `value_to_js`, `node_to_js`, `node_from_js` — and their helpers — `own_enumerable_keys`, `get_property_unknown`, `ensure_plain_object`, `define_own_property`, `wrapper_text` — take a live `napi_env`, `Unknown`, or `Object`, which only -exists inside a running V8 isolate, so Rust unit tests cannot reach them. They -remain exempted by name in `.cargo-crap.toml` and `.cargo/mutants.toml`; new -pure scalar conversion helpers participate in coverage and mutation gates. The -Node conformance test runs in package-scoped mutation tests as well as CI. +exists inside a running V8 isolate, so Rust unit tests cannot reach them. The +Node suites do: `tests/node.rs` rebuilds the test addon from the crate's +source, so the mutation gate tests these functions like any other code. Only +the CRAP gate still exempts them, by name in `.cargo-crap.toml`, because they +run inside the separately built addon, where `cargo llvm-cov` cannot measure +their coverage. ## Safety notes diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index 12c02854..5b1bd4cb 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -154,6 +154,11 @@ fn ensure_plain_object(obj: &Object<'_>) -> Result<()> { /// property (drop). The intake side already treats prototype pollution as /// in-threat-model (`own_enumerable_keys`, `ensure_plain_object`); this /// closes the output side. +/// The attributes `obj.key = value` gives a new property. +const OWN_DATA_PROPERTY: PropertyAttributes = PropertyAttributes::Writable + .union(PropertyAttributes::Enumerable) + .union(PropertyAttributes::Configurable); + pub(crate) fn define_own_property<'e, V: JsValue<'e>>( obj: &mut Object<'_>, key: &str, @@ -164,11 +169,7 @@ pub(crate) fn define_own_property<'e, V: JsValue<'e>>( // A JS string name, not a UTF-8 C string, so a key holding NUL works. .with_name(&env, key)? .with_value(value) - .with_property_attributes( - PropertyAttributes::Writable - | PropertyAttributes::Enumerable - | PropertyAttributes::Configurable, - ); + .with_property_attributes(OWN_DATA_PROPERTY); obj.define_properties(&[property]) } diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs index 90cd8677..fcff2351 100644 --- a/packages/aead-napi/tests/addon/src/lib.rs +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -12,6 +12,13 @@ pub fn encode(value: NapiValue) -> napi::Result { Ok(Buffer::from(bytes.risky_ref().to_vec())) } +/// Convert a JS value to a Rust value and drop it, to test the conversion +/// alone, without the transport encoder's own limits. +#[napi] +pub fn convert(value: NapiValue) { + drop(value); +} + #[napi] pub fn decode(bytes: Buffer) -> napi::Result { decode_value(&mut Reader::new(&bytes)) diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs index cb441d90..456f7bd6 100644 --- a/packages/aead-napi/tests/object-safety.cjs +++ b/packages/aead-napi/tests/object-safety.cjs @@ -50,6 +50,12 @@ try { delete Object.prototype.leak; } +// Output properties are writable, enumerable and configurable, like ones +// created by assignment. +assert.deepEqual(Object.getOwnPropertyDescriptor(roundTrip({ k: 1 }), 'k'), { + value: 1, writable: true, enumerable: true, configurable: true, +}); + // Prototype-touching keys are refused. assert.throws(() => addon.encode(JSON.parse('{"__proto__": 1}'))); assert.throws(() => addon.encode({ constructor: 1 })); @@ -87,3 +93,33 @@ try { delete Object.prototype.own; } assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v: JSON.parse('{"__proto__": {"t":"ct","v":"x"}}') })); + +// Nesting is limited to 128 levels below the root, in both directions of +// the value conversion and when reading a ciphertext tree. +const MAX_DEPTH = 128; +const nest = (levels, wrap, leaf) => { + let v = leaf; + for (let i = 0; i < levels; i++) v = wrap(v); + return v; +}; +for (const wrap of [(v) => [v], (v) => ({ k: v })]) { + addon.convert(nest(MAX_DEPTH, wrap, 1)); + assert.throws(() => addon.convert(nest(MAX_DEPTH + 1, wrap, 1)), /nested too deeply/); +} +for (const wrap of [(v) => ({ t: 'seq', v: [v] }), (v) => ({ t: 'map', v: { k: v } })]) { + const deepest = nest(MAX_DEPTH, wrap, { t: 'ct', v: leaf }); + assert.deepEqual(addon.ciphertextRoundTrip(deepest), deepest); + assert.throws( + () => addon.ciphertextRoundTrip(nest(MAX_DEPTH + 1, wrap, { t: 'ct', v: leaf })), + /nested too deeply/, + ); +} + +// A leap second cannot be a JS Date, even when it is millisecond-aligned. +assert.deepEqual(roundTrip({ timestamp: '2016-12-31T23:59:60Z' }), { + timestamp: '2016-12-31T23:59:60.000000000Z', +}); + +// A passthrough value decodes to its plain JS value, at the root and nested. +assert.equal(addon.decode(Buffer.from('f203', 'hex')), true); +assert.deepEqual(addon.decode(Buffer.from('f0010000' + '00f20a0100000061', 'hex')), ['a']); From 3b5880e06e7d3b3ad77a857efbceef8ce4741d0f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:36:11 +1100 Subject: [PATCH 25/28] fix(aead-napi): close prototype and lossy-key leaks at the N-API boundary Review of #386 found boundary bugs, reproduced in Node: - Decrypted array elements, and a ciphertext node's t, v and sequence elements, were written with [[Set]], so a polluted setter on Array.prototype or Object.prototype received the plaintext or ciphertext and the result lost the property. Every property this crate builds is now an own-property define, one define call per object. - A key holding an unpaired surrogate was read as U+FFFD, so the lookup missed and the value sealed as undefined. Such keys are now refused, as string values are, and each property is read through its original JS key handle. - A primitive was accepted as a ciphertext node: napi-rs's Object conversion does not check the type, and N-API reads a primitive's properties through its prototype. Nodes are now checked to be objects, and the SAFETY comment that claimed otherwise is corrected. - Building JS from a Rust tree had no depth limit, so a deep tree built in Rust could overflow the stack. Both directions now stop at 128 levels, counted as the transport decoder counts them. Also fixes a test that could not fail, restores define_own_properties' documentation, and drops the redundant attribute constant: napi-rs's default attributes are the ones assignment gives. The Node suite gains a test for each bug, and every mutant in the conversion code is caught. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- packages/aead-napi/README.md | 28 ++-- packages/aead-napi/src/ciphertext.rs | 123 ++++++++------- packages/aead-napi/src/convert.rs | 168 +++++++++++++-------- packages/aead-napi/tests/addon/Cargo.lock | 1 + packages/aead-napi/tests/addon/Cargo.toml | 1 + packages/aead-napi/tests/addon/src/lib.rs | 39 ++++- packages/aead-napi/tests/object-safety.cjs | 82 +++++++++- 7 files changed, 305 insertions(+), 137 deletions(-) diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index dded9135..a971538a 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -100,8 +100,10 @@ their coverage. - The original copies of encrypted values in the V8 heap are owned by the JS engine and cannot be wiped from Rust. - Property names that would touch the prototype chain (`__proto__`, - `constructor`, `prototype`) are rejected in both directions, and - recursion depth is bounded. + `constructor`, `prototype`) are rejected in both directions. Nesting is + limited to 128 levels in both directions too, counted as the transport + decoder counts it, so a tree built in Rust fails cleanly instead of + overflowing the stack. - Only **plain objects** (prototype `Object.prototype` or `null`) are accepted for encryption. `Map`, `Set`, `RegExp`, `DataView`, class instances and other exotic objects keep their state in internal slots and @@ -117,14 +119,20 @@ their coverage. - JS-reported array lengths are never trusted for up-front allocation (a `new Array(2**32 - 1)` costs the attacker one line and materialises nothing), in both the value converter and the ciphertext rebuilder. -- Strings containing unpaired UTF-16 surrogates are **rejected** rather - than silently normalized: the UTF-8 fetch would replace a lone surrogate - with U+FFFD, sealing a plaintext that no longer equals what the caller - passed. -- Decrypted objects (and rebuilt ciphertext maps) are written with - **own-property defines** (`napi_define_properties`), not `[[Set]]` - assignment, so a polluted `Object.prototype` setter can never observe - decrypted plaintext or swallow a property. +- Strings and object keys containing unpaired UTF-16 surrogates are + **rejected** rather than silently normalized: the UTF-8 fetch would + replace a lone surrogate with U+FFFD, sealing a plaintext that no longer + equals what the caller passed, or a key that no longer names its + property. Each property is read back through its original JS key. +- Everything this crate builds in JS is written with **own-property + defines** (`napi_define_properties`, one call per object), not `[[Set]]` + assignment: decrypted object properties and array elements, and every + ciphertext node's `t`, `v`, sequence elements and map entries. A polluted + setter on `Object.prototype` or `Array.prototype` can never observe + decrypted plaintext or ciphertext, or swallow a property. +- A ciphertext node handed back for decryption must be an object. N-API + reads properties of a primitive through its wrapper, so a bare string + would otherwise take `t` and `v` from `String.prototype`. - Errors carry no cryptographic detail (`Unspecified` at the trait layer). [`Value`]: vitaminc_aead_value::Value diff --git a/packages/aead-napi/src/ciphertext.rs b/packages/aead-napi/src/ciphertext.rs index 979bad0f..50271131 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -31,8 +31,8 @@ use napi::{sys, Env, Error, Result, Status, ValueType}; use vitaminc_aead::CipherText; use crate::convert::{ - define_own_property, eager_capacity, forbidden_key, get_property_unknown, own_enumerable_keys, - MAX_DEPTH, + define_own_properties, eager_capacity, forbidden_key, get_own, get_property_unknown, new_array, + own_enumerable_keys, own_property, MAX_DEPTH, }; const T_CIPHERTEXT: &str = "ct"; @@ -53,44 +53,43 @@ const T_PASSTHROUGH: &str = "pt"; /// the tree first with [`CipherText::map_passthrough`]. pub struct JsCipherText(pub CipherText); +fn depth_error() -> Error { + Error::new(Status::InvalidArg, "ciphertext is nested too deeply") +} + fn shape_error() -> Error { Error::new(Status::InvalidArg, "malformed ciphertext value") } -fn node_to_js(env: &Env, node: CipherText) -> Result> +fn node_to_js(env: &Env, node: CipherText, depth: usize) -> Result> where Leaf: AsRef<[u8]>, P: ToNapiValue, { - let mut obj = Object::new(env)?; - match node { - CipherText::Single(leaf) => { - obj.set(T_KEY, T_CIPHERTEXT)?; - obj.set(V_KEY, Buffer::from(leaf.as_ref().to_vec()))?; - } - CipherText::None(leaf) => { - obj.set(T_KEY, T_NONE)?; - obj.set(V_KEY, Buffer::from(leaf.as_ref().to_vec()))?; - } - CipherText::EmptySequence(leaf) => { - obj.set(T_KEY, T_EMPTY_SEQ)?; - obj.set(V_KEY, Buffer::from(leaf.as_ref().to_vec()))?; - } - CipherText::EmptyMap(leaf) => { - obj.set(T_KEY, T_EMPTY_MAP)?; - obj.set(V_KEY, Buffer::from(leaf.as_ref().to_vec()))?; - } + if depth > MAX_DEPTH { + return Err(depth_error()); + } + let leaf = |tag: &'static str, leaf: Leaf| -> Result<(&'static str, Unknown<'_>)> { + Ok((tag, Buffer::from(leaf.as_ref().to_vec()).into_unknown(env)?)) + }; + let (tag, value) = match node { + CipherText::Single(l) => leaf(T_CIPHERTEXT, l)?, + CipherText::None(l) => leaf(T_NONE, l)?, + CipherText::EmptySequence(l) => leaf(T_EMPTY_SEQ, l)?, + CipherText::EmptyMap(l) => leaf(T_EMPTY_MAP, l)?, CipherText::Sequence(items) => { - obj.set(T_KEY, T_SEQ)?; - let mut arr = env.create_array(items.len() as u32)?; + let mut arr = new_array(env, items.len())?; + let mut elements = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { - arr.set(i as u32, node_to_js(env, item)?)?; + let node = node_to_js(env, item, depth + 1)?; + elements.push(own_property(env, &i.to_string(), &node)?); } - obj.set(V_KEY, arr)?; + define_own_properties(&mut arr, &elements)?; + (T_SEQ, arr.into_unknown(env)?) } CipherText::Map(entries) => { - obj.set(T_KEY, T_MAP)?; let mut map = Object::new(env)?; + let mut properties = Vec::with_capacity(entries.len()); for (key, value) in entries { // Map keys travel in the clear inside the stored ciphertext // and are therefore attacker-writable; never assign @@ -101,19 +100,25 @@ where format!("ciphertext map key is not allowed: {key}"), )); } - // Own-property define, not `[[Set]]`: these keys are - // attacker-writable, so a polluted inherited setter must - // never run — see `convert::define_own_property`. - let js_value = node_to_js(env, value)?; - define_own_property(&mut map, &key, &js_value)?; + let node = node_to_js(env, value, depth + 1)?; + properties.push(own_property(env, &key, &node)?); } - obj.set(V_KEY, map)?; + define_own_properties(&mut map, &properties)?; + (T_MAP, map.into_unknown(env)?) } - CipherText::Passthrough(p) => { - obj.set(T_KEY, T_PASSTHROUGH)?; - obj.set(V_KEY, p)?; - } - } + CipherText::Passthrough(p) => (T_PASSTHROUGH, p.into_unknown(env)?), + }; + // Own properties throughout, so a polluted inherited `t`, `v` or index + // setter never sees the ciphertext (see `define_own_properties`). + let mut obj = Object::new(env)?; + let tag = tag.into_unknown(env)?; + define_own_properties( + &mut obj, + &[ + own_property(env, T_KEY, &tag)?, + own_property(env, V_KEY, &value)?, + ], + )?; Ok(obj) } @@ -126,20 +131,28 @@ where P: ToNapiValue, { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - Ok(node_to_js(&Env::from_raw(env), val.0)?.raw()) + Ok(node_to_js(&Env::from_raw(env), val.0, 0)?.raw()) } } +/// A node must be a JS object. napi-rs's `Object` conversion does not check +/// the type, and N-API reads properties of a primitive through its wrapper +/// object, so a bare string would otherwise read `t` and `v` from a +/// (possibly polluted) `String.prototype`. +fn node_object(value: Unknown<'_>) -> Result> { + if value.get_type()? != ValueType::Object { + return Err(shape_error()); + } + Object::from_unknown(value) +} + fn node_from_js(obj: &Object<'_>, depth: usize) -> Result> where Leaf: From>, P: FromNapiValue, { if depth > MAX_DEPTH { - return Err(Error::new( - Status::InvalidArg, - "ciphertext is nested too deeply", - )); + return Err(depth_error()); } let t: String = obj.get(T_KEY)?.ok_or_else(shape_error)?; match t.as_str() { @@ -166,16 +179,12 @@ where // hole comes back as `undefined`, which is not a node // object — check the type before treating it as one. let element = arr.get::(i)?.ok_or_else(shape_error)?; - if element.get_type()? != ValueType::Object { - return Err(shape_error()); - } - let element = Object::from_unknown(element)?; - items.push(node_from_js(&element, depth + 1)?); + items.push(node_from_js(&node_object(element)?, depth + 1)?); } Ok(CipherText::Sequence(items)) } T_MAP => { - let map: Object = obj.get(V_KEY)?.ok_or_else(shape_error)?; + let map = node_object(get_property_unknown(obj, V_KEY)?)?; // Own keys only — `Object::keys` walks the prototype chain, so // a hostile node object with enumerable prototype members would // otherwise have its inherited keys become ciphertext map @@ -183,14 +192,14 @@ where let keys = own_enumerable_keys(&map)?; let mut entries = Vec::with_capacity(keys.len()); for key in keys { - if forbidden_key(&key) { + if forbidden_key(&key.name) { return Err(Error::new( Status::InvalidArg, - format!("ciphertext map key is not allowed: {key}"), + format!("ciphertext map key is not allowed: {}", key.name), )); } - let value: Object = map.get(&key)?.ok_or_else(shape_error)?; - entries.push((key, node_from_js(&value, depth + 1)?)); + let value = node_object(get_own(&map, &key)?)?; + entries.push((key.name, node_from_js(&value, depth + 1)?)); } Ok(CipherText::Map(entries)) } @@ -216,10 +225,12 @@ where { unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result { // SAFETY: napi-rs calls this with the `env` and `napi_val` of the - // current call, which is the contract `Object::from_napi_value` asks - // for; it checks the value is an object and errors otherwise. The - // handle does not outlive this call. - let obj = unsafe { Object::from_napi_value(env, napi_val) }?; + // current call, which is all `Unknown::from_napi_value` needs: an + // `Unknown` may hold any value type, and `node_object` checks it is + // an object before anything reads it as one. The handle does not + // outlive this call. + let value = unsafe { Unknown::from_napi_value(env, napi_val) }?; + let obj = node_object(value)?; Ok(JsCipherText(node_from_js(&obj, 0)?)) } } diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index 5b1bd4cb..2e0b0956 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -22,16 +22,17 @@ use napi::bindgen_prelude::{ Array, BigInt, Buffer, FromNapiValue, JsObjectValue, JsValue, KeyCollectionMode, KeyConversion, KeyFilter, Null, Object, ToNapiValue, Uint8Array, Unknown, Utf16String, }; -use napi::{sys, Env, Error, Property, PropertyAttributes, Result, Status, ValueType}; +use napi::{sys, Env, Error, Property, Result, Status, ValueType}; use vitaminc_aead_value::Value; use vitaminc_protected::{Controlled, Protected}; use crate::scalar; use crate::value::NapiValue; -/// Maximum nesting depth accepted when converting a JS value tree (and when -/// rebuilding one). Bounds recursion so a hostile deeply-nested input cannot -/// overflow the stack. +/// Maximum nesting depth, in both directions: converting a JS value tree to +/// Rust, and building one from a Rust tree. Bounds recursion so a deeply +/// nested input cannot overflow the stack. The same limit, counted the same +/// way, as the transport decoder's, so any decoded tree converts. pub(crate) const MAX_DEPTH: usize = 128; /// Property names that must never be read from or written onto a plain JS @@ -66,7 +67,14 @@ pub(crate) fn eager_capacity(reported_len: u32) -> usize { (reported_len as usize).min(MAX_EAGER_CAPACITY) } -/// Own, enumerable, string-keyed property names of `obj`. +/// An own property key: the JS key itself, for reading the property back +/// exactly, and its text. +pub(crate) struct OwnKey<'e> { + handle: Unknown<'e>, + pub(crate) name: String, +} + +/// Own, enumerable, string-keyed properties of `obj`. /// /// `Object::keys` (`napi_get_property_names`) walks the prototype chain — /// per the Node-API spec it equals `napi_get_all_property_names` with @@ -74,7 +82,10 @@ pub(crate) fn eager_capacity(reported_len: u32) -> usize { /// (or an instance with enumerable prototype members) would inject its /// keys into the result, and inherited getters would run when the values /// are read. Own-only enumeration forecloses both. -pub(crate) fn own_enumerable_keys(obj: &Object<'_>) -> Result> { +/// +/// A key holding an unpaired surrogate is refused, as string values are: it +/// has no UTF-8 spelling, so it could not be sealed as written. +pub(crate) fn own_enumerable_keys<'e>(obj: &Object<'e>) -> Result>> { // napi-rs takes a single key filter, so symbol keys are skipped below // rather than by an `enumerable | skip_symbols` filter. let names = obj.get_all_property_names( @@ -85,15 +96,36 @@ pub(crate) fn own_enumerable_keys(obj: &Object<'_>) -> Result> { let len = names.get_array_length()?; let mut keys = Vec::with_capacity(eager_capacity(len)); for i in 0..len { - let name = names.get_element::(i)?; - if name.get_type()? == ValueType::Symbol { + let handle = names.get_element::(i)?; + if handle.get_type()? == ValueType::Symbol { continue; } - keys.push(String::from_unknown(name)?); + let name = lossless_string(handle, "property name")?; + keys.push(OwnKey { handle, name }); } Ok(keys) } +/// Read the property `key` names, by the key's own JS handle. +pub(crate) fn get_own<'e>(obj: &Object<'e>, key: &OwnKey<'e>) -> Result> { + obj.get_property_unchecked(key.handle) +} + +/// A JS string as Rust text. Fetched as UTF-16 (JS's native representation) +/// and converted with `String::from_utf16`, which errors on unpaired +/// surrogates. The UTF-8 fetch (`napi_get_value_string_utf8`) would silently +/// replace them with U+FFFD, so a value would seal *mutated* and a key would +/// stop naming its property; refuse loudly instead. +fn lossless_string(value: Unknown<'_>, what: &str) -> Result { + let utf16 = Utf16String::from_unknown(value)?; + String::from_utf16(&utf16).map_err(|_| { + Error::new( + Status::InvalidArg, + format!("{what} contains an unpaired surrogate and cannot be encrypted losslessly"), + ) + }) +} + /// Fetch a property as the raw JS value, without [`Object::get`]'s /// `undefined`-to-`None` mapping — `{ x: undefined }` has an own property /// `x` (`Object.hasOwn` says so) and must round-trip, but `Object::get` @@ -146,31 +178,37 @@ fn ensure_plain_object(obj: &Object<'_>) -> Result<()> { // direction still projects a passthrough produced elsewhere (e.g. a value // written by another binding) onto its plain JS value. Follow-up: add an // explicit passthrough marker type to the JS API. -/// Write `value` onto `obj` as an **own data property** (`defineProperty` -/// semantics: writable, enumerable, configurable) rather than through -/// [`Object::set`] (`napi_set_property`, `[[Set]]` semantics), which walks -/// the prototype chain: a polluted inherited setter on the key would receive -/// the decrypted plaintext (leak) and leave the result without the own -/// property (drop). The intake side already treats prototype pollution as -/// in-threat-model (`own_enumerable_keys`, `ensure_plain_object`); this -/// closes the output side. -/// The attributes `obj.key = value` gives a new property. -const OWN_DATA_PROPERTY: PropertyAttributes = PropertyAttributes::Writable - .union(PropertyAttributes::Enumerable) - .union(PropertyAttributes::Configurable); -pub(crate) fn define_own_property<'e, V: JsValue<'e>>( - obj: &mut Object<'_>, +/// One own data property, for [`define_own_properties`]. `Property::new()` +/// defaults to writable, enumerable and configurable, the attributes +/// `obj.key = value` would give. +pub(crate) fn own_property<'e, V: JsValue<'e>>( + env: &Env, key: &str, value: &V, -) -> Result<()> { - let env = Env::from_raw(obj.value().env); - let property = Property::new() - // A JS string name, not a UTF-8 C string, so a key holding NUL works. - .with_name(&env, key)? - .with_value(value) - .with_property_attributes(OWN_DATA_PROPERTY); - obj.define_properties(&[property]) +) -> Result { + // A JS string name, not a UTF-8 C string, so a key holding NUL works. + Ok(Property::new().with_name(env, key)?.with_value(value)) +} + +/// Write `properties` onto `obj` as **own data properties** +/// (`defineProperty` semantics) rather than through [`Object::set`] or +/// `Array::set` (`napi_set_property` / `napi_set_element`, `[[Set]]` +/// semantics), which walk the prototype chain: a polluted inherited setter on +/// a key or array index would receive the decrypted plaintext (leak) and +/// leave the result without the own property (drop). The intake side already +/// treats prototype pollution as in-threat-model (`own_enumerable_keys`, +/// `ensure_plain_object`); this closes the output side. One call per object. +pub(crate) fn define_own_properties(obj: &mut Object<'_>, properties: &[Property]) -> Result<()> { + obj.define_properties(properties) +} + +/// A new JS array of `len` holes, seen as an object so its elements can be +/// defined with [`define_own_properties`]. +pub(crate) fn new_array<'e>(env: &'e Env, len: usize) -> Result> { + let len = u32::try_from(len) + .map_err(|_| Error::new(Status::InvalidArg, "array is too long for JS"))?; + Object::from_unknown(env.create_array(len)?.to_unknown()) } fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { @@ -188,21 +226,9 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { scalar::bigint(big.sign_bit, &big.words).map_err(|e| conversion_error(env, e)) } ValueType::String => { - // Fetch as UTF-16 (JS's native representation) and convert with - // `String::from_utf16`, which errors on unpaired surrogates. The - // UTF-8 fetch (`napi_get_value_string_utf8`) would silently - // replace them with U+FFFD — sealing a *mutated* plaintext that - // no longer equals what the caller passed — so refuse loudly - // instead. The converted copy moves into `Protected`; the V8 - // original is owned by the engine and cannot be wiped from here. - let utf16 = Utf16String::from_unknown(unknown)?; - let s = String::from_utf16(&utf16).map_err(|_| { - Error::new( - Status::InvalidArg, - "string contains an unpaired surrogate and cannot be encrypted losslessly", - ) - })?; - Ok(Value::String(s.into())) + // The converted copy moves into `Protected`; the V8 original is + // owned by the engine and cannot be wiped from here. + Ok(Value::String(lossless_string(unknown, "string")?.into())) } ValueType::Object => { if unknown.is_buffer()? { @@ -249,7 +275,7 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { let keys = own_enumerable_keys(&obj)?; if let [key] = keys.as_slice() { use scalar::ConversionError::{InvalidDate, InvalidDecimal, InvalidTimestamp}; - let parsed = match key.as_str() { + let parsed = match key.name.as_str() { "date" => Some( wrapper_text(&obj, key, InvalidDate)? .and_then(|text| scalar::date(&text)), @@ -271,13 +297,13 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { } let mut entries = Vec::with_capacity(keys.len()); for key in keys { - if forbidden_key(&key) { - return Err(forbidden_key_error(&key)); + if forbidden_key(&key.name) { + return Err(forbidden_key_error(&key.name)); } // Raw fetch so an explicit `undefined` value survives as // `Value::Undefined` instead of erroring. - let value = get_property_unknown(&obj, &key)?; - entries.push((key, js_to_value(value, depth + 1)?)); + let value = get_own(&obj, &key)?; + entries.push((key.name, js_to_value(value, depth + 1)?)); } Ok(Value::Object(entries)) } @@ -292,15 +318,19 @@ fn js_to_value(unknown: Unknown<'_>, depth: usize) -> Result { impl FromNapiValue for NapiValue { unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result { // SAFETY: napi-rs calls this with the `env` and `napi_val` of the - // current call, which is the contract `Unknown::from_napi_value` - // asks for. Wrapping as `Unknown` accepts any value type, and the - // result does not outlive this call. + // current call, which is all `Unknown::from_napi_value` needs: an + // `Unknown` may hold any value type, and `js_to_value` checks the + // type before every narrower conversion. The handle does not outlive + // this call. let unknown = unsafe { Unknown::from_napi_value(env, napi_val) }?; js_to_value(unknown, 0).map(NapiValue) } } -fn value_to_js(env: &Env, value: Value) -> Result> { +fn value_to_js(env: &Env, value: Value, depth: usize) -> Result> { + if depth > MAX_DEPTH { + return Err(depth_error()); + } match value { Value::Null => Null.into_unknown(env), Value::Undefined => ().into_unknown(env), @@ -346,23 +376,28 @@ fn value_to_js(env: &Env, value: Value) -> Result> { } Value::Bytes(b) => Buffer::from(b.risky_ref().to_vec()).into_unknown(env), Value::Array(items) => { - let mut arr = env.create_array(items.len() as u32)?; + let mut arr = new_array(env, items.len())?; + let mut elements = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { - arr.set(i as u32, value_to_js(env, item)?)?; + let js_value = value_to_js(env, item, depth + 1)?; + elements.push(own_property(env, &i.to_string(), &js_value)?); } + define_own_properties(&mut arr, &elements)?; arr.into_unknown(env) } Value::Object(entries) => { let mut obj = Object::new(env)?; + let mut properties = Vec::with_capacity(entries.len()); for (key, value) in entries { // Keys decrypted from a ciphertext are attacker-influenced // in principle; never assign prototype-polluting names. if forbidden_key(&key) { return Err(forbidden_key_error(&key)); } - let js_value = value_to_js(env, value)?; - define_own_property(&mut obj, &key, &js_value)?; + let js_value = value_to_js(env, value, depth + 1)?; + properties.push(own_property(env, &key, &js_value)?); } + define_own_properties(&mut obj, &properties)?; obj.into_unknown(env) } // A passthrough subtree projects onto its plain JS value: the marking @@ -370,7 +405,8 @@ fn value_to_js(env: &Env, value: Value) -> Result> { // ordinary value to the application. (Round-tripping the *marking* // back into a re-encryptable JS value is future work — see // `js_to_value`, which has no passthrough constructor yet.) - Value::Passthrough(inner) => value_to_js(env, *inner), + // One level deeper, as the transport decoder counts it. + Value::Passthrough(inner) => value_to_js(env, *inner, depth + 1), _ => Err(Error::new(Status::GenericFailure, "unsupported value kind")), } } @@ -379,12 +415,12 @@ fn value_to_js(env: &Env, value: Value) -> Result> { /// (`{ date: new Date() }`, `{ decimal: 1n }`, ...) is refused with a typed /// error chosen by [`scalar::non_string_payload`] rather than a generic /// N-API one. -fn wrapper_text( - obj: &Object, - key: &str, +fn wrapper_text<'e>( + obj: &Object<'e>, + key: &OwnKey<'e>, invalid: scalar::ConversionError, ) -> Result> { - let input = get_property_unknown(obj, key)?; + let input = get_own(obj, key)?; Ok(match input.get_type()? { ValueType::String => Ok(String::from_unknown(input)?), ValueType::Number => Err(scalar::non_string_payload( @@ -405,13 +441,13 @@ fn conversion_error(env: Env, error: scalar::ConversionError) -> Error { fn wrapper_to_js<'e>(env: &'e Env, key: &str, text: String) -> Result> { let mut obj = Object::new(env)?; let value = text.into_unknown(env)?; - define_own_property(&mut obj, key, &value)?; + define_own_properties(&mut obj, &[own_property(env, key, &value)?])?; obj.into_unknown(env) } impl ToNapiValue for NapiValue { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - Ok(value_to_js(&Env::from_raw(env), val.0)?.raw()) + Ok(value_to_js(&Env::from_raw(env), val.0, 0)?.raw()) } } diff --git a/packages/aead-napi/tests/addon/Cargo.lock b/packages/aead-napi/tests/addon/Cargo.lock index 19c2ac65..06607f46 100644 --- a/packages/aead-napi/tests/addon/Cargo.lock +++ b/packages/aead-napi/tests/addon/Cargo.lock @@ -861,6 +861,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "vitaminc-aead", "vitaminc-aead-napi", "vitaminc-aead-value", "vitaminc-protected", diff --git a/packages/aead-napi/tests/addon/Cargo.toml b/packages/aead-napi/tests/addon/Cargo.toml index f7961fd7..7489682b 100644 --- a/packages/aead-napi/tests/addon/Cargo.toml +++ b/packages/aead-napi/tests/addon/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "3", default-features = false, features = ["napi6"] } napi-derive = "3" +vitaminc-aead = { path = "../../../aead" } vitaminc-aead-napi = { path = "../.." } vitaminc-aead-value = { path = "../../../aead-value", features = ["chrono", "rust_decimal"] } vitaminc-protected = { path = "../../../protected" } diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs index fcff2351..7adb6524 100644 --- a/packages/aead-napi/tests/addon/src/lib.rs +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -1,6 +1,7 @@ use napi::bindgen_prelude::Buffer; use napi_derive::napi; -use vitaminc_aead_napi::{JsCipherText, NapiValue}; +use vitaminc_aead::CipherText; +use vitaminc_aead_napi::{JsCipherText, NapiValue, Value}; use vitaminc_aead_value::transport::{decode_value, encode_value, Reader}; use vitaminc_protected::{Controlled, Protected}; @@ -43,3 +44,39 @@ pub fn ciphertext_round_trip( ) -> JsCipherText, NapiValue> { ct } + +/// `levels` containers of one `wrap` kind ("array", "object" or +/// "passthrough") around `true`, built in Rust rather than decoded, so the +/// output conversion's own depth limit is what applies. +#[napi] +pub fn nested_value(levels: u32, wrap: String) -> NapiValue { + let mut value = Value::Bool(true); + for _ in 0..levels { + value = match wrap.as_str() { + "array" => Value::Array(vec![value]), + "object" => Value::Object(vec![("k".into(), value)]), + _ => Value::Passthrough(Box::new(value)), + }; + } + NapiValue(value) +} + +/// `levels` ciphertext containers of one `wrap` kind ("seq" or "map") +/// around one sealed leaf. +#[napi(ts_return_type = "unknown")] +pub fn nested_ciphertext(levels: u32, wrap: String) -> JsCipherText, NapiValue> { + let mut node = CipherText::Single(vec![0xa1]); + for _ in 0..levels { + node = match wrap.as_str() { + "seq" => CipherText::Sequence(vec![node]), + _ => CipherText::Map(vec![("k".into(), node)]), + }; + } + JsCipherText(node) +} + +/// Read a ciphertext node tree into Rust and drop it, to test reading alone. +#[napi] +pub fn read_ciphertext(#[napi(ts_arg_type = "unknown")] ct: JsCipherText, NapiValue>) { + drop(ct); +} diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs index 456f7bd6..89210d0a 100644 --- a/packages/aead-napi/tests/object-safety.cjs +++ b/packages/aead-napi/tests/object-safety.cjs @@ -92,10 +92,14 @@ try { } finally { delete Object.prototype.own; } -assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v: JSON.parse('{"__proto__": {"t":"ct","v":"x"}}') })); +{ + const v = JSON.parse('{"__proto__": {"t": "ct"}}'); + v.__proto__.v = leaf; + assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v }), /not allowed/); +} -// Nesting is limited to 128 levels below the root, in both directions of -// the value conversion and when reading a ciphertext tree. +// Nesting is limited to 128 levels below the root, converting JS to Rust +// and reading a ciphertext tree. const MAX_DEPTH = 128; const nest = (levels, wrap, leaf) => { let v = leaf; @@ -109,8 +113,9 @@ for (const wrap of [(v) => [v], (v) => ({ k: v })]) { for (const wrap of [(v) => ({ t: 'seq', v: [v] }), (v) => ({ t: 'map', v: { k: v } })]) { const deepest = nest(MAX_DEPTH, wrap, { t: 'ct', v: leaf }); assert.deepEqual(addon.ciphertextRoundTrip(deepest), deepest); + // Reading alone, so the output limit cannot be what refuses it. assert.throws( - () => addon.ciphertextRoundTrip(nest(MAX_DEPTH + 1, wrap, { t: 'ct', v: leaf })), + () => addon.readCiphertext(nest(MAX_DEPTH + 1, wrap, { t: 'ct', v: leaf })), /nested too deeply/, ); } @@ -123,3 +128,72 @@ assert.deepEqual(roundTrip({ timestamp: '2016-12-31T23:59:60Z' }), { // A passthrough value decodes to its plain JS value, at the root and nested. assert.equal(addon.decode(Buffer.from('f203', 'hex')), true); assert.deepEqual(addon.decode(Buffer.from('f0010000' + '00f20a0100000061', 'hex')), ['a']); + +// Building JS from Rust is limited to the same depth, so a tree built in Rust +// fails cleanly instead of overflowing the stack. +for (const wrap of ['array', 'object', 'passthrough']) { + addon.nestedValue(MAX_DEPTH, wrap); + assert.throws(() => addon.nestedValue(MAX_DEPTH + 1, wrap), /nested too deeply/, wrap); +} +assert.equal(nest(MAX_DEPTH, (v) => v[0], addon.nestedValue(MAX_DEPTH, 'array')), true); +for (const wrap of ['seq', 'map']) { + addon.nestedCiphertext(MAX_DEPTH, wrap); + assert.throws(() => addon.nestedCiphertext(MAX_DEPTH + 1, wrap), /nested too deeply/, wrap); +} + +// Array elements are own properties too: a polluted index setter on +// Array.prototype sees nothing, and the result has no hole. +for (const make of [ + () => addon.decode(addon.encode(['secret'])), + () => addon.ciphertextRoundTrip({ t: 'seq', v: [{ t: 'ct', v: leaf }] }).v, +]) { + let stolen = 'untouched'; + Object.defineProperty(Array.prototype, '0', { + set(v) { stolen = v; }, + configurable: true, + }); + try { + const out = make(); + assert.equal(stolen, 'untouched'); + assert.ok(Object.hasOwn(out, 0)); + } finally { + delete Array.prototype[0]; + } +} + +// So are a ciphertext node's `t` and `v`. +for (const key of ['t', 'v']) { + let stolen = 'untouched'; + Object.defineProperty(Object.prototype, key, { + set(v) { stolen = v; }, + configurable: true, + }); + try { + const out = addon.ciphertextRoundTrip({ t: 'ct', v: leaf }); + assert.equal(stolen, 'untouched'); + assert.deepEqual(Object.keys(out), ['t', 'v']); + } finally { + delete Object.prototype[key]; + } +} + +// A key holding an unpaired surrogate is refused, not sealed under U+FFFD +// with its value lost. +assert.throws(() => addon.encode({ '\uD800': 'secret' }), /unpaired surrogate/); +assert.throws( + () => addon.ciphertextRoundTrip({ t: 'map', v: { '\uDC00': { t: 'ct', v: leaf } } }), + /unpaired surrogate/, +); + +// A ciphertext node must be an object. N-API would otherwise read `t` and +// `v` of a primitive from its (here polluted) prototype. +String.prototype.t = 'pt'; +Number.prototype.t = 'pt'; +try { + assert.throws(() => addon.ciphertextRoundTrip('abc'), /malformed ciphertext/); + assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v: { k: 5 } }), /malformed ciphertext/); + assert.throws(() => addon.ciphertextRoundTrip({ t: 'map', v: 'abc' }), /malformed ciphertext/); +} finally { + delete String.prototype.t; + delete Number.prototype.t; +} From f75f6607e985d7cc9a1b88951fe1435b906995f1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 21:52:10 +1100 Subject: [PATCH 26/28] fix(aead-napi)!: read ciphertext nodes from own fields and carry depth into payloads A node object missing its own t or v took it from a possibly polluted prototype, and a hole in a sequence took an element from Array.prototype. Both now count as malformed ciphertext; an own v: undefined is still accepted. A passthrough payload restarted the depth count at zero, so a tree could nest 128 ciphertext levels and then 128 payload levels, unlike the transport encoding, where the payload continues the count. JsCipherText's payload now converts through a new NapiPassthrough trait, implemented for NapiValue and (), whose methods take the payload's depth. The () payload now also checks that it receives undefined, which napi-rs's own conversion does not. Also names define_own_properties instead of the removed define_own_property in the README and the CRAP allow list, and exempts wrapper_to_js, which sat exactly on the CRAP threshold. BREAKING CHANGE: JsCipherText's payload type P must implement NapiPassthrough instead of napi-rs's ToNapiValue and FromNapiValue. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- .cargo-crap.toml | 4 +- packages/aead-napi/README.md | 25 +++++--- packages/aead-napi/src/ciphertext.rs | 75 +++++++++++++++++----- packages/aead-napi/src/convert.rs | 12 ++++ packages/aead-napi/src/lib.rs | 2 +- packages/aead-napi/tests/addon/src/lib.rs | 21 ++++++ packages/aead-napi/tests/object-safety.cjs | 42 ++++++++++++ 7 files changed, 154 insertions(+), 27 deletions(-) diff --git a/.cargo-crap.toml b/.cargo-crap.toml index 41811ec7..20a5ca65 100644 --- a/.cargo-crap.toml +++ b/.cargo-crap.toml @@ -53,7 +53,7 @@ exclude = ["tests/**", "src/test_support.rs"] # # * The `vitaminc-aead-napi` conversion functions and their JS-boundary # helpers (`own_enumerable_keys`, `get_property_unknown`, -# `ensure_plain_object`, `wrapper_text`): each takes a live `napi_env`/`Unknown`/`Object`, +# `ensure_plain_object`, `wrapper_text`, `wrapper_to_js`): each takes a live `napi_env`/`Unknown`/`Object`, # which only exists inside a running V8 isolate. The Node suites run by # `tests/node.rs` do exercise them, but inside a separately built test # addon loaded by `node`, which llvm-cov does not instrument; the @@ -70,8 +70,8 @@ allow = [ "node_to_js", "node_from_js", "own_enumerable_keys", - "define_own_property", "get_property_unknown", "ensure_plain_object", "wrapper_text", + "wrapper_to_js", ] diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index a971538a..b8456cf3 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -45,7 +45,10 @@ cross-language leaf wire format. This crate adds only what is JS-specific: - **`JsCipherText`** — projects the generic `CipherText` container onto plain JS values (`{ t, v }` nodes with `Buffer` leaves) and back, as an in-memory/application-side representation. Durable cross-language - database storage is the EQL layer's job, not this projection's. + database storage is the EQL layer's job, not this projection's. The + passthrough payload `P` converts through `NapiPassthrough`, implemented + for `NapiValue` and `()`, which carries the tree's nesting depth into the + payload. This crate is a library consumed by the Node addon (cdylib/npm package); it is not itself loadable from Node. @@ -81,14 +84,18 @@ node packages/aead-napi/tests/kind_inventory.cjs target/debug/examples/libkind_i The JS-boundary conversion functions — `js_to_value`, `value_to_js`, `node_to_js`, `node_from_js` — and their helpers — `own_enumerable_keys`, -`get_property_unknown`, `ensure_plain_object`, `define_own_property`, -`wrapper_text` — take a live `napi_env`, `Unknown`, or `Object`, which only -exists inside a running V8 isolate, so Rust unit tests cannot reach them. The -Node suites do: `tests/node.rs` rebuilds the test addon from the crate's -source, so the mutation gate tests these functions like any other code. Only -the CRAP gate still exempts them, by name in `.cargo-crap.toml`, because they -run inside the separately built addon, where `cargo llvm-cov` cannot measure -their coverage. +`get_property_unknown`, `ensure_plain_object`, `wrapper_text`, +`wrapper_to_js` — take a live +`napi_env`, `Unknown`, or `Object`, which only exists inside a running V8 +isolate, so Rust unit tests cannot reach them. The Node suites do: +`tests/node.rs` rebuilds the test addon from the crate's source, so the +mutation gate tests these functions like any other code. Only the CRAP gate +still exempts them, by name in `.cargo-crap.toml`, because they run inside +the separately built addon, where `cargo llvm-cov` cannot measure their +coverage. The smaller helpers in the same position (`define_own_properties`, +`own_property`, `new_array`, `get_own`, `lossless_string`, `node_object`) +are simple enough to stay under the CRAP threshold even at 0% coverage, so +they are not exempted. ## Safety notes diff --git a/packages/aead-napi/src/ciphertext.rs b/packages/aead-napi/src/ciphertext.rs index 50271131..88488488 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -26,7 +26,9 @@ //! have persisted it), but it is not a cross-language wire commitment; //! only the leaf byte format (see `vitaminc-aead-value`) is frozen. -use napi::bindgen_prelude::{Array, Buffer, FromNapiValue, JsValue, Object, ToNapiValue, Unknown}; +use napi::bindgen_prelude::{ + Array, Buffer, FromNapiValue, JsObjectValue, JsValue, Object, ToNapiValue, Unknown, +}; use napi::{sys, Env, Error, Result, Status, ValueType}; use vitaminc_aead::CipherText; @@ -47,12 +49,42 @@ const T_PASSTHROUGH: &str = "pt"; /// node projection documented at the module level. /// /// `Leaf` needs only byte-level accessors (`AsRef<[u8]>` out, -/// `From>` in); the passthrough payload `P` uses its own NAPI -/// conversions. For a cipher whose passthrough payload type is not itself -/// NAPI-convertible (e.g. the Rust-native `Box`), re-home -/// the tree first with [`CipherText::map_passthrough`]. +/// `From>` in); the passthrough payload `P` converts through +/// [`NapiPassthrough`]. For a cipher whose passthrough payload type is not +/// [`NapiValue`] or `()` (e.g. the Rust-native `Box`), +/// re-home the tree first with [`CipherText::map_passthrough`]. +/// +/// [`NapiValue`]: crate::NapiValue pub struct JsCipherText(pub CipherText); +/// A ciphertext passthrough payload's conversion to and from JS. +/// +/// Both directions take the depth the payload sits at, so a payload shares +/// the tree's 128-level nesting limit, as it does in the transport encoding, +/// rather than starting a fresh count. +pub trait NapiPassthrough: Sized { + /// Build the payload's JS value at `depth`. + fn to_js(self, env: &Env, depth: usize) -> Result>; + /// Read the payload from its JS value at `depth`. + fn from_js(value: Unknown<'_>, depth: usize) -> Result; +} + +/// No payload: projects to `undefined`, which is all it accepts back. +impl NapiPassthrough for () { + fn to_js(self, env: &Env, _depth: usize) -> Result> { + ().into_unknown(env) + } + + fn from_js(value: Unknown<'_>, _depth: usize) -> Result { + // napi-rs's own `()` conversion accepts any value; check instead. + if value.get_type()? == ValueType::Undefined { + Ok(()) + } else { + Err(shape_error()) + } + } +} + fn depth_error() -> Error { Error::new(Status::InvalidArg, "ciphertext is nested too deeply") } @@ -64,7 +96,7 @@ fn shape_error() -> Error { fn node_to_js(env: &Env, node: CipherText, depth: usize) -> Result> where Leaf: AsRef<[u8]>, - P: ToNapiValue, + P: NapiPassthrough, { if depth > MAX_DEPTH { return Err(depth_error()); @@ -106,7 +138,8 @@ where define_own_properties(&mut map, &properties)?; (T_MAP, map.into_unknown(env)?) } - CipherText::Passthrough(p) => (T_PASSTHROUGH, p.into_unknown(env)?), + // The payload sits one level deeper, as the transport encoding counts it. + CipherText::Passthrough(p) => (T_PASSTHROUGH, p.to_js(env, depth + 1)?), }; // Own properties throughout, so a polluted inherited `t`, `v` or index // setter never sees the ciphertext (see `define_own_properties`). @@ -128,7 +161,7 @@ const V_KEY: &str = "v"; impl ToNapiValue for JsCipherText where Leaf: AsRef<[u8]>, - P: ToNapiValue, + P: NapiPassthrough, { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { Ok(node_to_js(&Env::from_raw(env), val.0, 0)?.raw()) @@ -149,11 +182,19 @@ fn node_object(value: Unknown<'_>) -> Result> { fn node_from_js(obj: &Object<'_>, depth: usize) -> Result> where Leaf: From>, - P: FromNapiValue, + P: NapiPassthrough, { if depth > MAX_DEPTH { return Err(depth_error()); } + // Own `t` and `v` only: a node missing either would otherwise take it + // from a (possibly polluted) prototype. An own `v: undefined` still + // counts, for a passthrough of `undefined`. + for key in [T_KEY, V_KEY] { + if !obj.has_own_property(key)? { + return Err(shape_error()); + } + } let t: String = obj.get(T_KEY)?.ok_or_else(shape_error)?; match t.as_str() { T_CIPHERTEXT | T_NONE | T_EMPTY_SEQ | T_EMPTY_MAP => { @@ -168,6 +209,8 @@ where } T_SEQ => { let arr: Array = obj.get(V_KEY)?.ok_or_else(shape_error)?; + // The same array seen as an object, for per-index own checks. + let elements = Object::from_unknown(arr.to_unknown())?; let len = arr.len(); // Capacity is granted lazily: the reported length is // attacker-cheap (`{t:"seq", v:new Array(2**32-1)}` materialises @@ -175,9 +218,12 @@ where // allocation failure before any element is validated. let mut items = Vec::with_capacity(eager_capacity(len)); for i in 0..len { - // `Array::get` maps only out-of-range indices to `None`; a - // hole comes back as `undefined`, which is not a node - // object — check the type before treating it as one. + // Own elements only: a hole would otherwise read an element + // from a (possibly polluted) `Array.prototype`, and as + // `undefined` it is not a node anyway. + if !elements.has_own_property(&i.to_string())? { + return Err(shape_error()); + } let element = arr.get::(i)?.ok_or_else(shape_error)?; items.push(node_from_js(&node_object(element)?, depth + 1)?); } @@ -211,8 +257,7 @@ where // crate's own output as malformed. Whether `undefined` // converts is `P`'s decision. let value = get_property_unknown(obj, V_KEY)?; - let p = P::from_unknown(value)?; - Ok(CipherText::Passthrough(p)) + Ok(CipherText::Passthrough(P::from_js(value, depth + 1)?)) } _ => Err(shape_error()), } @@ -221,7 +266,7 @@ where impl FromNapiValue for JsCipherText where Leaf: From>, - P: FromNapiValue, + P: NapiPassthrough, { unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result { // SAFETY: napi-rs calls this with the `env` and `napi_val` of the diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index 2e0b0956..b5dfc503 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -26,6 +26,7 @@ use napi::{sys, Env, Error, Property, Result, Status, ValueType}; use vitaminc_aead_value::Value; use vitaminc_protected::{Controlled, Protected}; +use crate::ciphertext::NapiPassthrough; use crate::scalar; use crate::value::NapiValue; @@ -445,6 +446,17 @@ fn wrapper_to_js<'e>(env: &'e Env, key: &str, text: String) -> Result Result> { + value_to_js(env, self.0, depth) + } + + fn from_js(value: Unknown<'_>, depth: usize) -> Result { + js_to_value(value, depth).map(NapiValue) + } +} + impl ToNapiValue for NapiValue { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { Ok(value_to_js(&Env::from_raw(env), val.0, 0)?.raw()) diff --git a/packages/aead-napi/src/lib.rs b/packages/aead-napi/src/lib.rs index 9bb15c0e..a09bc5df 100644 --- a/packages/aead-napi/src/lib.rs +++ b/packages/aead-napi/src/lib.rs @@ -10,7 +10,7 @@ mod scalar; mod value; pub use scalar::ConversionError; -pub use ciphertext::JsCipherText; +pub use ciphertext::{JsCipherText, NapiPassthrough}; pub use value::NapiValue; // Re-exported so addon crates need not depend on the value crate directly. pub use vitaminc_aead_value::Value; diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs index 7adb6524..7fa2b135 100644 --- a/packages/aead-napi/tests/addon/src/lib.rs +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -75,8 +75,29 @@ pub fn nested_ciphertext(levels: u32, wrap: String) -> JsCipherText, Nap JsCipherText(node) } +/// `ct_levels` ciphertext sequences around a passthrough node whose payload +/// is `value_levels` arrays around `true`: one tree whose depth spans both. +#[napi(ts_return_type = "unknown")] +pub fn ciphertext_with_payload( + ct_levels: u32, + value_levels: u32, +) -> JsCipherText, NapiValue> { + let NapiValue(payload) = nested_value(value_levels, "array".into()); + let mut node = CipherText::Passthrough(NapiValue(payload)); + for _ in 0..ct_levels { + node = CipherText::Sequence(vec![node]); + } + JsCipherText(node) +} + /// Read a ciphertext node tree into Rust and drop it, to test reading alone. #[napi] pub fn read_ciphertext(#[napi(ts_arg_type = "unknown")] ct: JsCipherText, NapiValue>) { drop(ct); } + +/// As `read_ciphertext`, for a cipher with no passthrough payload. +#[napi] +pub fn read_unit_ciphertext(#[napi(ts_arg_type = "unknown")] ct: JsCipherText, ()>) { + drop(ct); +} diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs index 89210d0a..a5b938c8 100644 --- a/packages/aead-napi/tests/object-safety.cjs +++ b/packages/aead-napi/tests/object-safety.cjs @@ -197,3 +197,45 @@ try { delete String.prototype.t; delete Number.prototype.t; } + +// A node's `t` and `v` must be its own: a polluted prototype cannot supply +// either. An own `v: undefined` is still accepted (see `u` above). +for (const [key, value] of [['t', 'pt'], ['v', leaf]]) { + Object.prototype[key] = value; + try { + assert.throws(() => addon.readCiphertext(key === 't' ? { v: leaf } : { t: 'ct' }), /malformed ciphertext/); + } finally { + delete Object.prototype[key]; + } +} +Object.prototype.t = 'pt'; +Object.prototype.v = 'injected'; +try { + assert.throws(() => addon.readCiphertext({}), /malformed ciphertext/); +} finally { + delete Object.prototype.t; + delete Object.prototype.v; +} + +// Sequence elements must be own: a hole cannot be filled by a node on a +// polluted Array.prototype. +Array.prototype[0] = { t: 'ct', v: leaf }; +try { + assert.throws(() => addon.readCiphertext({ t: 'seq', v: new Array(1) }), /malformed ciphertext/); +} finally { + delete Array.prototype[0]; +} + +// A passthrough payload continues the tree's depth count, in both +// directions, as it does in the transport encoding: 60 sequences, the +// passthrough node, then 67 arrays reach exactly 128. +addon.ciphertextWithPayload(60, 67); +assert.throws(() => addon.ciphertextWithPayload(60, 68), /nested too deeply/); +const payloadTree = (arrays) => + nest(60, (v) => ({ t: 'seq', v: [v] }), { t: 'pt', v: nest(arrays, (v) => [v], true) }); +addon.readCiphertext(payloadTree(67)); +assert.throws(() => addon.readCiphertext(payloadTree(68)), /nested too deeply/); + +// With no passthrough payload type, a passthrough node must carry undefined. +addon.readUnitCiphertext({ t: 'pt', v: undefined }); +assert.throws(() => addon.readUnitCiphertext({ t: 'pt', v: 1 }), /malformed ciphertext/); From ec44dfcdc7f150e0912b8373a7a6174911bb26a1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:07:58 +1100 Subject: [PATCH 27/28] fix(aead-napi): check ownership immediately before each property read The own-key list and the own t/v checks were snapshots taken before the reads. A getter that ran in between could delete a later property, and the read then fell through to a possibly polluted prototype. Each read of an object property, a ciphertext map entry, or a node's t or v now checks ownership immediately before it, where no JS can run in between for an ordinary object. Also restores reading a passthrough node whose v was dropped by JSON.stringify (a passthrough of undefined) as undefined; requiring an own v had broken it. The prototype is still never read. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- packages/aead-napi/src/ciphertext.rs | 46 +++++++++++++--------- packages/aead-napi/src/convert.rs | 13 +++++- packages/aead-napi/tests/object-safety.cjs | 36 +++++++++++++++++ 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/packages/aead-napi/src/ciphertext.rs b/packages/aead-napi/src/ciphertext.rs index 88488488..a4ec7f5f 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -179,6 +179,17 @@ fn node_object(value: Unknown<'_>) -> Result> { Object::from_unknown(value) } +/// A node's own `t` or `v`. A node missing either would otherwise take it +/// from a (possibly polluted) prototype. Ownership is checked immediately +/// before the read, where no JS can run in between, so a getter on the +/// other field cannot delete this one after the check. +fn own_field<'e>(obj: &Object<'e>, key: &str) -> Result> { + if !obj.has_own_property(key)? { + return Err(shape_error()); + } + get_property_unknown(obj, key) +} + fn node_from_js(obj: &Object<'_>, depth: usize) -> Result> where Leaf: From>, @@ -187,18 +198,10 @@ where if depth > MAX_DEPTH { return Err(depth_error()); } - // Own `t` and `v` only: a node missing either would otherwise take it - // from a (possibly polluted) prototype. An own `v: undefined` still - // counts, for a passthrough of `undefined`. - for key in [T_KEY, V_KEY] { - if !obj.has_own_property(key)? { - return Err(shape_error()); - } - } - let t: String = obj.get(T_KEY)?.ok_or_else(shape_error)?; + let t = String::from_unknown(own_field(obj, T_KEY)?)?; match t.as_str() { T_CIPHERTEXT | T_NONE | T_EMPTY_SEQ | T_EMPTY_MAP => { - let buf: Buffer = obj.get(V_KEY)?.ok_or_else(shape_error)?; + let buf = Buffer::from_unknown(own_field(obj, V_KEY)?)?; let leaf = Leaf::from(buf.to_vec()); Ok(match t.as_str() { T_CIPHERTEXT => CipherText::Single(leaf), @@ -208,7 +211,7 @@ where }) } T_SEQ => { - let arr: Array = obj.get(V_KEY)?.ok_or_else(shape_error)?; + let arr = Array::from_unknown(own_field(obj, V_KEY)?)?; // The same array seen as an object, for per-index own checks. let elements = Object::from_unknown(arr.to_unknown())?; let len = arr.len(); @@ -230,7 +233,7 @@ where Ok(CipherText::Sequence(items)) } T_MAP => { - let map = node_object(get_property_unknown(obj, V_KEY)?)?; + let map = node_object(own_field(obj, V_KEY)?)?; // Own keys only — `Object::keys` walks the prototype chain, so // a hostile node object with enumerable prototype members would // otherwise have its inherited keys become ciphertext map @@ -250,13 +253,18 @@ where Ok(CipherText::Map(entries)) } T_PASSTHROUGH => { - // Raw fetch, not `obj.get` — `undefined` is a *faithful* - // projection here (`P = ()` and `NapiValue::Undefined` both - // project to it, and `JSON.stringify` drops the key outright), - // and `Object::get` would map it to `None` and reject the - // crate's own output as malformed. Whether `undefined` - // converts is `P`'s decision. - let value = get_property_unknown(obj, V_KEY)?; + // `undefined` is a *faithful* projection here (`P = ()` and + // `NapiValue::Undefined` both project to it), and + // `JSON.stringify` drops a `v: undefined` key outright, so a + // missing `v` reads as `undefined` rather than as malformed — + // never from the prototype. Whether `undefined` converts is + // `P`'s decision. + let env = Env::from_raw(obj.value().env); + let value = if obj.has_own_property(V_KEY)? { + own_field(obj, V_KEY)? + } else { + ().into_unknown(&env)? + }; Ok(CipherText::Passthrough(P::from_js(value, depth + 1)?)) } _ => Err(shape_error()), diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index b5dfc503..d2f217fa 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -107,8 +107,19 @@ pub(crate) fn own_enumerable_keys<'e>(obj: &Object<'e>) -> Result Ok(keys) } -/// Read the property `key` names, by the key's own JS handle. +/// Read the own property `key` names, by the key's own JS handle. +/// +/// The key list is a snapshot, and a getter that ran since may have deleted +/// this property; `[[Get]]` would then read it from a (possibly polluted) +/// prototype. So ownership is checked again immediately before the read, +/// where no JS can run in between for an ordinary object. pub(crate) fn get_own<'e>(obj: &Object<'e>, key: &OwnKey<'e>) -> Result> { + if !obj.has_own_property_js(key.handle)? { + return Err(Error::new( + Status::InvalidArg, + format!("property was removed while it was being read: {}", key.name), + )); + } obj.get_property_unchecked(key.handle) } diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs index a5b938c8..1f752803 100644 --- a/packages/aead-napi/tests/object-safety.cjs +++ b/packages/aead-napi/tests/object-safety.cjs @@ -208,6 +208,42 @@ for (const [key, value] of [['t', 'pt'], ['v', leaf]]) { delete Object.prototype[key]; } } + +// A passthrough of undefined loses its `v` key through JSON, so a missing +// `v` reads as undefined there, and never from the prototype. +Object.prototype.v = 'injected'; +try { + const out = addon.ciphertextRoundTrip(JSON.parse(JSON.stringify({ t: 'pt', v: undefined }))); + assert.ok(Object.hasOwn(out, 'v')); + assert.equal(out.v, undefined); +} finally { + delete Object.prototype.v; +} + +// Ownership is checked right before each read, so a getter that deletes a +// later property cannot make that read fall through to the prototype. +Object.prototype.b = 'injected'; +Object.prototype.v = leaf; +try { + assert.throws( + () => addon.encode({ get a() { delete this.b; return 1; }, b: 2 }), + /removed while it was being read/, + ); + assert.throws( + () => addon.readCiphertext({ get t() { delete this.v; return 'ct'; }, v: leaf }), + /malformed ciphertext/, + ); + assert.throws( + () => addon.readCiphertext({ + t: 'map', + v: { get a() { delete this.b; return { t: 'ct', v: leaf }; }, b: { t: 'ct', v: leaf } }, + }), + /removed while it was being read/, + ); +} finally { + delete Object.prototype.b; + delete Object.prototype.v; +} Object.prototype.t = 'pt'; Object.prototype.v = 'injected'; try { From 6627f86df913d40e93d5699a88a10d7748f55275 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 9 Oct 2026 22:21:04 +1100 Subject: [PATCH 28/28] fix(aead-napi): measure and drop deep Rust-built trees without recursing The depth limit returned an error but still dropped the unconverted remainder of the tree through Value's recursive Drop, so a tree deep enough could overflow the stack and abort Node anyway. A tree built in Rust is now measured iteratively before conversion, and one past the limit is dropped iteratively before the error is returned. The conversion itself no longer needs a depth argument. NapiPassthrough gains exceeds_depth and drop_flat so ciphertext payloads take part, and a () payload now counts against the limit like any other payload, in both directions. Claude-Session: https://claude.ai/code/session_012zHhtTEo968WazExKxA5oT --- packages/aead-napi/README.md | 10 +- packages/aead-napi/src/ciphertext.rs | 169 ++++++++++++++++++--- packages/aead-napi/src/convert.rs | 107 +++++++++++-- packages/aead-napi/tests/addon/src/lib.rs | 10 ++ packages/aead-napi/tests/object-safety.cjs | 22 +++ 5 files changed, 279 insertions(+), 39 deletions(-) diff --git a/packages/aead-napi/README.md b/packages/aead-napi/README.md index b8456cf3..aed0abaa 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -47,8 +47,8 @@ cross-language leaf wire format. This crate adds only what is JS-specific: an in-memory/application-side representation. Durable cross-language database storage is the EQL layer's job, not this projection's. The passthrough payload `P` converts through `NapiPassthrough`, implemented - for `NapiValue` and `()`, which carries the tree's nesting depth into the - payload. + for `NapiValue` and `()`, which applies the tree's nesting limit to the + payload too. This crate is a library consumed by the Node addon (cdylib/npm package); it is not itself loadable from Node. @@ -109,8 +109,10 @@ they are not exempted. - Property names that would touch the prototype chain (`__proto__`, `constructor`, `prototype`) are rejected in both directions. Nesting is limited to 128 levels in both directions too, counted as the transport - decoder counts it, so a tree built in Rust fails cleanly instead of - overflowing the stack. + decoder counts it, including inside a ciphertext's passthrough payload. A + tree built in Rust is measured before it is converted, and one that is too + deep is dropped without recursing, so even a tree far too deep for the + stack fails cleanly instead of aborting Node. - Only **plain objects** (prototype `Object.prototype` or `null`) are accepted for encryption. `Map`, `Set`, `RegExp`, `DataView`, class instances and other exotic objects keep their state in internal slots and diff --git a/packages/aead-napi/src/ciphertext.rs b/packages/aead-napi/src/ciphertext.rs index a4ec7f5f..7825e7c5 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -33,8 +33,8 @@ use napi::{sys, Env, Error, Result, Status, ValueType}; use vitaminc_aead::CipherText; use crate::convert::{ - define_own_properties, eager_capacity, forbidden_key, get_own, get_property_unknown, new_array, - own_enumerable_keys, own_property, MAX_DEPTH, + define_own_properties, depth_error, eager_capacity, forbidden_key, get_own, + get_property_unknown, new_array, own_enumerable_keys, own_property, MAX_DEPTH, }; const T_CIPHERTEXT: &str = "ct"; @@ -59,23 +59,35 @@ pub struct JsCipherText(pub CipherText); /// A ciphertext passthrough payload's conversion to and from JS. /// -/// Both directions take the depth the payload sits at, so a payload shares -/// the tree's 128-level nesting limit, as it does in the transport encoding, -/// rather than starting a fresh count. +/// A payload shares the tree's 128-level nesting limit, as it does in the +/// transport encoding, rather than starting a fresh count: it sits one level +/// below its passthrough node. pub trait NapiPassthrough: Sized { - /// Build the payload's JS value at `depth`. - fn to_js(self, env: &Env, depth: usize) -> Result>; - /// Read the payload from its JS value at `depth`. + /// Build the payload's JS value. [`JsCipherText`] calls this only once + /// [`exceeds_depth`](Self::exceeds_depth) has passed for the payload's + /// position, which bounds any recursion here. + fn to_js(self, env: &Env) -> Result>; + /// Read the payload from its JS value at `depth`, refusing anything that + /// nests past the limit. fn from_js(value: Unknown<'_>, depth: usize) -> Result; + /// Whether the payload, placed at `depth`, nests past the limit. + fn exceeds_depth(&self, depth: usize) -> bool; + /// Drop the payload without recursing once per nesting level, for a tree + /// refused as too deep. + fn drop_flat(self); } -/// No payload: projects to `undefined`, which is all it accepts back. +/// No payload: projects to `undefined`, which is all it accepts back. It is +/// a single node, so it is too deep only past the limit itself. impl NapiPassthrough for () { - fn to_js(self, env: &Env, _depth: usize) -> Result> { + fn to_js(self, env: &Env) -> Result> { ().into_unknown(env) } - fn from_js(value: Unknown<'_>, _depth: usize) -> Result { + fn from_js(value: Unknown<'_>, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(depth_error()); + } // napi-rs's own `()` conversion accepts any value; check instead. if value.get_type()? == ValueType::Undefined { Ok(()) @@ -83,9 +95,49 @@ impl NapiPassthrough for () { Err(shape_error()) } } + + fn exceeds_depth(&self, depth: usize) -> bool { + depth > MAX_DEPTH + } + + fn drop_flat(self) {} } -fn depth_error() -> Error { +/// Whether a ciphertext tree nests past [`MAX_DEPTH`], payloads included. +/// Iterative, so a tree of any depth is measured without recursion. +fn exceeds_depth(node: &CipherText) -> bool { + let mut stack = vec![(node, 0)]; + while let Some((node, depth)) = stack.pop() { + if depth > MAX_DEPTH { + return true; + } + match node { + CipherText::Sequence(items) => stack.extend(items.iter().map(|item| (item, depth + 1))), + CipherText::Map(entries) => { + stack.extend(entries.iter().map(|(_, value)| (value, depth + 1))) + } + CipherText::Passthrough(p) if p.exceeds_depth(depth + 1) => return true, + _ => {} + } + } + false +} + +/// Drop a ciphertext tree without recursing once per level (see +/// [`NapiPassthrough::drop_flat`]). +fn drop_flat(node: CipherText) { + let mut stack = vec![node]; + while let Some(node) = stack.pop() { + match node { + CipherText::Sequence(items) => stack.extend(items), + CipherText::Map(entries) => stack.extend(entries.into_iter().map(|(_, value)| value)), + CipherText::Passthrough(p) => p.drop_flat(), + _ => {} + } + } +} + +fn ciphertext_depth_error() -> Error { Error::new(Status::InvalidArg, "ciphertext is nested too deeply") } @@ -93,14 +145,13 @@ fn shape_error() -> Error { Error::new(Status::InvalidArg, "malformed ciphertext value") } -fn node_to_js(env: &Env, node: CipherText, depth: usize) -> Result> +/// Build `node` in JS. Callers check [`exceeds_depth`] first, which bounds +/// this recursion. +fn node_to_js(env: &Env, node: CipherText) -> Result> where Leaf: AsRef<[u8]>, P: NapiPassthrough, { - if depth > MAX_DEPTH { - return Err(depth_error()); - } let leaf = |tag: &'static str, leaf: Leaf| -> Result<(&'static str, Unknown<'_>)> { Ok((tag, Buffer::from(leaf.as_ref().to_vec()).into_unknown(env)?)) }; @@ -113,7 +164,7 @@ where let mut arr = new_array(env, items.len())?; let mut elements = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { - let node = node_to_js(env, item, depth + 1)?; + let node = node_to_js(env, item)?; elements.push(own_property(env, &i.to_string(), &node)?); } define_own_properties(&mut arr, &elements)?; @@ -132,14 +183,13 @@ where format!("ciphertext map key is not allowed: {key}"), )); } - let node = node_to_js(env, value, depth + 1)?; + let node = node_to_js(env, value)?; properties.push(own_property(env, &key, &node)?); } define_own_properties(&mut map, &properties)?; (T_MAP, map.into_unknown(env)?) } - // The payload sits one level deeper, as the transport encoding counts it. - CipherText::Passthrough(p) => (T_PASSTHROUGH, p.to_js(env, depth + 1)?), + CipherText::Passthrough(p) => (T_PASSTHROUGH, p.to_js(env)?), }; // Own properties throughout, so a polluted inherited `t`, `v` or index // setter never sees the ciphertext (see `define_own_properties`). @@ -164,7 +214,11 @@ where P: NapiPassthrough, { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - Ok(node_to_js(&Env::from_raw(env), val.0, 0)?.raw()) + if exceeds_depth(&val.0) { + drop_flat(val.0); + return Err(ciphertext_depth_error()); + } + Ok(node_to_js(&Env::from_raw(env), val.0)?.raw()) } } @@ -196,7 +250,7 @@ where P: NapiPassthrough, { if depth > MAX_DEPTH { - return Err(depth_error()); + return Err(ciphertext_depth_error()); } let t = String::from_unknown(own_field(obj, T_KEY)?)?; match t.as_str() { @@ -287,3 +341,74 @@ where Ok(JsCipherText(node_from_js(&obj, 0)?)) } } + +#[cfg(test)] +mod tests { + use super::{drop_flat, exceeds_depth, CipherText, NapiPassthrough, MAX_DEPTH}; + use crate::NapiValue; + use vitaminc_aead_value::Value; + + type Node

= CipherText, P>; + + fn nested

(levels: usize, wrap: fn(Node

) -> Node

, leaf: Node

) -> Node

{ + (0..levels).fold(leaf, |node, _| wrap(node)) + } + + fn seq

(node: Node

) -> Node

{ + CipherText::Sequence(vec![CipherText::Single(vec![1]), node]) + } + + fn map

(node: Node

) -> Node

{ + CipherText::Map(vec![ + ("a".into(), CipherText::None(vec![1])), + ("k".into(), node), + ]) + } + + #[test] + fn depth_is_measured_to_the_limit_for_every_container() { + for wrap in [seq::<()>, map::<()>] { + let leaf = || CipherText::EmptySequence(vec![1]); + assert!(!exceeds_depth(&nested(MAX_DEPTH, wrap, leaf()))); + assert!(exceeds_depth(&nested(MAX_DEPTH + 1, wrap, leaf()))); + } + } + + #[test] + fn a_payload_continues_the_count() { + // A `()` payload sits one level below its passthrough node. + assert!(!exceeds_depth(&nested( + MAX_DEPTH - 1, + seq, + CipherText::Passthrough(()) + ))); + assert!(exceeds_depth(&nested( + MAX_DEPTH, + seq, + CipherText::Passthrough(()) + ))); + assert!(!().exceeds_depth(MAX_DEPTH)); + assert!(().exceeds_depth(MAX_DEPTH + 1)); + // So does every level inside a value payload. + let payload = |levels| { + let value = (0..levels).fold(Value::Null, |v, _| Value::Array(vec![v])); + CipherText::Passthrough(NapiValue(value)) + }; + assert!(!exceeds_depth(&nested(60, seq, payload(67)))); + assert!(exceeds_depth(&nested(60, seq, payload(68)))); + } + + #[test] + fn trees_too_deep_for_the_stack_are_measured_and_dropped_flat() { + for wrap in [seq::, map::] { + let deep = nested(1_000_000, wrap, CipherText::Single(vec![1])); + assert!(exceeds_depth(&deep)); + drop_flat(deep); + } + let value = (0..1_000_000).fold(Value::Null, |v, _| Value::Array(vec![v])); + let deep: Node = CipherText::Passthrough(NapiValue(value)); + assert!(exceeds_depth(&deep)); + drop_flat(deep); + drop_flat(nested(1_000_000, seq, CipherText::Passthrough(()))); + } +} diff --git a/packages/aead-napi/src/convert.rs b/packages/aead-napi/src/convert.rs index d2f217fa..7ef20b27 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -44,7 +44,7 @@ pub(crate) fn forbidden_key(key: &str) -> bool { matches!(key, "__proto__" | "constructor" | "prototype") } -fn depth_error() -> Error { +pub(crate) fn depth_error() -> Error { Error::new(Status::InvalidArg, "value is nested too deeply") } @@ -339,10 +339,9 @@ impl FromNapiValue for NapiValue { } } -fn value_to_js(env: &Env, value: Value, depth: usize) -> Result> { - if depth > MAX_DEPTH { - return Err(depth_error()); - } +/// Build `value` in JS. Callers check [`value_exceeds_depth`] first, which +/// bounds this recursion. +fn value_to_js(env: &Env, value: Value) -> Result> { match value { Value::Null => Null.into_unknown(env), Value::Undefined => ().into_unknown(env), @@ -391,7 +390,7 @@ fn value_to_js(env: &Env, value: Value, depth: usize) -> Result> { let mut arr = new_array(env, items.len())?; let mut elements = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { - let js_value = value_to_js(env, item, depth + 1)?; + let js_value = value_to_js(env, item)?; elements.push(own_property(env, &i.to_string(), &js_value)?); } define_own_properties(&mut arr, &elements)?; @@ -406,7 +405,7 @@ fn value_to_js(env: &Env, value: Value, depth: usize) -> Result> { if forbidden_key(&key) { return Err(forbidden_key_error(&key)); } - let js_value = value_to_js(env, value, depth + 1)?; + let js_value = value_to_js(env, value)?; properties.push(own_property(env, &key, &js_value)?); } define_own_properties(&mut obj, &properties)?; @@ -417,8 +416,7 @@ fn value_to_js(env: &Env, value: Value, depth: usize) -> Result> { // ordinary value to the application. (Round-tripping the *marking* // back into a re-encryptable JS value is future work — see // `js_to_value`, which has no passthrough constructor yet.) - // One level deeper, as the transport decoder counts it. - Value::Passthrough(inner) => value_to_js(env, *inner, depth + 1), + Value::Passthrough(inner) => value_to_js(env, *inner), _ => Err(Error::new(Status::GenericFailure, "unsupported value kind")), } } @@ -457,26 +455,109 @@ fn wrapper_to_js<'e>(env: &'e Env, key: &str, text: String) -> Result bool { + let mut stack = vec![(value, depth)]; + while let Some((value, depth)) = stack.pop() { + if depth > MAX_DEPTH { + return true; + } + match value { + Value::Array(items) => stack.extend(items.iter().map(|item| (item, depth + 1))), + Value::Object(entries) => { + stack.extend(entries.iter().map(|(_, value)| (value, depth + 1))) + } + Value::Passthrough(inner) => stack.push((inner, depth + 1)), + _ => {} + } + } + false +} + +/// Drop `value` without recursing. `Value`'s own drop recurses once per +/// level, so dropping a tree too deep to convert could overflow the stack +/// and abort Node instead of returning the depth error. +pub(crate) fn drop_value_flat(value: Value) { + let mut stack = vec![value]; + while let Some(value) = stack.pop() { + match value { + Value::Array(items) => stack.extend(items), + Value::Object(entries) => stack.extend(entries.into_iter().map(|(_, value)| value)), + Value::Passthrough(inner) => stack.push(*inner), + _ => {} + } + } +} + /// A [`Value`] payload continues the tree's depth count. impl NapiPassthrough for NapiValue { - fn to_js(self, env: &Env, depth: usize) -> Result> { - value_to_js(env, self.0, depth) + fn to_js(self, env: &Env) -> Result> { + value_to_js(env, self.0) } fn from_js(value: Unknown<'_>, depth: usize) -> Result { js_to_value(value, depth).map(NapiValue) } + + fn exceeds_depth(&self, depth: usize) -> bool { + value_exceeds_depth(&self.0, depth) + } + + fn drop_flat(self) { + drop_value_flat(self.0); + } } impl ToNapiValue for NapiValue { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - Ok(value_to_js(&Env::from_raw(env), val.0, 0)?.raw()) + if val.exceeds_depth(0) { + val.drop_flat(); + return Err(depth_error()); + } + Ok(val.to_js(&Env::from_raw(env))?.raw()) } } #[cfg(test)] mod tests { - use super::{eager_capacity, forbidden_key, MAX_EAGER_CAPACITY}; + use super::{ + drop_value_flat, eager_capacity, forbidden_key, value_exceeds_depth, Value, MAX_DEPTH, + MAX_EAGER_CAPACITY, + }; + + fn nested(levels: usize, wrap: fn(Value) -> Value) -> Value { + (0..levels).fold(Value::Bool(true), |value, _| wrap(value)) + } + + const WRAPS: [fn(Value) -> Value; 3] = [ + |v| Value::Array(vec![Value::Null, v]), + |v| Value::Object(vec![("a".into(), Value::Null), ("k".into(), v)]), + |v| Value::Passthrough(Box::new(v)), + ]; + + #[test] + fn depth_is_measured_to_the_limit_for_every_container() { + for wrap in WRAPS { + assert!(!value_exceeds_depth(&nested(MAX_DEPTH, wrap), 0)); + assert!(value_exceeds_depth(&nested(MAX_DEPTH + 1, wrap), 0)); + // Placed one level down, as a ciphertext payload is. + assert!(!value_exceeds_depth(&nested(MAX_DEPTH - 1, wrap), 1)); + assert!(value_exceeds_depth(&nested(MAX_DEPTH, wrap), 1)); + } + assert!(!value_exceeds_depth(&Value::Null, MAX_DEPTH)); + assert!(value_exceeds_depth(&Value::Null, MAX_DEPTH + 1)); + } + + #[test] + fn trees_too_deep_for_the_stack_are_measured_and_dropped_flat() { + for wrap in WRAPS { + let deep = nested(1_000_000, wrap); + assert!(value_exceeds_depth(&deep, 0)); + drop_value_flat(deep); + } + } #[test] fn forbidden_keys_are_rejected() { diff --git a/packages/aead-napi/tests/addon/src/lib.rs b/packages/aead-napi/tests/addon/src/lib.rs index 7fa2b135..fb1d3f57 100644 --- a/packages/aead-napi/tests/addon/src/lib.rs +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -101,3 +101,13 @@ pub fn read_ciphertext(#[napi(ts_arg_type = "unknown")] ct: JsCipherText pub fn read_unit_ciphertext(#[napi(ts_arg_type = "unknown")] ct: JsCipherText, ()>) { drop(ct); } + +/// `levels` ciphertext sequences around a passthrough node with no payload. +#[napi(ts_return_type = "unknown")] +pub fn unit_ciphertext(levels: u32) -> JsCipherText, ()> { + let mut node = CipherText::Passthrough(()); + for _ in 0..levels { + node = CipherText::Sequence(vec![node]); + } + JsCipherText(node) +} diff --git a/packages/aead-napi/tests/object-safety.cjs b/packages/aead-napi/tests/object-safety.cjs index 1f752803..f71c6f5d 100644 --- a/packages/aead-napi/tests/object-safety.cjs +++ b/packages/aead-napi/tests/object-safety.cjs @@ -275,3 +275,25 @@ assert.throws(() => addon.readCiphertext(payloadTree(68)), /nested too deeply/); // With no passthrough payload type, a passthrough node must carry undefined. addon.readUnitCiphertext({ t: 'pt', v: undefined }); assert.throws(() => addon.readUnitCiphertext({ t: 'pt', v: 1 }), /malformed ciphertext/); + +// A `()` payload counts like any other: it sits one level below its +// passthrough node, so 127 sequences are the most it allows. +addon.unitCiphertext(127); +assert.throws(() => addon.unitCiphertext(128), /nested too deeply/); +addon.readUnitCiphertext(nest(127, (v) => ({ t: 'seq', v: [v] }), { t: 'pt' })); +assert.throws( + () => addon.readUnitCiphertext(nest(128, (v) => ({ t: 'seq', v: [v] }), { t: 'pt' })), + /nested too deeply/, +); + +// A Rust-built tree far too deep for the stack is refused cleanly: it is +// measured and then dropped without recursing once per level, so neither +// step can overflow the stack and abort Node. +const HUGE = 1_000_000; +for (const wrap of ['array', 'object', 'passthrough']) { + assert.throws(() => addon.nestedValue(HUGE, wrap), /nested too deeply/, wrap); +} +for (const wrap of ['seq', 'map']) { + assert.throws(() => addon.nestedCiphertext(HUGE, wrap), /nested too deeply/, wrap); +} +assert.throws(() => addon.ciphertextWithPayload(60, HUGE), /nested too deeply/);