Skip to main content

vitaminc_aead_napi/
ciphertext.rs

1//! Projection of the generic [`CipherText`] container onto JS structures.
2//!
3//! The container has **no canonical byte representation** — only the sealed
4//! `Leaf` type does (see the container docs). Crossing the FFI boundary, the
5//! tree is projected onto plain JS values that the application can hold,
6//! store (e.g. as JSONB with its own Buffer encoding policy), and pass back
7//! for decryption:
8//!
9//! ```text
10//! Single(leaf)        ↦ { t: "ct",   v: Buffer }
11//! Sequence(...)       ↦ { t: "seq",  v: [node, ...] }
12//! EmptySequence(leaf) ↦ { t: "eseq", v: Buffer }
13//! Map(entries)        ↦ { t: "map",  v: { key: node, ... } }
14//! EmptyMap(leaf)      ↦ { t: "emap", v: Buffer }
15//! Passthrough(p)      ↦ { t: "pt",   v: <P's own JS projection> }
16//! ```
17//!
18//! The passthrough payload of the sink-based tree ([`Node`]) is a
19//! [`PassthroughFrame`], which projects onto the plain JavaScript value the
20//! frame describes, and reads back by walking the JavaScript value into a
21//! frame.
22//!
23//! The `t`/`v` node shape is an **in-memory/application-side convenience**:
24//! it is what a JS application holds between `encrypt` and `decrypt` calls,
25//! and what an application that manages its own storage may choose to
26//! persist. Database interop across languages is *not* this projection's
27//! job — that is the role of the EQL layer, which owns the durable
28//! database format and treats the sealed leaves opaquely. Within that
29//! scope the node shape should still change only deliberately (apps may
30//! have persisted it), but it is not a cross-language wire commitment;
31//! only the leaf byte format (the leaf codec in `vitaminc-aead`) is frozen.
32//!
33//! [`Node`]: vitaminc_aead::Node
34
35use napi::bindgen_prelude::{
36    Array, Buffer, BufferSlice, FromNapiValue, JsObjectValue, JsValue, Object, ToNapiValue,
37    TypeName, Unknown,
38};
39use napi::{sys, Env, Error, Result, Status, ValueType};
40use vitaminc_aead::{CipherText, Frame, PassthroughFrame};
41use vitaminc_protected::Controlled;
42
43use crate::js::{
44    define_own_properties, depth_error, eager_capacity, forbidden_key, get_own,
45    get_property_unknown, new_array, own_enumerable_keys, own_property, MAX_DEPTH,
46};
47use crate::js_builder::JsBuilder;
48use crate::js_ref::{frame_error, JsRef};
49
50const T_CIPHERTEXT: &str = "ct";
51const T_SEQ: &str = "seq";
52const T_EMPTY_SEQ: &str = "eseq";
53const T_MAP: &str = "map";
54const T_EMPTY_MAP: &str = "emap";
55const T_PASSTHROUGH: &str = "pt";
56
57/// Newtype carrying a [`CipherText`] across the NAPI boundary using the
58/// node projection documented at the module level.
59///
60/// `Leaf` needs only byte-level accessors (`AsRef<[u8]>` out,
61/// `From<Vec<u8>>` in); the passthrough payload `P` converts through
62/// [`NapiPassthrough`], implemented for [`PassthroughFrame`] (the
63/// sink-based tree's payload) and `()`. A cipher whose passthrough
64/// payload type is something else needs its own `NapiPassthrough` impl.
65pub struct JsCipherText<Leaf, P>(pub CipherText<Leaf, P>);
66
67/// A ciphertext passthrough payload's conversion to and from JS.
68///
69/// A payload shares the tree's 128-level nesting limit, as it does in the
70/// transport encoding, rather than starting a fresh count: it sits one level
71/// below its passthrough node.
72pub trait NapiPassthrough: Sized {
73    /// Build the payload's JS value. [`JsCipherText`] calls this only once
74    /// [`exceeds_depth`](Self::exceeds_depth) has passed for the payload's
75    /// position, which bounds any recursion here.
76    fn to_js(self, env: &Env) -> Result<Unknown<'_>>;
77    /// Read the payload from its JS value at `depth`, refusing anything that
78    /// nests past the limit.
79    fn from_js(value: Unknown<'_>, depth: usize) -> Result<Self>;
80    /// Whether the payload, placed at `depth`, nests past the limit.
81    fn exceeds_depth(&self, depth: usize) -> bool;
82    /// Drop the payload without recursing once per nesting level, for a tree
83    /// refused as too deep.
84    fn drop_flat(self);
85}
86
87/// No payload: projects to `undefined`, which is all it accepts back. It is
88/// a single node, so it is too deep only past the limit itself.
89impl NapiPassthrough for () {
90    fn to_js(self, env: &Env) -> Result<Unknown<'_>> {
91        ().into_unknown(env)
92    }
93
94    fn from_js(value: Unknown<'_>, depth: usize) -> Result<Self> {
95        if depth > MAX_DEPTH {
96            return Err(depth_error());
97        }
98        // napi-rs's own `()` conversion accepts any value; check instead.
99        if value.get_type()? == ValueType::Undefined {
100            Ok(())
101        } else {
102            Err(shape_error())
103        }
104    }
105
106    fn exceeds_depth(&self, depth: usize) -> bool {
107        depth > MAX_DEPTH
108    }
109
110    fn drop_flat(self) {}
111}
112
113/// A frame payload projects onto the JavaScript value it describes, and
114/// reads back as the frame of the JavaScript value, walked at the
115/// payload's depth so the tree's limit counts the whole structure.
116impl NapiPassthrough for PassthroughFrame {
117    fn to_js(self, env: &Env) -> Result<Unknown<'_>> {
118        // `exceeds_depth` has passed for this payload, which validated it.
119        let frame = Frame::parse(&self.0).map_err(|_| shape_error())?;
120        JsBuilder::build(env, frame)
121    }
122
123    fn from_js(value: Unknown<'_>, depth: usize) -> Result<Self> {
124        let frame = vitaminc_aead::to_frame(JsRef::at_depth(value, depth)).map_err(frame_error)?;
125        Ok(PassthroughFrame(frame.risky_ref().clone()))
126    }
127
128    /// A frame parsed `depth` levels down is bounded there; a payload that
129    /// is not a well-formed frame cannot be built either, so it counts as
130    /// exceeding too.
131    fn exceeds_depth(&self, depth: usize) -> bool {
132        Frame::parse_nested(&self.0, depth).is_err()
133    }
134
135    /// A frame is one flat buffer.
136    fn drop_flat(self) {}
137}
138
139/// Whether a ciphertext tree nests past [`MAX_DEPTH`], payloads included.
140/// Iterative, so a tree of any depth is measured without recursion.
141fn exceeds_depth<Leaf, P: NapiPassthrough>(node: &CipherText<Leaf, P>) -> bool {
142    let mut stack = vec![(node, 0)];
143    while let Some((node, depth)) = stack.pop() {
144        if depth > MAX_DEPTH {
145            return true;
146        }
147        match node {
148            CipherText::Sequence(items) => stack.extend(items.iter().map(|item| (item, depth + 1))),
149            CipherText::Map(entries) => {
150                stack.extend(entries.iter().map(|(_, value)| (value, depth + 1)))
151            }
152            CipherText::Passthrough(p) if p.exceeds_depth(depth + 1) => return true,
153            _ => {}
154        }
155    }
156    false
157}
158
159/// Drop a ciphertext tree without recursing once per level (see
160/// [`NapiPassthrough::drop_flat`]).
161fn drop_flat<Leaf, P: NapiPassthrough>(node: CipherText<Leaf, P>) {
162    let mut stack = vec![node];
163    while let Some(node) = stack.pop() {
164        match node {
165            CipherText::Sequence(items) => stack.extend(items),
166            CipherText::Map(entries) => stack.extend(entries.into_iter().map(|(_, value)| value)),
167            CipherText::Passthrough(p) => p.drop_flat(),
168            _ => {}
169        }
170    }
171}
172
173fn ciphertext_depth_error() -> Error {
174    Error::new(Status::InvalidArg, "ciphertext is nested too deeply")
175}
176
177fn shape_error() -> Error {
178    Error::new(Status::InvalidArg, "malformed ciphertext value")
179}
180
181/// Build `node` in JS. Callers check [`exceeds_depth`] first, which bounds
182/// this recursion.
183fn node_to_js<Leaf, P>(env: &Env, node: CipherText<Leaf, P>) -> Result<Object<'_>>
184where
185    Leaf: AsRef<[u8]>,
186    P: NapiPassthrough,
187{
188    let leaf = |tag: &'static str, leaf: Leaf| -> Result<(&'static str, Unknown<'_>)> {
189        Ok((tag, Buffer::from(leaf.as_ref().to_vec()).into_unknown(env)?))
190    };
191    let (tag, value) = match node {
192        CipherText::Single(l) => leaf(T_CIPHERTEXT, l)?,
193        CipherText::EmptySequence(l) => leaf(T_EMPTY_SEQ, l)?,
194        CipherText::EmptyMap(l) => leaf(T_EMPTY_MAP, l)?,
195        CipherText::Sequence(items) => {
196            let mut arr = new_array(env, items.len())?;
197            let mut elements = Vec::with_capacity(items.len());
198            for (i, item) in items.into_iter().enumerate() {
199                let node = node_to_js(env, item)?;
200                elements.push(own_property(env, &i.to_string(), &node)?);
201            }
202            define_own_properties(&mut arr, &elements)?;
203            (T_SEQ, arr.into_unknown(env)?)
204        }
205        CipherText::Map(entries) => {
206            let mut map = Object::new(env)?;
207            let mut properties = Vec::with_capacity(entries.len());
208            for (key, value) in entries {
209                // Map keys travel in the clear inside the stored ciphertext
210                // and are therefore attacker-writable; never assign
211                // prototype-polluting names onto a JS object.
212                if forbidden_key(&key) {
213                    return Err(Error::new(
214                        Status::InvalidArg,
215                        format!("ciphertext map key is not allowed: {key}"),
216                    ));
217                }
218                let node = node_to_js(env, value)?;
219                properties.push(own_property(env, &key, &node)?);
220            }
221            define_own_properties(&mut map, &properties)?;
222            (T_MAP, map.into_unknown(env)?)
223        }
224        CipherText::Passthrough(p) => (T_PASSTHROUGH, p.to_js(env)?),
225    };
226    // Own properties throughout, so a polluted inherited `t`, `v` or index
227    // setter never sees the ciphertext (see `define_own_properties`).
228    let mut obj = Object::new(env)?;
229    let tag = tag.into_unknown(env)?;
230    define_own_properties(
231        &mut obj,
232        &[
233            own_property(env, T_KEY, &tag)?,
234            own_property(env, V_KEY, &value)?,
235        ],
236    )?;
237    Ok(obj)
238}
239
240const T_KEY: &str = "t";
241const V_KEY: &str = "v";
242
243/// A node is a JavaScript object, for the places napi-rs asks what a value
244/// is, such as the result of an async task.
245impl<Leaf, P> TypeName for JsCipherText<Leaf, P> {
246    fn type_name() -> &'static str {
247        "CipherText"
248    }
249
250    fn value_type() -> ValueType {
251        ValueType::Object
252    }
253}
254
255impl<Leaf, P> ToNapiValue for JsCipherText<Leaf, P>
256where
257    Leaf: AsRef<[u8]>,
258    P: NapiPassthrough,
259{
260    unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result<sys::napi_value> {
261        if exceeds_depth(&val.0) {
262            drop_flat(val.0);
263            return Err(ciphertext_depth_error());
264        }
265        Ok(node_to_js(&Env::from_raw(env), val.0)?.raw())
266    }
267}
268
269/// A node must be a JS object. napi-rs's `Object` conversion does not check
270/// the type, and N-API reads properties of a primitive through its wrapper
271/// object, so a bare string would otherwise read `t` and `v` from a
272/// (possibly polluted) `String.prototype`.
273fn node_object(value: Unknown<'_>) -> Result<Object<'_>> {
274    if value.get_type()? != ValueType::Object {
275        return Err(shape_error());
276    }
277    Object::from_unknown(value)
278}
279
280/// A node's own `t` or `v`. A node missing either would otherwise take it
281/// from a (possibly polluted) prototype. Ownership is checked immediately
282/// before the read, where no JS can run in between, so a getter on the
283/// other field cannot delete this one after the check.
284fn own_field<'e>(obj: &Object<'e>, key: &str) -> Result<Unknown<'e>> {
285    if !obj.has_own_property(key)? {
286        return Err(shape_error());
287    }
288    get_property_unknown(obj, key)
289}
290
291fn node_from_js<Leaf, P>(obj: &Object<'_>, depth: usize) -> Result<CipherText<Leaf, P>>
292where
293    Leaf: From<Vec<u8>>,
294    P: NapiPassthrough,
295{
296    if depth > MAX_DEPTH {
297        return Err(ciphertext_depth_error());
298    }
299    let t = String::from_unknown(own_field(obj, T_KEY)?)?;
300    match t.as_str() {
301        T_CIPHERTEXT | T_EMPTY_SEQ | T_EMPTY_MAP => {
302            // Borrowed as a slice, which holds no reference: napi-rs's
303            // `Buffer` takes one before it checks the value and never
304            // releases it when the check fails.
305            let buf = BufferSlice::from_unknown(own_field(obj, V_KEY)?)?;
306            let leaf = Leaf::from(buf.to_vec());
307            Ok(match t.as_str() {
308                T_CIPHERTEXT => CipherText::Single(leaf),
309                T_EMPTY_SEQ => CipherText::EmptySequence(leaf),
310                _ => CipherText::EmptyMap(leaf),
311            })
312        }
313        T_SEQ => {
314            let arr = Array::from_unknown(own_field(obj, V_KEY)?)?;
315            // The same array seen as an object, for per-index own checks.
316            let elements = Object::from_unknown(arr.to_unknown())?;
317            let len = arr.len();
318            // Capacity is granted lazily: the reported length is
319            // attacker-cheap (`{t:"seq", v:new Array(2**32-1)}` materialises
320            // nothing), so reserving it up front would abort the process on
321            // allocation failure before any element is validated.
322            let mut items = Vec::with_capacity(eager_capacity(len));
323            for i in 0..len {
324                // Own elements only: a hole would otherwise read an element
325                // from a (possibly polluted) `Array.prototype`, and as
326                // `undefined` it is not a node anyway.
327                if !elements.has_own_property(&i.to_string())? {
328                    return Err(shape_error());
329                }
330                let element = arr.get::<Unknown>(i)?.ok_or_else(shape_error)?;
331                items.push(node_from_js(&node_object(element)?, depth + 1)?);
332            }
333            Ok(CipherText::Sequence(items))
334        }
335        T_MAP => {
336            let map = node_object(own_field(obj, V_KEY)?)?;
337            // Own keys only — `Object::keys` walks the prototype chain, so
338            // a hostile node object with enumerable prototype members would
339            // otherwise have its inherited keys become ciphertext map
340            // entries (see `own_enumerable_keys`).
341            let keys = own_enumerable_keys(&map)?;
342            let mut entries = Vec::with_capacity(keys.len());
343            for key in keys {
344                if forbidden_key(&key.name) {
345                    return Err(Error::new(
346                        Status::InvalidArg,
347                        format!("ciphertext map key is not allowed: {}", *key.name),
348                    ));
349                }
350                let value = node_object(get_own(&map, &key)?)?;
351                entries.push((key.name.to_string(), node_from_js(&value, depth + 1)?));
352            }
353            Ok(CipherText::Map(entries))
354        }
355        T_PASSTHROUGH => {
356            // `undefined` is a *faithful* projection here (`P = ()` and
357            // `NapiValue::Undefined` both project to it), and
358            // `JSON.stringify` drops a `v: undefined` key outright, so a
359            // missing `v` reads as `undefined` rather than as malformed —
360            // never from the prototype. Whether `undefined` converts is
361            // `P`'s decision.
362            let env = Env::from_raw(obj.value().env);
363            let value = if obj.has_own_property(V_KEY)? {
364                own_field(obj, V_KEY)?
365            } else {
366                ().into_unknown(&env)?
367            };
368            Ok(CipherText::Passthrough(P::from_js(value, depth + 1)?))
369        }
370        _ => Err(shape_error()),
371    }
372}
373
374impl<Leaf, P> FromNapiValue for JsCipherText<Leaf, P>
375where
376    Leaf: From<Vec<u8>>,
377    P: NapiPassthrough,
378{
379    unsafe fn from_napi_value(env: sys::napi_env, napi_val: sys::napi_value) -> Result<Self> {
380        // SAFETY: napi-rs calls this with the `env` and `napi_val` of the
381        // current call, which is all `Unknown::from_napi_value` needs: an
382        // `Unknown` may hold any value type, and `node_object` checks it is
383        // an object before anything reads it as one. The handle does not
384        // outlive this call.
385        let value = unsafe { Unknown::from_napi_value(env, napi_val) }?;
386        let obj = node_object(value)?;
387        Ok(JsCipherText(node_from_js(&obj, 0)?))
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::{drop_flat, exceeds_depth, CipherText, NapiPassthrough, MAX_DEPTH};
394    use vitaminc_aead::{to_frame, PassthroughFrame};
395    use vitaminc_protected::Controlled;
396
397    type Node<P> = CipherText<Vec<u8>, P>;
398
399    #[test]
400    fn a_node_is_a_javascript_object_named_ciphertext() {
401        use napi::bindgen_prelude::{TypeName, ValueType};
402        type Js = super::JsCipherText<Vec<u8>, PassthroughFrame>;
403        assert_eq!(Js::type_name(), "CipherText");
404        assert_eq!(Js::value_type(), ValueType::Object);
405    }
406
407    fn nested<P>(levels: usize, wrap: fn(Node<P>) -> Node<P>, leaf: Node<P>) -> Node<P> {
408        (0..levels).fold(leaf, |node, _| wrap(node))
409    }
410
411    fn seq<P>(node: Node<P>) -> Node<P> {
412        CipherText::Sequence(vec![CipherText::Single(vec![1]), node])
413    }
414
415    fn map<P>(node: Node<P>) -> Node<P> {
416        CipherText::Map(vec![
417            ("a".into(), CipherText::EmptySequence(vec![1])),
418            ("k".into(), node),
419        ])
420    }
421
422    /// `levels` arrays around `true`, as a frame payload.
423    fn payload(levels: usize) -> Node<PassthroughFrame> {
424        struct Arrays(usize);
425        impl vitaminc_aead::Encrypt for Arrays {
426            fn visit<S: vitaminc_aead::Sink>(self, sink: S) -> Result<S::Ok, S::Error> {
427                use vitaminc_aead::SeqSink;
428                if self.0 == 0 {
429                    return sink.scalar(None, vitaminc_aead::Physical::Bool(true));
430                }
431                let mut seq = sink.seq(Some(1))?;
432                Arrays(self.0 - 1).visit(seq.element()?)?;
433                seq.end()
434            }
435        }
436        let frame = to_frame(Arrays(levels)).expect("within the frame's own bound");
437        CipherText::Passthrough(PassthroughFrame(frame.risky_ref().clone()))
438    }
439
440    #[test]
441    fn depth_is_measured_to_the_limit_for_every_container() {
442        for wrap in [seq::<()>, map::<()>] {
443            let leaf = || CipherText::EmptySequence(vec![1]);
444            assert!(!exceeds_depth(&nested(MAX_DEPTH, wrap, leaf())));
445            assert!(exceeds_depth(&nested(MAX_DEPTH + 1, wrap, leaf())));
446        }
447    }
448
449    #[test]
450    fn a_payload_continues_the_count() {
451        // A `()` payload sits one level below its passthrough node.
452        assert!(!exceeds_depth(&nested(
453            MAX_DEPTH - 1,
454            seq,
455            CipherText::Passthrough(())
456        )));
457        assert!(exceeds_depth(&nested(
458            MAX_DEPTH,
459            seq,
460            CipherText::Passthrough(())
461        )));
462        assert!(!().exceeds_depth(MAX_DEPTH));
463        assert!(().exceeds_depth(MAX_DEPTH + 1));
464        // So does every level inside a frame payload.
465        assert!(!exceeds_depth(&nested(60, seq, payload(67))));
466        assert!(exceeds_depth(&nested(60, seq, payload(68))));
467        // A frame that is not well formed cannot be built, so it exceeds.
468        assert!(PassthroughFrame(vec![0xF0, 1, 0, 0, 0]).exceeds_depth(0));
469    }
470
471    #[test]
472    fn trees_too_deep_for_the_stack_are_measured_and_dropped_flat() {
473        for wrap in [seq::<PassthroughFrame>, map::<PassthroughFrame>] {
474            let deep = nested(1_000_000, wrap, CipherText::Single(vec![1]));
475            assert!(exceeds_depth(&deep));
476            drop_flat(deep);
477        }
478        drop_flat(nested(1_000_000, seq, CipherText::Passthrough(())));
479    }
480}