diff --git a/.cargo-crap.toml b/.cargo-crap.toml index a9520d9e..20a5ca65 100644 --- a/.cargo-crap.toml +++ b/.cargo-crap.toml @@ -53,17 +53,15 @@ 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`, -# 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 +# `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 +# `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/**", @@ -72,7 +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/.cargo/mutants.toml b/.cargo/mutants.toml index 4c616ebe..5d328f82 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -37,26 +37,15 @@ 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 + # 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/.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/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..5cd62309 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,109 @@ +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} + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + name: "🐹 Go codec fuzzing" + strategy: + fail-fast: false + matrix: + # `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 + cache-dependency-path: | + bindings/go/vcencrypt/go.sum + bindings/go/vcencrypt/examples/go.sum + + - name: fuzz ${{ matrix.target }} + working-directory: ${{ matrix.dir }} + 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: ${{ matrix.dir }}/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 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" + else + gh issue create --title "Nightly fuzzing found a failure" --label fuzz --body "$body" + fi 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 5a12e317..d9648be4 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: @@ -96,6 +100,16 @@ jobs: cargo test -p vitaminc-aead-value --features rust_decimal && 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 --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 738766b7..1088f43d 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" @@ -2627,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", @@ -2639,7 +2672,13 @@ name = "vitaminc-aead-value" version = "0.5.1" dependencies = [ "chrono", + "orderable-bytes", + "quickcheck", + "quickcheck_macros", "rust_decimal", + "serde_json", + "unicode-general-category", + "unicode-normalization", "vitaminc-aead", "vitaminc-encrypt", "vitaminc-protected", @@ -2683,12 +2722,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 +2780,7 @@ dependencies = [ "quickcheck", "quickcheck_macros", "thiserror", + "vitaminc-aead-value", "vitaminc-context", "vitaminc-protected", ] 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/differential_fuzz_test.go b/bindings/go/vcencrypt/differential_fuzz_test.go new file mode 100644 index 00000000..d1bf32fd --- /dev/null +++ b/bindings/go/vcencrypt/differential_fuzz_test.go @@ -0,0 +1,350 @@ +package vcencrypt + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "os" + "slices" + "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 +} + +// 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() + want := bytes.Clone(rustOut) + for _, at := range undefinedTags(rustOut) { + want[at] = 0x00 + } + 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) { + 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) + } + requireGoMatchesRust(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) + } + } + }) +} + +// 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) + } + } +} 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/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/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/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") diff --git a/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm b/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm index af5a63eb..2974e29f 100755 Binary files a/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm and b/bindings/go/vcencrypt/wasm/vitaminc_guest.wasm differ diff --git a/bindings/go/vcffi/README.md b/bindings/go/vcffi/README.md index 525a8c47..a855ec11 100644 --- a/bindings/go/vcffi/README.md +++ b/bindings/go/vcffi/README.md @@ -47,8 +47,15 @@ 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 +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, @@ -143,3 +150,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..c80704a2 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" @@ -35,12 +36,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 +1006,36 @@ 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) + } + } +} + +// 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) + value, err := Unmarshal(wire) + if err != nil { + t.Fatal(err) + } + 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) + } + if !bytes.Contains(encoded, wire) { + t.Fatalf("%08x re-encoded as %x", bits, encoded) + } } } } diff --git a/bindings/go/vcffi/conformance_test.go b/bindings/go/vcffi/conformance_test.go new file mode 100644 index 00000000..0567d0f3 --- /dev/null +++ b/bindings/go/vcffi/conformance_test.go @@ -0,0 +1,180 @@ +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 { + t.Fatal(err) + } + var corpus struct { + Vectors []struct { + Name, Kind, Transport string + Value json.RawMessage + 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) + } + 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 + } + // 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) + } + } + }) + } + 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..6f95bc2e 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,20 +565,35 @@ 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 // 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. 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())) + } case reflect.Float64: enc.Float64(rv.Float()) case reflect.String: @@ -664,3 +697,14 @@ func encodeStruct(enc Encoder, rv reflect.Value) { } _ = m.End() } + +// 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 + } + return rv.Interface() +} diff --git a/bindings/go/vcffi/fuzz_test.go b/bindings/go/vcffi/fuzz_test.go index c8dd4eaf..c7c69455 100644 --- a/bindings/go/vcffi/fuzz_test.go +++ b/bindings/go/vcffi/fuzz_test.go @@ -1,15 +1,132 @@ package vcffi import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "slices" "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, 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() + want := bytes.Clone(data) + for _, at := range undefinedAt { + want[at] = tagNull + } + 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) { // Seeds: one of each node kind, plus known-hostile shapes. @@ -21,15 +138,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, valueUndefinedTags(data)) + // 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 +173,42 @@ 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, 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) + } + } +} diff --git a/bindings/go/vcffi/scalars.go b/bindings/go/vcffi/scalars.go new file mode 100644 index 00000000..fa6f3c02 --- /dev/null +++ b/bindings/go/vcffi/scalars.go @@ -0,0 +1,137 @@ +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) { + b, err := r.take(scalarWidth(tag)) + 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 + } +} + +// 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 +} 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/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") 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 76a8ff36..b1f4427a 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 3684a95d..aed0abaa 100644 --- a/packages/aead-napi/README.md +++ b/packages/aead-napi/README.md @@ -13,61 +13,106 @@ 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`. + Equality terms share one domain across integer widths, so a `BigInt` + 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 + precision is discarded. Years outside 0000–9999 use an ISO 8601 expanded + year (`+12000-01-01T00:00:00.000000001Z`), accepted on input as well. + - 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, including a wrapper whose payload is not a string, + have stable `ERR_INTEGER_RANGE`, `ERR_INVALID_DATE`, + `ERR_INVALID_TIMESTAMP` and `ERR_INVALID_DECIMAL` codes. + - **The wrapper keys are reserved.** An object with exactly one own + enumerable property named `date`, `timestamp` or `decimal` is always read + as a wrapper. Other languages can store such an object as ordinary data, + so decrypting it in JS and encrypting it again is lossy: Go's + `{"date": "2024-01-01"}` comes back as a `date` scalar, and + `{"date": "tomorrow"}` throws `ERR_INVALID_DATE`. Avoid these one-key + shapes in data that crosses languages. - **`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 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. ## Testing the conversion layer -The `Encrypt`/`Decrypt` impls and the value tree are covered by ordinary Rust -unit tests. A live Node test converts a sample for every `ValueKind::ALL` -entry through `NapiValue::to_napi_value` and checks its JavaScript value. -Adding a kind without a sample or a working output conversion fails CI. -It also covers `Null`, `Undefined`, and `Passthrough`, which have no kind. +`cargo test -p vitaminc-aead-napi` runs pure Rust boundary tests and builds a +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. -Run the same test locally (use `.dylib` instead of `.so` on macOS): +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. + +A second live Node test converts a sample for every `ValueKind::ALL` entry +through `NapiValue::to_napi_value` and checks its JavaScript value, so adding +a kind without a sample or a working output conversion fails CI. It also +covers `Null`, `Undefined`, and `Passthrough`, which have no kind. Run it +locally (use `.dylib` instead of `.so` on macOS): ```sh cargo build --locked -p vitaminc-aead-napi --example kind_inventory node packages/aead-napi/tests/kind_inventory.cjs target/debug/examples/libkind_inventory.so ``` -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 covered by the Rust unit tests: each takes a live `napi_env`, `Unknown`, or `Object`, -which only exists inside a running V8 isolate. The `napi/noop` dev feature -disables automatic addon registration, and `dyn-symbols` loads Node-API -symbols when the test addon runs in Node. Neither supplies a Node environment -to Rust unit tests. These functions remain exempted in `.cargo-crap.toml` and -`.cargo/mutants.toml`, by name rather than by file so the rest of those -modules stays gated. - -The kind inventory test covers the output mapping and basic recursive -conversion. Input conversion, ciphertext conversion, and boundary rejection -cases still need JS-level tests driving a built addon; review changes to -those paths by hand until that coverage lands. +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`, `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 +- 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__`, - `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, 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 @@ -83,14 +128,20 @@ those paths by hand until that coverage lands. - 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/examples/kind_inventory.rs b/packages/aead-napi/examples/kind_inventory.rs index 84d4c56d..64c7cb64 100644 --- a/packages/aead-napi/examples/kind_inventory.rs +++ b/packages/aead-napi/examples/kind_inventory.rs @@ -1,13 +1,13 @@ //! 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; use vitaminc_protected::Protected; -fn sample(kind: ValueKind) -> Result> { - Ok(Some(match kind { +fn sample(kind: ValueKind) -> Result { + Ok(match kind { ValueKind::Bool => Value::Bool(true), ValueKind::Int32 => Value::Int32(-32), ValueKind::Int64 => Value::Int64(i64::MIN), @@ -19,24 +19,24 @@ fn sample(kind: ValueKind) -> Result> { ValueKind::Bytes => Value::Bytes(Protected::new(vec![0, 255])), ValueKind::Array => Value::Array(vec![Value::Bool(true)]), ValueKind::Object => Value::Object(vec![("answer".into(), Value::UInt32(42))]), - // The Node mappings for these kinds land with #375. Listing them keeps - // any other new kind failing below until it has a sample. - ValueKind::Int8 - | ValueKind::UInt8 - | ValueKind::Int16 - | ValueKind::UInt16 - | ValueKind::Int128 - | ValueKind::UInt128 - | ValueKind::Date - | ValueKind::Timestamp - | ValueKind::Decimal => return Ok(None), + ValueKind::Int8 => Value::Int8(i8::MIN), + ValueKind::UInt8 => Value::UInt8(u8::MAX), + ValueKind::Int16 => Value::Int16(i16::MIN), + ValueKind::UInt16 => Value::UInt16(u16::MAX), + ValueKind::Int128 => Value::Int128(i128::MIN), + ValueKind::UInt128 => Value::UInt128(u128::MAX), + ValueKind::Date => { + Value::Date(chrono::NaiveDate::from_ymd_opt(2024, 2, 29).expect("valid sample date")) + } + ValueKind::Timestamp => Value::Timestamp(chrono::DateTime::UNIX_EPOCH), + ValueKind::Decimal => Value::Decimal("1.50".parse().expect("valid sample decimal")), _ => { return Err(Error::new( Status::GenericFailure, format!("add a Node conversion sample for {kind}"), )); } - })) + }) } fn convert_samples(env: sys::napi_env) -> Result { @@ -45,9 +45,7 @@ fn convert_samples(env: sys::napi_env) -> Result { // Drive coverage from the upstream inventory: a newly added kind must // have both a sample here and a working production ToNapiValue mapping. for &kind in ValueKind::ALL { - let Some(value) = sample(kind)? else { - continue; - }; + let value = sample(kind)?; if value.kind() != Some(kind) { return Err(Error::new( Status::GenericFailure, @@ -63,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..7825e7c5 100644 --- a/packages/aead-napi/src/ciphertext.rs +++ b/packages/aead-napi/src/ciphertext.rs @@ -26,13 +26,15 @@ //! 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, JsObjectValue, JsValue, Object, ToNapiValue, Unknown, +}; 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, 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"; @@ -47,51 +49,130 @@ 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. +/// +/// 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. [`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. It is +/// a single node, so it is too deep only past the limit itself. +impl NapiPassthrough for () { + fn to_js(self, env: &Env) -> Result> { + ().into_unknown(env) + } + + 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(()) + } else { + Err(shape_error()) + } + } + + fn exceeds_depth(&self, depth: usize) -> bool { + depth > MAX_DEPTH + } + + fn drop_flat(self) {} +} + +/// 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") +} + fn shape_error() -> Error { Error::new(Status::InvalidArg, "malformed ciphertext value") } -fn node_to_js(env: sys::napi_env, node: CipherText) -> 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: ToNapiValue, + P: NapiPassthrough, { - let raw_env = Env::from_raw(env); - let mut obj = Object::new(&raw_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()))?; - } + 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 = raw_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, NodeHandle(item))?; + let node = node_to_js(env, item)?; + 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 map = Object::new(&raw_env)?; + 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 @@ -102,72 +183,79 @@ 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(&map, &key, js_value)?; + let node = node_to_js(env, value)?; + properties.push(own_property(env, &key, &node)?); } - obj.set(V_KEY, map)?; - } - CipherText::Passthrough(p) => { - obj.set(T_KEY, T_PASSTHROUGH)?; - obj.set(V_KEY, PassthroughHandle(p))?; + define_own_properties(&mut map, &properties)?; + (T_MAP, map.into_unknown(env)?) } - } - unsafe { Object::to_napi_value(env, obj) } + 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`). + 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) } 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 +impl ToNapiValue for JsCipherText where Leaf: AsRef<[u8]>, - P: ToNapiValue, + P: NapiPassthrough, { unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - node_to_js(env, val.0) + 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()) } } -/// 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) } +/// 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) } -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) +/// 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>, - P: FromNapiValue, + P: NapiPassthrough, { if depth > MAX_DEPTH { - return Err(Error::new( - Status::InvalidArg, - "ciphertext is nested too deeply", - )); + return Err(ciphertext_depth_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), @@ -177,7 +265,9 @@ 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(); // Capacity is granted lazily: the reported length is // attacker-cheap (`{t:"seq", v:new Array(2**32-1)}` materialises @@ -185,20 +275,19 @@ 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. - let element = arr.get::(i)?.ok_or_else(shape_error)?; - if element.get_type()? != ValueType::Object { + // 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 = Object::from_unknown(element)?; - items.push(node_from_js(&element, depth + 1)?); + let element = arr.get::(i)?.ok_or_else(shape_error)?; + 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(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 @@ -206,27 +295,31 @@ 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)) } 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)?; - let p = P::from_unknown(value)?; - Ok(CipherText::Passthrough(p)) + // `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()), } @@ -235,10 +328,87 @@ 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 { - let obj = unsafe { Object::from_napi_value(env, napi_val) }?; + // SAFETY: napi-rs calls this with the `env` and `napi_val` of the + // 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)?)) } } + +#[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 6c52ffe7..7ef20b27 100644 --- a/packages/aead-napi/src/convert.rs +++ b/packages/aead-napi/src/convert.rs @@ -6,42 +6,36 @@ //! //! # 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`]. +//! 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" }`. +//! 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. //! -//! 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. +//! 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, - 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, Result, Status, ValueType}; use vitaminc_aead_value::Value; use vitaminc_protected::{Controlled, Protected}; +use crate::ciphertext::NapiPassthrough; +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; -/// 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 @@ -50,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") } @@ -74,7 +68,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 @@ -82,34 +83,59 @@ 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> { - 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 { +/// +/// 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( + 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 handle = names.get_element::(i)?; + if handle.get_type()? == ValueType::Symbol { + continue; + } + let name = lossless_string(handle, "property name")?; + keys.push(OwnKey { handle, name }); + } + Ok(keys) +} + +/// 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::GenericFailure, - "failed to enumerate object properties", + Status::InvalidArg, + format!("property was removed while it was being read: {}", key.name), )); } - 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); - } - Ok(keys) + 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 @@ -119,31 +145,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,55 +190,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. -pub(crate) fn define_own_property( - obj: &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: sys::napi_value, -) -> 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(()) + value: &V, +) -> 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 { @@ -245,40 +233,14 @@ 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 - // `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()? { @@ -316,23 +278,44 @@ 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 let [key] = keys.as_slice() { + use scalar::ConversionError::{InvalidDate, InvalidDecimal, InvalidTimestamp}; + let parsed = match key.name.as_str() { + "date" => Some( + wrapper_text(&obj, key, InvalidDate)? + .and_then(|text| scalar::date(&text)), + ), + "timestamp" => Some( + wrapper_text(&obj, key, InvalidTimestamp)? + .and_then(|text| scalar::timestamp(&text)), + ), + "decimal" => Some( + wrapper_text(&obj, key, InvalidDecimal)? + .and_then(|text| scalar::decimal(&text)), + ), + _ => None, + }; + if let Some(parsed) = parsed { + return parsed + .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) { - 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)) } @@ -346,45 +329,51 @@ 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 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: sys::napi_env, value: Value) -> Result { +/// 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 => 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)) }, - // 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::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) => { + // 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 + { + Ok(env.create_date(v.timestamp_millis() as f64)?.to_unknown()) } 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) => { @@ -392,26 +381,24 @@ 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 = new_array(env, items.len())?; + let mut elements = Vec::with_capacity(items.len()); for (i, item) in items.into_iter().enumerate() { - let js = ValueHandle(item); - arr.set(i as u32, js)?; + let js_value = value_to_js(env, item)?; + elements.push(own_property(env, &i.to_string(), &js_value)?); } - unsafe { Array::to_napi_value(env, arr) } + define_own_properties(&mut arr, &elements)?; + 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)?; + 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. @@ -419,9 +406,10 @@ 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)?; + properties.push(own_property(env, &key, &js_value)?); } - unsafe { Object::to_napi_value(env, obj) } + define_own_properties(&mut obj, &properties)?; + 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 @@ -433,26 +421,143 @@ fn value_to_js(env: sys::napi_env, value: Value) -> Result { } } -/// Internal newtype so recursive positions (array elements, object values) -/// can go through `ToNapiValue` without exposing a blanket recursive impl -/// signature difference. -struct ValueHandle(Value); +/// The string payload of a one-key scalar wrapper. Any other payload +/// (`{ 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<'e>( + obj: &Object<'e>, + key: &OwnKey<'e>, + invalid: scalar::ConversionError, +) -> Result> { + 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( + invalid, + Some(f64::from_unknown(input)?), + )), + _ => Err(scalar::non_string_payload(invalid, None)), + }) +} -impl ToNapiValue for ValueHandle { - unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { - value_to_js(env, val.0) +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<'e>(env: &'e Env, key: &str, text: String) -> Result> { + let mut obj = Object::new(env)?; + let value = text.into_unknown(env)?; + define_own_properties(&mut obj, &[own_property(env, key, &value)?])?; + obj.into_unknown(env) +} + +/// Whether `value`, placed at `depth`, nests past [`MAX_DEPTH`], counted as +/// the transport decoder counts it (a passthrough's inner value is one level +/// deeper). Iterative, so a tree of any depth is measured without recursion. +pub(crate) fn value_exceeds_depth(value: &Value, depth: usize) -> 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) -> 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 { - value_to_js(env, val.0) + 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, MAX_SAFE_INTEGER}; + 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() { @@ -464,12 +569,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..a09bc5df 100644 --- a/packages/aead-napi/src/lib.rs +++ b/packages/aead-napi/src/lib.rs @@ -1,10 +1,16 @@ #![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; +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/src/scalar.rs b/packages/aead-napi/src/scalar.rs new file mode 100644 index 00000000..0008e9af --- /dev/null +++ b/packages/aead-napi/src/scalar.rs @@ -0,0 +1,365 @@ +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) + } +} + +/// 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)?; + if value.to_string() != text { + return Err(ConversionError::InvalidDate); + } + Ok(Value::Date(value)) +} + +pub(crate) fn timestamp(text: &str) -> Result { + // 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) +} + +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()); + // 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 + ); + 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 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"] { + 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..06607f46 --- /dev/null +++ b/packages/aead-napi/tests/addon/Cargo.lock @@ -0,0 +1,1050 @@ +# 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", + "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..7489682b --- /dev/null +++ b/packages/aead-napi/tests/addon/Cargo.toml @@ -0,0 +1,21 @@ +[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 = { path = "../../../aead" } +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..fb1d3f57 --- /dev/null +++ b/packages/aead-napi/tests/addon/src/lib.rs @@ -0,0 +1,113 @@ +use napi::bindgen_prelude::Buffer; +use napi_derive::napi; +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}; + +#[napi] +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"))?; + 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)) + .map(NapiValue) + .map_err(|_| napi::Error::from_reason("transport decode failed")) +} + +#[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())) +} + +/// 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 +} + +/// `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) +} + +/// `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); +} + +/// `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/kind_inventory.cjs b/packages/aead-napi/tests/kind_inventory.cjs index 1db9889b..a51f4ecd 100644 --- a/packages/aead-napi/tests/kind_inventory.cjs +++ b/packages/aead-napi/tests/kind_inventory.cjs @@ -11,16 +11,25 @@ try { const samples = require(addon); assert.deepStrictEqual(samples, { bool: true, - int32: -32, + int32: -32n, int64: -(1n << 63n), - uint32: 0xffffffff, + uint32: 0xffffffffn, uint64: (1n << 64n) - 1n, float32: 1.5, float64: 2.5, string: 'hello', bytes: Buffer.from([0, 255]), + int8: -128n, + uint8: 255n, + int16: -32768n, + uint16: 65535n, + int128: -(1n << 127n), + uint128: (1n << 128n) - 1n, + date: { date: '2024-02-29' }, + timestamp: new Date(0), + decimal: { decimal: '1.50' }, array: [true], - object: { answer: 42 }, + object: { answer: 42n }, null: null, undefined: undefined, passthrough: true, diff --git a/packages/aead-napi/tests/node.rs b/packages/aead-napi/tests/node.rs new file mode 100644 index 00000000..ae0bcc77 --- /dev/null +++ b/packages/aead-napi/tests/node.rs @@ -0,0 +1,45 @@ +//! 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"); + 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..f71c6f5d --- /dev/null +++ b/packages/aead-napi/tests/object-safety.cjs @@ -0,0 +1,299 @@ +// 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; +} + +// 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 })); + +// 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; +} +{ + 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, converting JS to Rust +// and 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); + // Reading alone, so the output limit cannot be what refuses it. + assert.throws( + () => addon.readCiphertext(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']); + +// 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; +} + +// 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]; + } +} + +// 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 { + 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/); + +// 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/); diff --git a/packages/aead-napi/tests/value-conformance.cjs b/packages/aead-napi/tests/value-conformance.cjs new file mode 100644 index 00000000..a56ea003 --- /dev/null +++ b/packages/aead-napi/tests/value-conformance.cjs @@ -0,0 +1,69 @@ +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); + // 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 = [ + [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')); +// 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/packages/aead-value/Cargo.toml b/packages/aead-value/Cargo.toml index 33e5cac2..7a529908 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,25 @@ features = ["std"] optional = true [dev-dependencies] +serde_json = "1" +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 + +# 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" +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/README.md b/packages/aead-value/README.md index 189909b2..d1dbb799 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/src/canonical.rs b/packages/aead-value/src/canonical.rs new file mode 100644 index 00000000..9a0276df --- /dev/null +++ b/packages/aead-value/src/canonical.rs @@ -0,0 +1,256 @@ +//! Canonical scalar plaintext for search terms, separate from reversible ciphertext. +//! +//! 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. + +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 {} + +/// 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` 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. + 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 | ValueKind::Float64 => Some(domain::FLOAT), + 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. +/// +/// 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`. 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 +/// a variable-iteration call to `Decimal::normalize`. +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. + 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::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")] + Value::Timestamp(v) => (domain::TIMESTAMP, timestamp(v)), + #[cfg(feature = "rust_decimal")] + 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> +where + for<'a> T::Bytes<'a>: Zeroize, +{ + let bytes = Protected::new(value.to_orderable_bytes()); + Protected::new(bytes.risky_ref().as_ref().to_vec()) +} + +/// `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 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. + 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; + 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).wrapping_add(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. +/// +/// 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 + .chars() + .any(|c| get_general_category(c) == GeneralCategory::Unassigned) + { + return Err(CanonicalError::UnassignedCodePoint); + } + // 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 + .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..76dc86de --- /dev/null +++ b/packages/aead-value/src/canonical_tests.rs @@ -0,0 +1,364 @@ +use super::*; +use quickcheck_macros::quickcheck; + +fn bytes(value: Value) -> Vec { + 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() { + // 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!( + 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_input(&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::Float64(0.0))); + for bits in [0x7fc0_0000, 0xff80_0001, 0xffff_ffff, 0x7f80_0001] { + assert_eq!( + bytes(Value::Float32(f32::from_bits(bits))), + [0xff, 0xf8, 0, 0, 0, 0, 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] + ); + } + 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, 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) { + 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") +} + +#[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 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] +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/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); +} diff --git a/packages/aead-value/tests/conformance.rs b/packages/aead-value/tests/conformance.rs new file mode 100644 index 00000000..340d443c --- /dev/null +++ b/packages/aead-value/tests/conformance.rs @@ -0,0 +1,139 @@ +#![cfg(all(feature = "chrono", feature = "rust_decimal"))] + +use vitaminc_aead_value::{ + transport::{decode_value, encode_value, Reader}, + Value, ValueKind, +}; +use vitaminc_protected::{Controlled, Protected}; + +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() +} + +/// 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 = + 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 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}"); + } + 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/packages/hmac/Cargo.toml b/packages/hmac/Cargo.toml index c5aa6c29..e9220521 100644 --- a/packages/hmac/Cargo.toml +++ b/packages/hmac/Cargo.toml @@ -19,6 +19,10 @@ 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"] } 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..89ad719b --- /dev/null +++ b/packages/hmac/tests/value_equality.rs @@ -0,0 +1,194 @@ +//! 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; 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}; +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/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", + ), + ( + Value::Int16(-7), + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", + ), + ( + Value::Int32(-7), + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", + ), + ( + Value::Int64(-7), + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", + ), + ( + Value::Int128(-7), + "vitaminc/prf/value/integer-orderable/v1", + "00fffffffffffffffffffffffffffffff9", + "e025b480a9b3b10b709056fd6fc5ec4ca44c04476d4ebed8360110fec3a0e511", + ), + ( + Value::UInt8(7), + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", + ), + ( + Value::UInt16(7), + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", + ), + ( + Value::UInt32(7), + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", + ), + ( + Value::UInt64(7), + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", + ), + ( + Value::UInt128(7), + "vitaminc/prf/value/integer-orderable/v1", + "0100000000000000000000000000000007", + "8e9c7310e99f566c03e43de34c13cd21fe0e3fc6fb03cf7a6c2f31b86e336a5a", + ), + ( + Value::Float32(1.5), + "vitaminc/prf/value/float-orderable/v1", + "bff8000000000000", + "8a4e5233cd0f982178536465a0ced2b2d3451198a57da553970a17a0069be1e1", + ), + ( + Value::Float64(-1.5), + "vitaminc/prf/value/float-orderable/v1", + "4007ffffffffffff", + "cf40d5d1d3da65f02bb0ceaabebce6c2569a129cb890e19a9b4dbacefb763adb", + ), + ( + 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_input(&value).unwrap().bytes.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..d202c72e 100644 --- a/packages/prf/Cargo.toml +++ b/packages/prf/Cargo.toml @@ -11,7 +11,15 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[features] +default = [] +# `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"], optional = true } 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..033334b0 100644 --- a/packages/prf/README.md +++ b/packages/prf/README.md @@ -108,3 +108,43 @@ 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` + +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 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. 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 +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 +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..1289ba42 100644 --- a/packages/prf/src/error.rs +++ b/packages/prf/src/error.rs @@ -29,8 +29,15 @@ 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}")] 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..66a8c8f8 100644 --- a/packages/prf/src/lib.rs +++ b/packages/prf/src/lib.rs @@ -7,8 +7,13 @@ 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)] mod test_backend; diff --git a/packages/prf/src/value.rs b/packages/prf/src/value.rs new file mode 100644 index 00000000..a80bff5d --- /dev/null +++ b/packages/prf/src/value.rs @@ -0,0 +1,125 @@ +//! Equality terms for the language-neutral value model. + +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. 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 { + match equality_domain(kind) { + Some(domain) => Some(Self::new(domain)), + None => None, + } + } +} + +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. + // It returns each term's domain with its bytes. + match equality_input(self) { + Ok(EqualityInput { domain, bytes }) => prf.prf_bytes_vec( + bytes, + PrfEncoding::new(domain), + context.into_prf_context().into_owned(), + visitor, + ), + Err(error) => prf.failure(PrfError::Canonical(error)), + } + } +} + +#[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/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/integer-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, + "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)) + )); + } +} diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 00000000..c9fcabd7 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,28 @@ +# 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, 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. +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..7386ba4c --- /dev/null +++ b/testdata/value-conformance.json @@ -0,0 +1,309 @@ +{ + "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": "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", + "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" + } + ] +}