| Audited | paulmillr/noble-ed25519 at commit 0dd0e0e33a369bdd1079634e41f64524252f8407 |
|---|---|
| Date | 11 October 2026 |
| How it ran | API run on the Nacodex server, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 0 findings were first rated Medium or High; 0 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.
Info after review (6)
index.ts:855From the report
const hashes = {
sha512Async: async (message: TArg<Uint8Array>): Promise<TRet<Uint8Array>> => {
sha512: undefined as undefined | ((message: TArg<Uint8Array>) => TRet<Uint8Array>),
etc and utils are frozen, but the exported hashes object is not. It is one object shared by every importer in the process, so any module, including a transitive dependency, can replace sha512 or sha512Async. That changes how every other consumer signs and verifies. The wrapper only checks that the output is 64 bytes, not that it is a correct SHA-512 digest, so a wrong or malicious provider would make signatures silently invalid or non-standard. The slot is configurable on purpose, so this is a hardening note, not a demonstrated defect.
State clearly in the docs that the slots are process-global, and that only the application entry point should assign them, never library code. You could also accept an optional per-call hash provider, or let sha512 be set only once and refuse later reassignment.
Review: Cited lines exist (the report's snippet splices lines 855, 856 and 861; the opening line is right). Assigning ed.hashes.sha512 = sha512 is the documented, required setup: README.md:63, 81-82, 99, 112, 134, 151, 293-294. The source doc comment (index.ts:844-847) says "Both slots are configurable API surface". The report's claim that a wrong provider "would make signatures silently invalid": callHash (index.ts:206-207) enforces abytes(..., 64, 'digest'), so only a wrong-but-64-byte digest passes. That is the caller's own provider. Any code able to assign ed.hashes.sha512 already runs in the same process and can equally patch crypto.subtle, Uint8Array.prototype or the module's exports object. The slot grants no capability that does not already exist. No consequence beyond "a mis-configured application misbehaves". Fair: INFO (documentation note at most). The report itself calls it "a hardening note, not a demonstrated defect".
Point construction does not enforce the curve and extended-coordinate invariantindex.ts:266 (line corrected on review; the report cites :280)From the report
static fromAffine(p: AffinePoint): Point {
return new Point(p.x, p.y, 1n, modP(p.x * p.y));
A Point is meaningful only if (X, Y, Z) is on the curve and T·Z = X·Y. The public constructor and Point.fromAffine only check that each coordinate is in range, as the comment at index.ts:267-268 says. add() relies on T, while equals() ignores it. A point built from arbitrary coordinates therefore produces wrong results without any error unless the caller remembers assertValidity(). The library's own entry points decode points through fromBytes, which is safe. The risk only reaches callers who build points directly, so it is LOW.
Make fromAffine call .assertValidity() on the result. Alternatively, document prominently that points built from untrusted coordinates must be validated, and steer callers to fromBytes/fromHex.
Review: The behaviour is real and is documented in the code (267-268) and is deliberate: assertValidity() exists (index.ts:316-337) and the tests rely on fromAffine returning an invalid point: test/ed25519.test.ts:272-273 (Point.fromAffine({x:t,y:t}) then throws(() => malformedPoint.assertValidity())) and :277. The report's proposed fix would break the library's own tests. assertValidity() rejects ZERO (index.ts:320, "bad point: ZERO"), yet test/point.test.ts:312 requires equal(p.ZERO, p.fromAffine(p.ZERO.toAffine())) to succeed. Library entry points (verify, getPublicKey) decode through fromBytes (index.ts:283-304), which validates by construction (curve equation via uvRatio). No path from the public sign/verify API reaches an unvalidated Point. The risk is limited to callers that build points from untrusted coordinates and skip assertValidity(). Fair: INFO (docs).
test/benchmark.ts:22From the report
await mark('signAsync', () => curve.signAsync(msg, keys.secretKey));
await mark('verifyAsync', () => curve.verifyAsync(sig, msg, keys.publicKey));
Server-side users mostly call the async API many times at once. A sequential benchmark shows neither throughput under concurrency nor tail latency (p99 and above).
Optionally add a concurrent scenario for the async paths, such as N parallel promise chains of M operations each, and report total time plus latency percentiles.
Review: Accurate description of test/benchmark.ts (not shipped; package.json "files" lists only index.js, index.d.ts, index.ts). It is a preference for a different benchmark, not a defect.
Point.fromHex decodes the whole input before checking its sizeindex.ts:306-307From the report
static fromHex(hex: string, zip215?: boolean): Point {
return Point.fromBytes(hexToBytes(hex), zip215);
hexToBytes runs a regex over the whole string, allocates a buffer and loops over every byte before fromBytes enforces the 32-byte point width. An attacker-supplied hex string of arbitrary size costs work in proportion to its length before it is rejected. The work is linear, so the impact is small. It is still the one decode path that does no cheap size check first, unlike the signature and public-key inputs.
In fromHex, reject any string whose length is not 64 before calling hexToBytes.
Review: True that the length is checked only in fromBytes after full decode. Cost is O(n) in a string the attacker already had to supply and the host already holds (one regex pass, one n/2-byte allocation, one loop). No amplification, no secret-dependent timing, no crash. The report's contrast "unlike the signature and public-key inputs" is a false comparison: verify/sign/getPublicKey accept only Uint8Array (index.ts:699-701: abytes(sig, 64), abytes(publicKey, LEN)); the library has no other hex-accepting entry point except etc.hexToBytes. So fromHex is not an outlier. Fair: INFO hardening nit.
index.ts:960 (whole file, about 1,100 lines)From the report
let Gpows: Point[] | undefined = undefined; // shared process-wide cache of base-point precomputes
One file holds the byte/hex helpers, field arithmetic, the Point class, the hash-provider slots, the base-point precompute with its own mutable module cache, the sign/verify API and about 110 lines of TypeScript compatibility types. That makes changes harder to review in isolation. The single-file "5KB" design is a stated product goal, so this is a maintenance note, not a defect.
If the single-file layout is not a hard requirement, split the file along its existing section markers. Otherwise, record the single-file decision as intentional, for example in the README or a contributing note, so reviewers do not revisit it.
Review: Cite right (line 960; file 1134 lines, sections marked at 72, 242, 939, 1028). package.json description: "Fastest 5KB JS implementation"; single-file is the product goal as the report admits. Not a defect. Fair: INFO.
test/ed25519.helpers.ts:4 and .github/workflows/release.yml:8From the report
import * as ed from '../index.ts';
uses: paulmillr/jsbt/.github/workflows/release.yml@84f47b72e990ae11999fbe06c3efa449e943de60
All test files import the TypeScript source ../index.ts. npm consumers receive the compiled index.js and index.d.ts produced by tsc (package.json "files", "main": "index.js"). The release job delegates to an external reusable workflow whose steps cannot be seen in this repository. Nothing shown here proves that the artifact users install passes the test vectors. A build-configuration change, a compiler upgrade or a packaging mistake could ship a broken or different artifact while every test still passes.
Add a post-build check that runs npm pack, installs the tarball in a clean directory, imports it by package name, and runs a sign/verify round trip plus a sample of the vectors. Run it in CI, and make the release job depend on it.
Review: Cites are right. test.yml (repo's .github/workflows/test.yml:8 calls paulmillr/jsbt/.github/workflows/test.yml@84f47b72...) contains a job node_tsc ("Node v26 (compiled)"): npm ci, npm run build, then cd test && npx tsc && node compiled/test/index.js. The repo has test/tsconfig.json (outDir compiled) for exactly this. The jsbt tsconfig sets rewriteRelativeImportExtensions, so the compiled tests import the compiled index.js. So the tsc-compiled output is run through the full test suite on every push/PR. release.yml does npm ci --ignore-scripts, npm run build, npm publish --dry-run, version/tag checks, then publish. Residual true gap: no npm pack + install-tarball smoke test. Compiled output of the same tsc is tested, so risk is limited to packaging (the "files" list). Fair: INFO. ---------------------------------------------------------------------