Skip to content

feat(native): introduce @zapo-js/native, Rust NAPI accelerators#128

Draft
vinikjkkj wants to merge 1 commit into
masterfrom
feat/native-package
Draft

feat(native): introduce @zapo-js/native, Rust NAPI accelerators#128
vinikjkkj wants to merge 1 commit into
masterfrom
feat/native-package

Conversation

@vinikjkkj
Copy link
Copy Markdown
Owner

@vinikjkkj vinikjkkj commented May 29, 2026

New workspace package @zapo-js/native with Rust-backed accelerators for the messaging crypto hot path. The library try-requires the binding at module load and falls back to the JS impl when missing, so consumers without the prebuilt binary keep working.

Accelerators (src/crypto/):

  • XEdDSA sign / verify, sync + async. Avoids the WebCrypto Ed25519 round-trip that dominates SKDM sign in fanout.
  • X25519 ECDH scalar mult. Avoids the createPrivateKey / createPublicKey DER round-trip in node:crypto.diffieHellman.

Layout (src/lib.rs thin entry + src/<concern>/) leaves room for future non-crypto accelerators (hash, codec) without another rename. Force-JS escape hatches: ZAPO_XEDDSA_FORCE_JS, ZAPO_X25519_FORCE_JS.

Bench

fake-server sqlite messaging, 1000 msgs / scenario, 1000 contacts x 2 devices, 4 groups x 500 members. Medians of 3 runs after warmup, msg/s:

Scenario JS (master) Native Delta
send_1to1 121 ~384 +217% (2.93x)
recv_1to1 123 ~502 +308% (4.08x)
send_group 168 ~1083 +545% (6.45x)
recv_group 363 ~1573 +333% (4.34x)

feMul was 10.78% on master, absent post; new top is sqlite I/O.

Validation

Cross-check (50 sign / verify pairs both directions) passes; 722 / 722 unit tests, lint, typecheck all green.

Summary by CodeRabbit

  • New Features
    • Added native cryptographic acceleration module for XEdDSA signing/verification and X25519 scalar multiplication operations, with automatic fallback to JavaScript implementations when unavailable or disabled via environment variables.
    • Included performance benchmarking and cross-validation tests to ensure native and JavaScript implementations produce compatible results.

Review Change Stack

New workspace package `@zapo-js/native` with Rust-backed accelerators
for the messaging crypto hot path. The library try-requires the
binding at module load and falls back to the JS impl when missing, so
consumers without the prebuilt binary keep working.

Accelerators (`src/crypto/`):

- **XEdDSA sign / verify**, sync + async. Avoids the WebCrypto Ed25519
  round-trip that dominates SKDM sign in fanout.
- **X25519 ECDH scalar mult**. Avoids the `createPrivateKey` /
  `createPublicKey` DER round-trip in `node:crypto.diffieHellman`.

Layout (`src/lib.rs` thin entry + `src/<concern>/`) leaves room for
future non-crypto accelerators (hash, codec) without another rename.
Force-JS escape hatches: `ZAPO_XEDDSA_FORCE_JS`, `ZAPO_X25519_FORCE_JS`.

## Bench

fake-server sqlite messaging, 1000 msgs / scenario, 1000 contacts x 2
devices, 4 groups x 500 members. Medians of 3 runs after warmup,
msg/s:

| Scenario     | JS (master) | Native | Delta             |
| ------------ | ----------: | -----: | ----------------: |
| `send_1to1`  | 121         | ~384   | +217% (2.93x)     |
| `recv_1to1`  | 123         | ~502   | +308% (4.08x)     |
| `send_group` | 168         | ~1083  | +545% (6.45x)     |
| `recv_group` | 363         | ~1573  | +333% (4.34x)     |

`feMul` was 10.78% on master, absent post; new top is sqlite I/O.

## Validation

Cross-check (50 sign / verify pairs both directions) passes;
722 / 722 unit tests, lint, typecheck all green.
@vinikjkkj vinikjkkj requested a review from Copilot May 29, 2026 00:39
@github-actions github-actions Bot added the feat New feature label May 29, 2026
@vinikjkkj vinikjkkj marked this pull request as draft May 29, 2026 00:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

📝 Walkthrough

Walkthrough

This PR introduces a complete native Rust cryptographic module (@zapo-js/native) that implements XEdDSA signing/verification and X25519 scalar multiplication via N-API bindings. The native code is integrated into the TypeScript codebase with graceful fallbacks, comprehensive test coverage, and performance benchmarking.

Changes

Native Cryptographic Module

Layer / File(s) Summary
Native Module Build and Package Configuration
eslint.config.js, packages/native/.gitignore, packages/native/Cargo.toml, packages/native/package.json, packages/native/build.rs
ESLint excludes generated binding files; .gitignore ignores Rust build artifacts and generated bindings; Cargo.toml defines the zapo-native cdylib with N-API, curve25519-dalek, sha2, and getrandom dependencies; package.json configures entrypoints and Node ≥20.9.0 requirement; build.rs wires N-API build setup.
Rust Cryptographic Implementation: Module Structure and X25519
packages/native/src/lib.rs, packages/native/src/crypto/mod.rs, packages/native/src/crypto/x25519.rs
Crate root enables clippy lints and re-exports crypto APIs; crypto module declares x25519 and xeddsa submodules; x25519 module implements x25519_scalar_mult(privKey, pubKey) → Buffer using curve25519-dalek's Montgomery multiplication with strict 32-byte input validation.
Rust Cryptographic Implementation: XEdDSA Sync and Async Bindings
packages/native/src/crypto/xeddsa.rs
Synchronous xeddsa_sign(privKey, msg) → Buffer and xeddsa_verify(pubKey, msg, sig) → bool using SHA-512 for r derivation and Ed25519 group operations; async counterparts xeddsa_sign_async and xeddsa_verify_async return N-API AsyncTask promises backed by libuv threadpool; async verify precomputes force_false short-circuit for invalid input lengths.
Native Module Testing and Validation
packages/native/__test__/smoke.cjs, packages/native/__test__/cross-check.cjs, packages/native/__test__/bench-sync-vs-async.cjs
Smoke test validates binding load and sign/verify with 64-byte signature assertion and tamper detection; cross-check runs 50 iterations verifying native↔JS signature compatibility in both directions; benchmark measures sync vs async throughput across concurrency levels and reports ops/sec ratios.
TypeScript XEdDSA Native Integration
src/crypto/core/xeddsa.ts
Adds optional native binding plumbing with NativeBinding interface; nativeBinding initializer attempts to load @zapo-js/native with ZAPO_XEDDSA_FORCE_JS environment override and graceful fallback; exports isNativeXeddsaEnabled() to expose status; xeddsaVerify and xeddsaSign short-circuit to native when available.
TypeScript X25519 Native Integration
src/crypto/curves/X25519.ts
Adds optional native binding with nativeX25519ScalarMult initializer respecting ZAPO_X25519_FORCE_JS override and falling back on load errors; X25519.scalarMult returns immediately via native path when available, otherwise uses existing async DH probing logic.

Sequence Diagram

sequenceDiagram
  participant App as TypeScript App
  participant TS_XEdDSA as xeddsaSign/Verify
  participant NativeGateway as `@zapo-js/native` Gateway
  participant RustSync as Rust xeddsa_sign/verify
  participant RustAsync as Rust async Task

  App->>TS_XEdDSA: xeddsaSign(privKey, msg)
  TS_XEdDSA->>NativeGateway: nativeBinding.xeddsaSign?
  alt Native available
    NativeGateway->>RustSync: x27519_scalar_mult(privKey, msg)
    RustSync-->>NativeGateway: Buffer signature
    NativeGateway-->>TS_XEdDSA: signature Buffer
  else Native unavailable
    TS_XEdDSA-->>TS_XEdDSA: JS signing (existing logic)
  end
  TS_XEdDSA-->>App: signature

  App->>TS_XEdDSA: xeddsaVerify(pubKey, msg, sig)
  TS_XEdDSA->>NativeGateway: nativeBinding.xeddsaVerify?
  alt Native available
    NativeGateway->>RustSync: xeddsa_verify(pubKey, msg, sig)
    RustSync-->>NativeGateway: bool
    NativeGateway-->>TS_XEdDSA: bool
  else Native unavailable
    TS_XEdDSA-->>TS_XEdDSA: JS verification (existing logic)
  end
  TS_XEdDSA-->>App: boolean result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR introduces a substantial new Rust module with cryptographic primitives, multiple N-API bindings (sync and async variants), dense mathematical operations, comprehensive test coverage, and integration points across the TypeScript codebase. While individual sections are logical and well-separated, the breadth of crypto logic, unfamiliar domain (XEdDSA group operations), and multiple interdependent layers demand careful review of correctness, security assumptions, and integration.

Possibly related PRs

  • vinikjkkj/zapo#35: Modifies src/crypto/curves/X25519.ts to add a Bun-specific diffieHellman SPKI wrapper fallback, which may interact with the native X25519 fast-path introduced in this PR.

Poem

🐰 A rabbit in Rust, so speedy and keen,
Builds crypto that's faster than ever seen—
XEdDSA and X25519 signs with a bound,
While JavaScript waits as a fallback around!
Native bindings dance with libuv's grace,
Bringing performance into the crypto race. 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(native): introduce @zapo-js/native, Rust NAPI accelerators' accurately summarizes the main change—introducing a new native package with Rust-based N-API accelerators for crypto operations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/native-package

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/native/__test__/bench-sync-vs-async.cjs`:
- Around line 132-167: The current "sync" baseline is being measured wrapped in
a Promise because the calls to timeitParallel use async () => xeddsaSign(...)
and async () => xeddsaVerify(...), which adds Promise overhead; update the
benchmark so the true synchronous path is measured without Promise wrapping by
invoking xeddsaSign and xeddsaVerify directly in a non-async harness (or add a
separate non-Promise timing function instead of timeitParallel for the sync
path), and keep the async measurements using xeddsaSignAsync and
xeddsaVerifyAsync; alternatively, if you intentionally want a promise-wrapped
comparison, relabel the sync entries to indicate they are promise-wrapped to
avoid misleading "sync" vs "async" speedups.

In `@packages/native/__test__/cross-check.cjs`:
- Around line 1-49: The file fails the repo's formatting check; run the
repository Prettier formatter (using the project's config, e.g., via the repo's
format script or npx prettier --write) against the test file containing main(),
native.xeddsaSign and native.xeddsaVerify, stage the updated file, and commit
the formatted result so CI `ci / format` passes.

In `@packages/native/package.json`:
- Around line 12-25: Update the package.json for the `@zapo-js/native` optional
package: remove or set "private" to false (so it can be published), add
"publishConfig": { "access": "public" }, and declare "zapo-js" under
"peerDependencies" with a compatible version (e.g., match the workspace/root
zapo-js version or use workspace range). Ensure these keys ("private",
"publishConfig", "peerDependencies") are added/updated in the existing
package.json for `@zapo-js/native`.

In `@packages/native/src/crypto/x25519.rs`:
- Around line 8-30: The PR added the new x25519_scalar_mult binding but the test
suite lacks interop/validation coverage; add native-vs-existing-path tests that
exercise the x25519_scalar_mult function: write at least one test using a known
RFC7748 test vector (fixed 32-byte scalar and public key producing expected
shared secret) and one peer-agreement test that generates a keypair A (private
a, public A) and keypair B (private b, public B) and asserts
x25519_scalar_mult(a, B) == x25519_scalar_mult(b, A); place tests alongside
existing crypto validation tests and call the exported x25519_scalar_mult
function to ensure regressions are caught.

In `@src/crypto/core/xeddsa.ts`:
- Around line 24-41: The try/catch around require('`@zapo-js/native`') currently
swallows all errors; change it to catch the error into a variable and only treat
the "package not installed" case as a benign fallback (e.g., error.code ===
'MODULE_NOT_FOUND' or an explicit check for "Cannot find module
'`@zapo-js/native`'"); for any other error (broken native binary, wrong exports,
init failures) re-throw or log it with context so failures are visible. Locate
the self-invoking initializer that assigns nativeBinding and adjust its catch
handler to inspect the thrown error, permit fallback only when the module is
missing, and otherwise surface the error (including details about
xeddsaSign/xeddsaVerify) instead of silently returning null.

In `@src/crypto/curves/X25519.ts`:
- Around line 22-31: The try/catch that loads '`@zapo-js/native`' currently
swallows all errors; change it to only fall back when the package is truly
missing and rethrow other failures with normalized context: import toError from
'`@util/primitives`', catch the require error as e, if toError(e).code ===
'MODULE_NOT_FOUND' (or e.code === 'MODULE_NOT_FOUND') then continue to fallback
to node:crypto, otherwise throw a new error that includes contextual text (e.g.,
"[X25519] failed to load `@zapo-js/native`") and the normalized error via
toError(e); apply this change around the require block that returns
mod.x25519ScalarMult.
- Around line 169-170: The native branch should normalize and validate the
shared secret before returning: call nativeX25519ScalarMult(privKey, pubKey),
wrap its result with toBytesView(...) to convert any Buffer subclass to a plain
Uint8Array view, assert that the resulting byte length is exactly 32 (throw or
reject if not), and then return that validated Uint8Array; update the code
around the nativeX25519ScalarMult call to use toBytesView(...) and the 32-byte
length check so callers always receive a plain Uint8Array of length 32.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a4e1e083-f11c-4911-8b8a-7bd03a17199e

📥 Commits

Reviewing files that changed from the base of the PR and between b5203e7 and 4106c62.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • packages/native/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • eslint.config.js
  • packages/native/.gitignore
  • packages/native/Cargo.toml
  • packages/native/__test__/bench-sync-vs-async.cjs
  • packages/native/__test__/cross-check.cjs
  • packages/native/__test__/smoke.cjs
  • packages/native/build.rs
  • packages/native/package.json
  • packages/native/src/crypto/mod.rs
  • packages/native/src/crypto/x25519.rs
  • packages/native/src/crypto/xeddsa.rs
  • packages/native/src/lib.rs
  • src/crypto/core/xeddsa.ts
  • src/crypto/curves/X25519.ts

Comment on lines +132 to +167
for (const conc of [1, 4, 8, 16, 32]) {
console.log(`SIGN — concurrency ${conc} (total ${PAR_TOTAL} ops x ${RUNS} runs):`)
const sSync = await timeitParallel(
`sync (loop)`,
RUNS,
PAR_TOTAL,
conc,
async () => xeddsaSign(PRIV, MSG)
)
const sAsync = await timeitParallel(
`async (libuv pool)`,
RUNS,
PAR_TOTAL,
conc,
() => xeddsaSignAsync(PRIV, MSG)
)
console.log(` → speedup async vs sync: ${(sSync / sAsync).toFixed(2)}x\n`)
}

for (const conc of [1, 4, 8, 16, 32]) {
console.log(`VERIFY — concurrency ${conc} (total ${PAR_TOTAL} ops x ${RUNS} runs):`)
const vSync = await timeitParallel(
`sync (loop)`,
RUNS,
PAR_TOTAL,
conc,
async () => xeddsaVerify(PUB, MSG, SIG)
)
const vAsync = await timeitParallel(
`async (libuv pool)`,
RUNS,
PAR_TOTAL,
conc,
() => xeddsaVerifyAsync(PUB, MSG, SIG)
)
console.log(` → speedup async vs sync: ${(vSync / vAsync).toFixed(2)}x\n`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The parallel "sync" baseline is still promise-wrapped.

Here the sync branch is passed as async () => xeddsaSign(...) / async () => xeddsaVerify(...), and timeitParallel() runs everything through Promise.all(...). That means every "sync" sample includes Promise overhead, so the async-vs-sync speedups reported here are not a clean baseline. Please benchmark the synchronous path with a separate non-Promise harness, or relabel this as a promise-wrapped comparison.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/native/__test__/bench-sync-vs-async.cjs` around lines 132 - 167, The
current "sync" baseline is being measured wrapped in a Promise because the calls
to timeitParallel use async () => xeddsaSign(...) and async () =>
xeddsaVerify(...), which adds Promise overhead; update the benchmark so the true
synchronous path is measured without Promise wrapping by invoking xeddsaSign and
xeddsaVerify directly in a non-async harness (or add a separate non-Promise
timing function instead of timeitParallel for the sync path), and keep the async
measurements using xeddsaSignAsync and xeddsaVerifyAsync; alternatively, if you
intentionally want a promise-wrapped comparison, relabel the sync entries to
indicate they are promise-wrapped to avoid misleading "sync" vs "async"
speedups.

Comment on lines +1 to +49
// Cross-check: native sign / JS verify AND JS sign / native verify.
// Run from repo root via tsx so @crypto path alias resolves.
const native = require('@zapo-js/native')
const assert = require('node:assert')
const { randomBytes, createPrivateKey } = require('node:crypto')

if (typeof native.xeddsaSign !== 'function' || typeof native.xeddsaVerify !== 'function') {
console.error('native binding not loaded')
process.exit(2)
}

async function main() {
const { xeddsaSign: jsSign, xeddsaVerify: jsVerify } = await import('../../../src/crypto/core/xeddsa.ts')

const X25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex')

let okPairs = 0
for (let i = 0; i < 50; i += 1) {
const priv = randomBytes(32)
const keyObj = createPrivateKey({
key: Buffer.concat([X25519_PKCS8_PREFIX, priv]),
format: 'der',
type: 'pkcs8'
})
const jwk = keyObj.export({ format: 'jwk' })
const pub = Buffer.from(jwk.x, 'base64url')
const message = randomBytes(80 + (i % 200))

// native sign -> JS verify
const privClone1 = Buffer.from(priv)
const sigNative = native.xeddsaSign(privClone1, message)
const okJsVerifiesNative = await jsVerify(pub, message, Buffer.from(sigNative))
assert.equal(okJsVerifiesNative, true, `iter ${i}: js verify failed on native sig`)

// JS sign -> native verify
const privClone2 = Buffer.from(priv)
const sigJs = await jsSign(privClone2, message)
const okNativeVerifiesJs = native.xeddsaVerify(pub, message, sigJs)
assert.equal(okNativeVerifiesJs, true, `iter ${i}: native verify failed on JS sig`)

okPairs += 1
}
console.log(`cross-check OK: ${okPairs} pairs both directions`)
}

main().catch((e) => {
console.error(e)
process.exit(1)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run Prettier on this file before merge.

ci / format is already failing on this script, so the current revision does not match the repo's enforced formatting.

🧰 Tools
🪛 GitHub Actions: ci / 14_format.txt

[warning] 1-1: Prettier reported formatting/style issues in this file during 'prettier . --check'. Run 'prettier --write' to fix.

🪛 GitHub Actions: ci / format

[warning] 1-1: Prettier --check reported formatting/style issues in this file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/native/__test__/cross-check.cjs` around lines 1 - 49, The file fails
the repo's formatting check; run the repository Prettier formatter (using the
project's config, e.g., via the repo's format script or npx prettier --write)
against the test file containing main(), native.xeddsaSign and
native.xeddsaVerify, stage the updated file, and commit the formatted result so
CI `ci / format` passes.

Comment on lines +12 to +25
"private": true,
"engines": {
"node": ">=20.9.0"
},
"napi": {
"binaryName": "zapo-native"
},
"scripts": {
"build": "napi build --platform --release --js binding.js --dts binding.d.ts",
"build:debug": "napi build --platform --js binding.js --dts binding.d.ts"
},
"devDependencies": {
"@napi-rs/cli": "^3.0.0"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add required optional-package publish metadata and peer dependency.

@zapo-js/native is missing required peerDependencies.zapo-js and publishConfig.access: 'public', and
private: true conflicts with optional package publishing requirements.

Suggested fix
 {
     "name": "`@zapo-js/native`",
@@
-    "private": true,
+    "publishConfig": {
+        "access": "public"
+    },
@@
+    "peerDependencies": {
+        "zapo-js": "*"
+    },
     "engines": {
         "node": ">=20.9.0"
     },

As per coding guidelines, "Declare zapo-js as a peerDependency in optional packages
(packages/<name>/) ... Each optional package must ... publish with
\"publishConfig\": { \"access\": \"public\" } in package.json."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"private": true,
"engines": {
"node": ">=20.9.0"
},
"napi": {
"binaryName": "zapo-native"
},
"scripts": {
"build": "napi build --platform --release --js binding.js --dts binding.d.ts",
"build:debug": "napi build --platform --js binding.js --dts binding.d.ts"
},
"devDependencies": {
"@napi-rs/cli": "^3.0.0"
}
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"zapo-js": "*"
},
"engines": {
"node": ">=20.9.0"
},
"napi": {
"binaryName": "zapo-native"
},
"scripts": {
"build": "napi build --platform --release --js binding.js --dts binding.d.ts",
"build:debug": "napi build --platform --js binding.js --dts binding.d.ts"
},
"devDependencies": {
"`@napi-rs/cli`": "^3.0.0"
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/native/package.json` around lines 12 - 25, Update the package.json
for the `@zapo-js/native` optional package: remove or set "private" to false (so
it can be published), add "publishConfig": { "access": "public" }, and declare
"zapo-js" under "peerDependencies" with a compatible version (e.g., match the
workspace/root zapo-js version or use workspace range). Ensure these keys
("private", "publishConfig", "peerDependencies") are added/updated in the
existing package.json for `@zapo-js/native`.

Comment on lines +8 to +30
#[napi]
pub fn x25519_scalar_mult(private_key: Buffer, public_key: Buffer) -> Result<Buffer> {
if private_key.len() != 32 {
return Err(Error::new(
Status::InvalidArg,
format!("invalid x25519 private key length {}", private_key.len()),
));
}
if public_key.len() != 32 {
return Err(Error::new(
Status::InvalidArg,
format!("invalid x25519 public key length {}", public_key.len()),
));
}
let mut sk = [0u8; 32];
sk.copy_from_slice(&private_key);
let mut pk = [0u8; 32];
pk.copy_from_slice(&public_key);

// mul_clamped clamps the scalar per RFC 7748 internally; passing an
// already-clamped key is a no-op since clamping is idempotent.
let shared = MontgomeryPoint(pk).mul_clamped(sk).to_bytes();
Ok(Buffer::from(shared.to_vec()))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add interop coverage for the new X25519 binding.

This introduces a new public crypto primitive, but the validation files in this PR only exercise XEdDSA paths. Please add at least one native-vs-existing-path test for x25519_scalar_mult - ideally a known vector plus a peer-agreement case - so a scalar-multiplication regression does not land unnoticed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/native/src/crypto/x25519.rs` around lines 8 - 30, The PR added the
new x25519_scalar_mult binding but the test suite lacks interop/validation
coverage; add native-vs-existing-path tests that exercise the x25519_scalar_mult
function: write at least one test using a known RFC7748 test vector (fixed
32-byte scalar and public key producing expected shared secret) and one
peer-agreement test that generates a keypair A (private a, public A) and keypair
B (private b, public B) and asserts x25519_scalar_mult(a, B) ==
x25519_scalar_mult(b, A); place tests alongside existing crypto validation tests
and call the exported x25519_scalar_mult function to ensure regressions are
caught.

Comment thread src/crypto/core/xeddsa.ts
Comment on lines +24 to +41
const nativeBinding: NativeBinding | null = (() => {
if (process.env.ZAPO_XEDDSA_FORCE_JS) return null
try {
const mod = require('@zapo-js/native') as {
xeddsaSign?: NativeBinding['xeddsaSign']
xeddsaVerify?: NativeBinding['xeddsaVerify']
}
if (
mod &&
typeof mod.xeddsaSign === 'function' &&
typeof mod.xeddsaVerify === 'function'
) {
return { xeddsaSign: mod.xeddsaSign, xeddsaVerify: mod.xeddsaVerify }
}
} catch {
// optional native binding not installed; fall through to JS implementation
}
return null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only fall back when the optional package is actually missing.

This catch swallows every load/init failure from @zapo-js/native, including broken binaries and export mismatches, then silently downgrades to JS. That makes native deployment failures invisible. Please limit the fallback to the package-not-installed case and log or re-throw everything else.
As per coding guidelines, "Do not silently swallow exceptions; always log or re-throw caught errors with contextual metadata."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crypto/core/xeddsa.ts` around lines 24 - 41, The try/catch around
require('`@zapo-js/native`') currently swallows all errors; change it to catch the
error into a variable and only treat the "package not installed" case as a
benign fallback (e.g., error.code === 'MODULE_NOT_FOUND' or an explicit check
for "Cannot find module '`@zapo-js/native`'"); for any other error (broken native
binary, wrong exports, init failures) re-throw or log it with context so
failures are visible. Locate the self-invoking initializer that assigns
nativeBinding and adjust its catch handler to inspect the thrown error, permit
fallback only when the module is missing, and otherwise surface the error
(including details about xeddsaSign/xeddsaVerify) instead of silently returning
null.

Comment on lines +22 to +31
try {
const mod = require('@zapo-js/native') as {
x25519ScalarMult?: NativeX25519ScalarMult
}
if (mod && typeof mod.x25519ScalarMult === 'function') {
return mod.x25519ScalarMult
}
} catch {
// optional native binding not installed; fall through to node:crypto
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only swallow the missing-native-package case.

This catch {} hides every load failure from @zapo-js/native, including ABI mismatches and bugs inside the binding, then silently degrades to JS. Please narrow the fallback to the expected "module not found" case and rethrow unexpected errors with context instead. As per coding guidelines, "Normalize unknown errors with toError() from @util/primitives in error handling blocks" and "Do not silently swallow exceptions; always log or re-throw caught errors with contextual metadata."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crypto/curves/X25519.ts` around lines 22 - 31, The try/catch that loads
'`@zapo-js/native`' currently swallows all errors; change it to only fall back
when the package is truly missing and rethrow other failures with normalized
context: import toError from '`@util/primitives`', catch the require error as e,
if toError(e).code === 'MODULE_NOT_FOUND' (or e.code === 'MODULE_NOT_FOUND')
then continue to fallback to node:crypto, otherwise throw a new error that
includes contextual text (e.g., "[X25519] failed to load `@zapo-js/native`") and
the normalized error via toError(e); apply this change around the require block
that returns mod.x25519ScalarMult.

Comment on lines +169 to +170
if (nativeX25519ScalarMult) {
return nativeX25519ScalarMult(privKey, pubKey)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize and validate the native shared secret at the boundary.

The native path returns the binding output directly, so this method can now leak a Buffer subclass instead of a plain Uint8Array, and it skips the 32-byte invariant enforced by the Node path. Normalize with toBytesView(...) here and assert the returned length before handing it to callers.

Suggested fix
        if (nativeX25519ScalarMult) {
-            return nativeX25519ScalarMult(privKey, pubKey)
+            const sharedSecret = toBytesView(nativeX25519ScalarMult(privKey, pubKey))
+            assertByteLength(sharedSecret, 32, 'x25519 shared secret must be 32 bytes')
+            return sharedSecret
        }

As per coding guidelines, "Use Uint8Array for all binary data; do not use Buffer in runtime code" and "Use toBytesView() only at system boundaries where input may not be a plain Uint8Array."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/crypto/curves/X25519.ts` around lines 169 - 170, The native branch should
normalize and validate the shared secret before returning: call
nativeX25519ScalarMult(privKey, pubKey), wrap its result with toBytesView(...)
to convert any Buffer subclass to a plain Uint8Array view, assert that the
resulting byte length is exactly 32 (throw or reject if not), and then return
that validated Uint8Array; update the code around the nativeX25519ScalarMult
call to use toBytesView(...) and the 32-byte length check so callers always
receive a plain Uint8Array of length 32.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants