Sample audits · Open-source projects, first eight

paulmillr/noble-ed25519

The ed25519 signature library behind many Solana tools.

Auditedpaulmillr/noble-ed25519 at commit 0dd0e0e33a369bdd1079634e41f64524252f8407
Date11 October 2026
How it ranAPI run on the Nacodex server, full audit, Standard review
Verdict after reviewPass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes)
0
High after review
0
Medium after review
0
Low after review
6
Info after review
0
excluded on review
How to read this page

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)

1. Info Hash provider slots are mutable, process-wide state
First rating: Low · Reviewed rating: Info · Review: rated too high
Location: index.ts:855
From the report
Evidence
const hashes = {
  sha512Async: async (message: TArg<Uint8Array>): Promise<TRet<Uint8Array>> => {
  sha512: undefined as undefined | ((message: TArg<Uint8Array>) => TRet<Uint8Array>),
Why it matters

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.

Suggested fix, not tested

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".

2. Info Point construction does not enforce the curve and extended-coordinate invariant
First rating: Low · Reviewed rating: Info · Review: rated too high
Location: index.ts:266 (line corrected on review; the report cites :280)
From the report
Evidence
static fromAffine(p: AffinePoint): Point {
  return new Point(p.x, p.y, 1n, modP(p.x * p.y));
Why it matters

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.

Suggested fix, not tested

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).

3. Info Benchmark measures only sequential, single-shot latency
First rating: Info · Reviewed rating: Info · Review: confirmed
From the report
Evidence
await mark('signAsync', () => curve.signAsync(msg, keys.secretKey));
await mark('verifyAsync', () => curve.verifyAsync(sig, msg, keys.publicKey));
Why it matters

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).

Suggested fix, not tested

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.

4. Info Point.fromHex decodes the whole input before checking its size
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
static fromHex(hex: string, zip215?: boolean): Point {
  return Point.fromBytes(hexToBytes(hex), zip215);
Why it matters

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.

Suggested fix, not tested

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.

5. Info One large module mixes several separable concerns
First rating: Low · Reviewed rating: Info · Review: rated too high
Location: index.ts:960 (whole file, about 1,100 lines)
From the report
Evidence
let Gpows: Point[] | undefined = undefined; // shared process-wide cache of base-point precomputes
Why it matters

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.

Suggested fix, not tested

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.

6. Info Tests and release do not exercise the published build artifact
First rating: Low · Reviewed rating: Info · Review: rated too high
From the report
Evidence
import * as ed from '../index.ts';
uses: paulmillr/jsbt/.github/workflows/release.yml@84f47b72e990ae11999fbe06c3efa449e943de60
Why it matters

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.

Suggested fix, not tested

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. ---------------------------------------------------------------------

Want this for your code?

Upload a ZIP or link a public GitHub repo, pick the areas and the depth, and get findings by severity with fixes.