@cipherstash/protect-ffi 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Internal wire encoding for JS `bigint` plaintexts crossing the Neon
3
+ * boundary.
4
+ *
5
+ * The native addon extracts every options object with neon's `Json`
6
+ * extractor, which runs `JSON.stringify` on the JS side — and
7
+ * `JSON.stringify` throws a `TypeError` on any `bigint`. So `bigint`
8
+ * plaintexts are bounds-checked against i64 here (with a clear
9
+ * `RangeError`) and encoded into the tagged single-key map
10
+ * `{"__protect_ffi_bigint__": "<decimal string>"}`, which the Rust side
11
+ * deserializes into `JsPlaintext::BigInt`. The value is a decimal string,
12
+ * not a number: an i64-magnitude number literal would already have lost
13
+ * precision beyond 2^53.
14
+ *
15
+ * The wasm build performs the equivalent encoding in Rust
16
+ * (`encode_plaintext` in `crates/protect-ffi/src/wasm.rs`) because
17
+ * wasm consumers call the wasm-bindgen exports directly, without this
18
+ * wrapper.
19
+ *
20
+ * The decrypt direction needs no decoding: the native addon constructs a
21
+ * real JS `bigint` (`JsBigInt::from_i64` on Neon, `js_sys::BigInt` on
22
+ * wasm).
23
+ */
24
+ /** Must match `BIGINT_WIRE_KEY` in `crates/protect-ffi/src/js_plaintext.rs`. */
25
+ export declare const BIGINT_WIRE_KEY = "__protect_ffi_bigint__";
26
+ /** Smallest value an encrypted bigint column can store (i64::MIN). */
27
+ export declare const BIGINT_MIN: bigint;
28
+ /** Largest value an encrypted bigint column can store (i64::MAX). */
29
+ export declare const BIGINT_MAX: bigint;
30
+ /** The tagged wire form a `bigint` plaintext crosses the boundary in. */
31
+ export type BigIntWire = {
32
+ [K in typeof BIGINT_WIRE_KEY]: string;
33
+ };
34
+ /**
35
+ * Encode a `bigint` plaintext into its tagged wire form, throwing a
36
+ * `RangeError` (naming the i64 bounds and the offending direction) when
37
+ * the value does not fit a signed 64-bit integer. Non-`bigint` values pass
38
+ * through untouched. The error deliberately does not echo the value: it is
39
+ * plaintext being encrypted.
40
+ */
41
+ export declare function encodeBigIntPlaintext<T>(plaintext: T | bigint): T | BigIntWire;
42
+ /**
43
+ * Return `opts` with a `bigint` `plaintext` replaced by its tagged wire
44
+ * form. When the plaintext is not a `bigint`, the original object is
45
+ * returned unchanged (no clone).
46
+ */
47
+ export declare function withEncodedPlaintext<T extends {
48
+ plaintext: unknown;
49
+ }>(opts: T): T;
50
+ /**
51
+ * Bulk variant of {@link withEncodedPlaintext}: returns the original array
52
+ * (no clone) when no payload carries a `bigint` plaintext.
53
+ */
54
+ export declare function withEncodedPlaintexts<T extends {
55
+ plaintext: unknown;
56
+ }>(payloads: T[]): T[];
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ /**
3
+ * Internal wire encoding for JS `bigint` plaintexts crossing the Neon
4
+ * boundary.
5
+ *
6
+ * The native addon extracts every options object with neon's `Json`
7
+ * extractor, which runs `JSON.stringify` on the JS side — and
8
+ * `JSON.stringify` throws a `TypeError` on any `bigint`. So `bigint`
9
+ * plaintexts are bounds-checked against i64 here (with a clear
10
+ * `RangeError`) and encoded into the tagged single-key map
11
+ * `{"__protect_ffi_bigint__": "<decimal string>"}`, which the Rust side
12
+ * deserializes into `JsPlaintext::BigInt`. The value is a decimal string,
13
+ * not a number: an i64-magnitude number literal would already have lost
14
+ * precision beyond 2^53.
15
+ *
16
+ * The wasm build performs the equivalent encoding in Rust
17
+ * (`encode_plaintext` in `crates/protect-ffi/src/wasm.rs`) because
18
+ * wasm consumers call the wasm-bindgen exports directly, without this
19
+ * wrapper.
20
+ *
21
+ * The decrypt direction needs no decoding: the native addon constructs a
22
+ * real JS `bigint` (`JsBigInt::from_i64` on Neon, `js_sys::BigInt` on
23
+ * wasm).
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.BIGINT_MAX = exports.BIGINT_MIN = exports.BIGINT_WIRE_KEY = void 0;
27
+ exports.encodeBigIntPlaintext = encodeBigIntPlaintext;
28
+ exports.withEncodedPlaintext = withEncodedPlaintext;
29
+ exports.withEncodedPlaintexts = withEncodedPlaintexts;
30
+ /** Must match `BIGINT_WIRE_KEY` in `crates/protect-ffi/src/js_plaintext.rs`. */
31
+ exports.BIGINT_WIRE_KEY = '__protect_ffi_bigint__';
32
+ /** Smallest value an encrypted bigint column can store (i64::MIN). */
33
+ exports.BIGINT_MIN = -(2n ** 63n);
34
+ /** Largest value an encrypted bigint column can store (i64::MAX). */
35
+ exports.BIGINT_MAX = 2n ** 63n - 1n;
36
+ const I64_BOUNDS_SUFFIX = 'encrypted bigint values must fit in a signed 64-bit integer ' +
37
+ '(-9223372036854775808 to 9223372036854775807)';
38
+ /**
39
+ * Encode a `bigint` plaintext into its tagged wire form, throwing a
40
+ * `RangeError` (naming the i64 bounds and the offending direction) when
41
+ * the value does not fit a signed 64-bit integer. Non-`bigint` values pass
42
+ * through untouched. The error deliberately does not echo the value: it is
43
+ * plaintext being encrypted.
44
+ */
45
+ function encodeBigIntPlaintext(plaintext) {
46
+ if (typeof plaintext !== 'bigint') {
47
+ return plaintext;
48
+ }
49
+ if (plaintext > exports.BIGINT_MAX) {
50
+ throw new RangeError(`BigInt plaintext is above the maximum supported value: ${I64_BOUNDS_SUFFIX}`);
51
+ }
52
+ if (plaintext < exports.BIGINT_MIN) {
53
+ throw new RangeError(`BigInt plaintext is below the minimum supported value: ${I64_BOUNDS_SUFFIX}`);
54
+ }
55
+ return { [exports.BIGINT_WIRE_KEY]: plaintext.toString() };
56
+ }
57
+ /**
58
+ * Return `opts` with a `bigint` `plaintext` replaced by its tagged wire
59
+ * form. When the plaintext is not a `bigint`, the original object is
60
+ * returned unchanged (no clone).
61
+ */
62
+ function withEncodedPlaintext(opts) {
63
+ // `opts?.` (matching the detection pass in withEncodedPlaintexts): a
64
+ // null/undefined element must fall through to native's own validation
65
+ // error rather than throw a raw property-access TypeError here — the
66
+ // error a bad element produces must not depend on whether a *sibling*
67
+ // element happens to carry a bigint.
68
+ if (typeof opts?.plaintext !== 'bigint') {
69
+ return opts;
70
+ }
71
+ return { ...opts, plaintext: encodeBigIntPlaintext(opts.plaintext) };
72
+ }
73
+ /**
74
+ * Bulk variant of {@link withEncodedPlaintext}: returns the original array
75
+ * (no clone) when no payload carries a `bigint` plaintext.
76
+ */
77
+ function withEncodedPlaintexts(payloads) {
78
+ if (!Array.isArray(payloads) ||
79
+ !payloads.some((p) => typeof p?.plaintext === 'bigint')) {
80
+ return payloads;
81
+ }
82
+ return payloads.map(withEncodedPlaintext);
83
+ }
package/lib/index.cjs CHANGED
@@ -48,6 +48,7 @@ exports.decryptBulkFallible = decryptBulkFallible;
48
48
  exports.encryptQuery = encryptQuery;
49
49
  exports.encryptQueryBulk = encryptQueryBulk;
50
50
  exports.ensureKeyset = ensureKeyset;
51
+ const bigintWire_js_1 = require("./bigintWire.js");
51
52
  const credentials_js_1 = require("./credentials.js");
52
53
  const native = __importStar(require("./load.cjs"));
53
54
  const normalizeEncryptConfig_js_1 = require("./normalizeEncryptConfig.js");
@@ -81,7 +82,7 @@ function newClient(opts) {
81
82
  }, opts.strategy));
82
83
  }
83
84
  function encrypt(client, opts) {
84
- return wrapAsync(() => native.encrypt(client, opts));
85
+ return wrapAsync(() => native.encrypt(client, (0, bigintWire_js_1.withEncodedPlaintext)(opts)));
85
86
  }
86
87
  function decrypt(client, opts) {
87
88
  return wrapAsync(() => native.decrypt(client, opts));
@@ -97,7 +98,10 @@ function isEncrypted(encrypted) {
97
98
  return wrapSync(() => native.isEncrypted(encrypted));
98
99
  }
99
100
  function encryptBulk(client, opts) {
100
- return wrapAsync(() => native.encryptBulk(client, opts));
101
+ return wrapAsync(() => {
102
+ const plaintexts = (0, bigintWire_js_1.withEncodedPlaintexts)(opts.plaintexts);
103
+ return native.encryptBulk(client, plaintexts === opts.plaintexts ? opts : { ...opts, plaintexts });
104
+ });
101
105
  }
102
106
  function decryptBulk(client, opts) {
103
107
  return wrapAsync(() => native.decryptBulk(client, opts));
@@ -125,11 +129,14 @@ async function decryptBulkFallible(client, opts) {
125
129
  * shape exists yet, so those queries require an `eqlVersion: 2` client.
126
130
  */
127
131
  function encryptQuery(client, opts) {
128
- return wrapAsync(() => native.encryptQuery(client, opts));
132
+ return wrapAsync(() => native.encryptQuery(client, (0, bigintWire_js_1.withEncodedPlaintext)(opts)));
129
133
  }
130
134
  /** Bulk variant of {@link encryptQuery} — same EQL v3 restrictions apply. */
131
135
  function encryptQueryBulk(client, opts) {
132
- return wrapAsync(() => native.encryptQueryBulk(client, opts));
136
+ return wrapAsync(() => {
137
+ const queries = (0, bigintWire_js_1.withEncodedPlaintexts)(opts.queries);
138
+ return native.encryptQueryBulk(client, queries === opts.queries ? opts : { ...opts, queries });
139
+ });
133
140
  }
134
141
  /**
135
142
  * Test-only helper: ensures a keyset with the given name exists, creating it if necessary,
package/lib/index.d.cts CHANGED
@@ -325,7 +325,29 @@ export type EnsureKeysetResult = {
325
325
  id: string;
326
326
  name: string;
327
327
  };
328
- export type JsPlaintext = string | number | boolean | Record<string, unknown> | JsPlaintext[];
328
+ /**
329
+ * A plaintext value accepted by {@link encrypt} / {@link encryptBulk} /
330
+ * {@link encryptQuery} and returned by {@link decrypt} / {@link decryptBulk} /
331
+ * {@link decryptBulkFallible} (in the `data` arm of each result).
332
+ *
333
+ * `bigint` support (encrypted `cast_as: 'bigint'` columns store signed
334
+ * 64-bit integers):
335
+ *
336
+ * - **Input**: a top-level `bigint` plaintext is accepted alongside
337
+ * `number`. Values outside the i64 range (-2^63 to 2^63 - 1) throw a
338
+ * `RangeError` at the boundary — this covers index-term generation too,
339
+ * since terms derive from the same value. `number` inputs keep the
340
+ * existing exact-integer guard (fractional, non-finite, or beyond-2^53
341
+ * inexact values are rejected). `bigint` values nested inside JSON
342
+ * objects/arrays are NOT supported (JSON has no bigint) and throw a
343
+ * `TypeError` on both Neon and wasm — plaintexts follow
344
+ * `JSON.stringify` semantics on both platforms.
345
+ * - **Output** (BREAKING since the introduction of bigint support):
346
+ * decrypting a `cast_as: 'bigint'` column ALWAYS returns a `bigint`,
347
+ * even for values that fit in a JS number. Previous releases returned a
348
+ * `number`, silently losing precision beyond `Number.MAX_SAFE_INTEGER`.
349
+ */
350
+ export type JsPlaintext = string | number | boolean | bigint | Record<string, unknown> | JsPlaintext[];
329
351
  export type EncryptOptions = {
330
352
  plaintext: JsPlaintext;
331
353
  column: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cipherstash/protect-ffi",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/cipherstash/protectjs-ffi.git"
@@ -9,7 +9,7 @@
9
9
  "url": "https://github.com/cipherstash/protectjs-ffi/issues"
10
10
  },
11
11
  "homepage": "https://github.com/cipherstash/protectjs-ffi#readme",
12
- "description": "",
12
+ "description": "Native FFI bindings to the CipherStash Client SDK — powers @cipherstash/stack",
13
13
  "main": "./lib/index.cjs",
14
14
  "scripts": {
15
15
  "test": "npm run test:typecheck && npm run test:unit && npm run test:lint && npm run test:format && npm run test:rust",
@@ -32,14 +32,14 @@
32
32
  "cross": "npm run cross-build -- --release",
33
33
  "zigbuild": "npm run zig-build -- --release",
34
34
  "prepack": "tsc &&neon update",
35
- "version": "neon bump --binaries platforms && git add .",
35
+ "version": "neon bump --binaries platforms && node scripts/changelog-release.mjs && git add .",
36
36
  "release": "gh workflow run release.yml -f dryrun=false -f version=patch",
37
37
  "dryrun": "gh workflow run release.yml -f dryrun=true -f version=patch",
38
38
  "build:wasm": "wasm-pack build crates/protect-ffi --target bundler --out-dir ../../dist/wasm --no-pack",
39
39
  "postbuild:wasm": "node scripts/inline-wasm.mjs"
40
40
  },
41
- "author": "",
42
- "license": "ISC",
41
+ "author": "CipherStash <humans@cipherstash.com>",
42
+ "license": "MIT",
43
43
  "exports": {
44
44
  ".": {
45
45
  "node": {
@@ -103,11 +103,11 @@
103
103
  "vite": "^8.0.5"
104
104
  },
105
105
  "optionalDependencies": {
106
- "@cipherstash/protect-ffi-darwin-x64": "0.27.0",
107
- "@cipherstash/protect-ffi-darwin-arm64": "0.27.0",
108
- "@cipherstash/protect-ffi-win32-x64-msvc": "0.27.0",
109
- "@cipherstash/protect-ffi-linux-x64-gnu": "0.27.0",
110
- "@cipherstash/protect-ffi-linux-arm64-gnu": "0.27.0",
111
- "@cipherstash/protect-ffi-linux-x64-musl": "0.27.0"
106
+ "@cipherstash/protect-ffi-darwin-x64": "0.28.0",
107
+ "@cipherstash/protect-ffi-darwin-arm64": "0.28.0",
108
+ "@cipherstash/protect-ffi-win32-x64-msvc": "0.28.0",
109
+ "@cipherstash/protect-ffi-linux-x64-gnu": "0.28.0",
110
+ "@cipherstash/protect-ffi-linux-arm64-gnu": "0.28.0",
111
+ "@cipherstash/protect-ffi-linux-x64-musl": "0.28.0"
112
112
  }
113
113
  }