Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0494580
feat(prf): derive canonical equality from values
coderdan Oct 8, 2026
d1f3417
test(prf): cover canonical domain mutations
coderdan Oct 8, 2026
410a02e
feat(bindings): preserve extended scalar kinds
coderdan Oct 8, 2026
82b5cfb
chore: carry ValueKind::ALL slice fix into stack
coderdan Oct 9, 2026
6498f33
feat(prf)!: make Value equality terms an opt-in `value` feature
coderdan Oct 9, 2026
9675398
fix(aead-value): match equal integers across widths in equality terms
coderdan Oct 9, 2026
e8c9c98
chore: carry review fixes from #378 and #380 into stack
coderdan Oct 9, 2026
73eb0d1
fix(aead-napi): keep scalar wrappers lossless and their errors typed
coderdan Oct 9, 2026
47176be
perf(go): look up scalar widths without allocating on decode
coderdan Oct 9, 2026
d052293
fix(aead-value): match equal floats across widths in equality terms
coderdan Oct 9, 2026
69395bc
chore: carry shared float equality domain into stack
coderdan Oct 9, 2026
26015d6
docs(aead-napi): steer integer data to BigInt for equality terms
coderdan Oct 9, 2026
cf58b08
test(aead-napi): move wrapper payload errors out of the N-API glue
coderdan Oct 9, 2026
3a25b64
test: check shared corpus values, not only byte round trips
coderdan Oct 9, 2026
7487f9f
fix(go): keep float32 signaling NaN bits when encoding
coderdan Oct 9, 2026
a07e2a5
test(go): fuzz the codec for exact re-encoding, nightly
coderdan Oct 9, 2026
401107a
test(aead-value): property-test the value codec
coderdan Oct 9, 2026
84c1627
ci(mutants): skip equivalent | to ^ mutants in widen_f32
coderdan Oct 9, 2026
0819dbe
Merge branch 'feat/373-canonical-equality' into feat/375-binding-valu…
coderdan Oct 9, 2026
edf3966
ci(fuzz): open an issue when nightly fuzzing fails
coderdan Oct 9, 2026
1b10b09
test(vcencrypt): fuzz Go and Rust value decoders against each other
coderdan Oct 9, 2026
fc15187
ci(fuzz): run the differential targets nightly against a fresh guest
coderdan Oct 9, 2026
3bcc8c9
fix(vcffi): keep signaling NaN bits of named float32 types
coderdan Oct 9, 2026
8b8102a
test(vcffi): only tolerate Undefined becoming Null at real Undefined …
coderdan Oct 9, 2026
7da33f4
Merge branch 'feat/375-binding-value-kinds' into feat/383-differentia…
coderdan Oct 9, 2026
6150839
test(vcencrypt): only tolerate Undefined becoming Null at real Undefi…
coderdan Oct 9, 2026
fdb5995
Merge pull request #384 from cipherstash/feat/383-differential-fuzzing
coderdan Oct 9, 2026
505a805
test(aead-napi): cover object handling at the N-API boundary in Node
coderdan Oct 9, 2026
98d181f
refactor(aead-napi): move conversions onto napi-rs's safe API
coderdan Oct 9, 2026
de1fc15
test(aead-napi): gate the N-API conversions on mutation testing
coderdan Oct 9, 2026
3b5880e
fix(aead-napi): close prototype and lossy-key leaks at the N-API boun…
coderdan Oct 9, 2026
f75f660
fix(aead-napi)!: read ciphertext nodes from own fields and carry dept…
coderdan Oct 9, 2026
ec44dfc
fix(aead-napi): check ownership immediately before each property read
coderdan Oct 9, 2026
6627f86
fix(aead-napi): measure and drop deep Rust-built trees without recursing
coderdan Oct 9, 2026
68598d9
Merge pull request #386 from cipherstash/refactor/aead-napi-unsafe
coderdan Oct 9, 2026
9dd633c
Merge pull request #381 from cipherstash/feat/375-binding-value-kinds
coderdan Oct 9, 2026
a24a8e7
chore(prf): carry rebased value model into stack
coderdan Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .cargo-crap.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand All @@ -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",
]
27 changes: 8 additions & 19 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/crap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
109 changes: 109 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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-<target>\`
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
15 changes: 15 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/mutants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions bindings/go/vcencrypt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading