Expand description
§vitaminc-aead-napi
NAPI (Node-API) bindings layer for the Vitamin-C AEAD encryption — the
JS-specific piece of exposing vitaminc encryption to Node.js.
The data model is the FFI boundary (ADR 0001): a value coming in is a
Encrypt source and a value going out is built by a Sink, and
nothing sits in between. This crate supplies the JavaScript side of both:
JsRef<'e>— a live JavaScript value as anEncrypt. The walk runs on the JavaScript thread, reading each value as it goes, so no value tree is built and a scalar leaves the engine only to go straight to the sink.encrypt(JsRef::new(value), &cipher, aad)seals a value in place. To encrypt off the JavaScript thread,JsRef::to_framesnapshots the value as a frame there, and a worker seals the frame with no JavaScript access.JsBuilder<'e>— aSinkthat builds JavaScript values, fordecrypt_into(&cipher, ct, aad, JsBuilder::new(&env)), orJsBuilder::build(&env, frame)for a frame.JsCipherText<Leaf, P>— projects the genericCipherTextcontainer onto plain JS values ({ t, v }nodes withBufferleaves) and back, as an in-memory/application-side representation. Durable cross-language database storage is the EQL layer’s job, not this projection’s. The passthrough payloadPconverts throughNapiPassthrough, implemented forPassthroughFrame(the sink-based tree’s payload) and(), which applies the tree’s nesting limit to the payload too.Wrappers— the wrapper classesVcDate,VcTimestamp,VcDecimalandVcUuid, defined once per environment and exported by an addon withWrappers::exports. The crate keeps them in its own registry, keyed by the environment and released by an environment cleanup hook; the addon’s instance data is untouched.
§The mapping
| JavaScript | Scalar |
|---|---|
null | null |
undefined | null, logical type Undefined |
boolean | bool |
number | float (F64) |
bigint | int, at the narrowest fitting width |
string | text |
Buffer, Uint8Array | bytes (any other view is refused) |
Date | Timestamp(Millis) over the i64 count |
VcDate("YYYY-MM-DD") | Date over the day count |
VcTimestamp("RFC 3339, ns") | Timestamp(Nanos) over the count |
VcDecimal("1.50") | Decimal { scale } over the mantissa |
VcUuid("hyphenated hex") | Uuid over sixteen bytes |
Array | sequence |
| plain object | map, over own enumerable string keys |
And back: every int width is a BigInt; both float widths are a number
(f32 widens exactly); a timestamp in any unit is a Date when it is
whole milliseconds and a VcTimestamp otherwise, so no precision is
discarded; a calendar date, a decimal and a UUID are their wrapper. A
Date is stored as its millisecond count and read back as the same
count, never converted through seconds and nanoseconds. A logical type
this build does not know has no JavaScript value and is refused rather
than shown with its type dropped.
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 does not match an int64 5: use BigInt for
integer data.
§The wrapper classes
JavaScript has no type of its own for a calendar date, a timestamp finer
than a millisecond, a decimal or a UUID, so each is an instance of a small
class holding its text: new VcDecimal("1.50"). The constructor takes a
string and nothing else; the text is checked, and the scalar built from
it, when the value is encrypted, so one rule applies however the instance
was made. value is an own, read-only, enumerable property, so
JSON.stringify (which gives the bare text through toJSON),
structuredClone and a deep equality all see it. A clone, or a wrapper
posted to a Worker, is a plain { value } object and encrypts as a map;
rebuild it with the constructor on the other side. The classes, their
prototypes and every instance are frozen, and construction uses what the
class captured when it was defined, so a polluted Object.prototype or a
later edit of a class cannot make value writable, break a decrypt, or
turn a plain { value } object back into a wrapper.
An instance is not a plain object, so it is never confused with data
another language wrote as an ordinary one-key object: Go’s
{"date": "2024-01-01"} decrypts in JS as that object and encrypts again
unchanged (#382). Decimal text is exact fixed-point, not a JS number or an
arbitrary decimal-library instance; a negative zero reads back as zero
with its scale kept (ADR 0001). A leap second has no count since the epoch
and is refused. Timestamps outside years 0000–9999 use an ISO 8601
expanded year (+12000-01-01T00:00:00.000000001Z), on both sides.
Scalar failures throw a TypeError with a stable code: ERR_INTEGER_RANGE,
ERR_INVALID_DATE, ERR_INVALID_TIMESTAMP, ERR_INVALID_DECIMAL,
ERR_NON_FINITE_DECIMAL and ERR_INVALID_UUID.
§Errors
A getter that throws, a string with an unpaired surrogate, a BigInt out
of range or a Date outside chrono’s range stops the walk through the
sink’s Error::source, as a JsFault carrying the napi status and
reason. At the boundary, JsFault::from_source turns it back into the
napi error, and a pending JavaScript exception surfaces unchanged: the
application sees the error its getter threw.
This crate is a library consumed by the Node addon (cdylib/npm package); it is not itself loadable from Node.
§Testing the conversion layer
cargo test -p vitaminc-aead-napi runs pure Rust boundary tests and builds a
small standalone addon to run four suites inside Node:
tests/value-conformance.cjs for scalar conversions against the shared
corpus, tests/object-safety.cjs for which object keys are read, how
output properties are written, and the ciphertext node projection,
tests/kind_inventory.cjs for every scalar kind the build can produce,
and tests/encrypt.cjs for every row of the mapping encrypted and
decrypted through real AES-256-GCM, with the error, depth and async
guarantees above. 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.
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; a whole-millisecond timestamp becomes a Date and re-encodes as
Timestamp(Millis) in the 0xF3 envelope. Go retains its established
undefined-to-nil projection. These host projections are explicit fields in
the corpus; Rust codec vectors always round-trip byte-for-byte.
The functions that walk and build live JavaScript values take a
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 pure functions beside them stay scored.
§Safety notes
- Unsafe code is limited to the conversion trait methods napi-rs requires
(
FromNapiValue/ToNapiValueforJsCipherText) and the one Node-API call that reads a string’s code units into a buffer the crate owns. Everything else uses napi-rs’s safe API. The crate deniesunsafe_op_in_unsafe_fnandclippy::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. On the way in, every copy Rust
makes of a string is wiped on drop: the UTF-16 code units and the UTF-8
text of each value, key and wrapper class’s text. The UTF-8 text is built
in a buffer of its final size, so no smaller buffer is left behind, and
the wrapper text is parsed in place without further copies. The one
exception is an error message, which names a property a getter removed
while the walk was reading it. A frame snapshot is
Protectedand wiped on drop. - On the way out, the build hands decrypted text and bytes to JavaScript,
where the engine owns them. The Rust copies made along the way are not
wiped: the text of each wrapper class (formatted by
chrono,rust_decimalor the binding), each map key, and the bytes copied into aBuffer. - Property names that would touch the prototype chain (
__proto__,constructor,prototype) are rejected in both directions. Nesting is limited to 128 levels in both directions too, counted as the frame counts it, including inside a ciphertext’s passthrough payload. The walk and the build each check the depth before recursing, so a tree far too deep for the stack fails cleanly instead of aborting Node, and a document at the limit walks in a debug build. - Only plain objects (prototype
Object.prototypeornull) and the wrapper classes are accepted for encryption.Map,Set,RegExp,DataView, class instances and other exotic objects keep their state in internal slots and would silently encrypt as{}— they are rejected with an error instead. The prototype is asked throughObject.getPrototypeOf, which runs aProxy’s trap, so a proxiedMaporDatereports its target’s prototype and is refused; a proxy over a plain object walks through its traps. Typed arrays other thanUint8Array(andBuffer), andDataView, are refused rather than sealed as their raw bytes. - Object enumeration reads own, enumerable, string-keyed properties
only, so a polluted
Object.prototype(or inherited enumerable getters) can never leak keys into — or run code during — encryption. An own property explicitly set toundefinedround-trips. - Sparse arrays (
[, , "x"],new Array(n)) are rejected;[undefined]is not sparse and round-trips. A hole is detected per index viaObject.hasOwnsemantics, so a pollutedArray.prototypecannot fill holes with inherited elements. - 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 the walk, the build and the ciphertext rebuilder. - 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’st,v, sequence elements and map entries. A polluted setter onObject.prototypeorArray.prototypecan 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
tandvfromString.prototype. - Errors carry no cryptographic detail (
Unspecifiedat the trait layer).
Structs§
- JsBuilder
- A sink that builds a JavaScript value, on the JavaScript thread.
- JsCipher
Text - Newtype carrying a
CipherTextacross the NAPI boundary using the node projection documented at the module level. - JsFault
- A failure on the JavaScript side of a walk or a build: a getter threw, a string had an unpaired surrogate, a value was of a type that cannot be encrypted, a tree was too deep.
- JsRef
- A live JavaScript value, walked in place on the JavaScript thread.
- Wrappers
- The wrapper classes of one environment, for one call: a borrowed view that cannot outlive the environment it was taken from.
Enums§
- Conversion
Error - Host scalar conversion errors. Node receives a TypeError with the same stable code; errors contain no user plaintext.
- Wrapper
- Which wrapper class a value is an instance of.
Constants§
- MAX_
DEPTH - Maximum nesting depth, in both directions: walking a JavaScript value and building one. Bounds recursion so a deeply nested input cannot overflow the stack. The same limit, counted the same way, as the frame’s, so any frame builds and any value that walks frames.
Traits§
- Napi
Passthrough - A ciphertext passthrough payload’s conversion to and from JS.