From 18595396906980f9c1de05cbbea7c1bf820ec284 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:51 -0700 Subject: [PATCH 01/10] feat(random): add @endo/random source-agnostic samplers Introduce @endo/random: a new package providing source-agnostic sampling utilities (int, uint, random) built atop a pluggable fill-random-bytes source. Includes seeds for ChaCha20-based test sources and a fast-check-friendly _xorshift test helper. Implementation and tests ship together. --- packages/random/LICENSE | 201 ++++++++++++++++++++++ packages/random/README.md | 64 +++++++ packages/random/SECURITY.md | 38 +++++ packages/random/index.js | 11 ++ packages/random/int.js | 1 + packages/random/package.json | 88 ++++++++++ packages/random/random.js | 1 + packages/random/seeds.js | 51 ++++++ packages/random/src/int.js | 92 ++++++++++ packages/random/src/random.js | 26 +++ packages/random/src/uint.js | 106 ++++++++++++ packages/random/test/_chacha20.js | 129 ++++++++++++++ packages/random/test/_make-source.js | 25 +++ packages/random/test/_xorshift.js | 118 +++++++++++++ packages/random/test/int.test.js | 212 +++++++++++++++++++++++ packages/random/test/random.bench.js | 218 ++++++++++++++++++++++++ packages/random/test/random.test.js | 136 +++++++++++++++ packages/random/tsconfig.build.json | 12 ++ packages/random/tsconfig.composite.json | 12 ++ packages/random/tsconfig.json | 10 ++ packages/random/typedoc.json | 4 + packages/random/types.d.ts | 14 ++ packages/random/uint.js | 7 + 23 files changed, 1576 insertions(+) create mode 100644 packages/random/LICENSE create mode 100644 packages/random/README.md create mode 100644 packages/random/SECURITY.md create mode 100644 packages/random/index.js create mode 100644 packages/random/int.js create mode 100644 packages/random/package.json create mode 100644 packages/random/random.js create mode 100644 packages/random/seeds.js create mode 100644 packages/random/src/int.js create mode 100644 packages/random/src/random.js create mode 100644 packages/random/src/uint.js create mode 100644 packages/random/test/_chacha20.js create mode 100644 packages/random/test/_make-source.js create mode 100644 packages/random/test/_xorshift.js create mode 100644 packages/random/test/int.test.js create mode 100644 packages/random/test/random.bench.js create mode 100644 packages/random/test/random.test.js create mode 100644 packages/random/tsconfig.build.json create mode 100644 packages/random/tsconfig.composite.json create mode 100644 packages/random/tsconfig.json create mode 100644 packages/random/typedoc.json create mode 100644 packages/random/types.d.ts create mode 100644 packages/random/uint.js diff --git a/packages/random/LICENSE b/packages/random/LICENSE new file mode 100644 index 0000000000..f855f661ed --- /dev/null +++ b/packages/random/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Endo Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/random/README.md b/packages/random/README.md new file mode 100644 index 0000000000..336f404e37 --- /dev/null +++ b/packages/random/README.md @@ -0,0 +1,64 @@ +# `@endo/random` + +`@endo/random` is a small, source-agnostic library of random sampling functions. +Each sampler is its own module so consumers can import only what they need: + +```js +import { random } from '@endo/random/random.js'; +import { randomInt } from '@endo/random/int.js'; +``` + +Every sampler accepts a `RandomSource` as its first argument. +A `RandomSource` is simply a function `(out: Uint8Array) => void` that fills the supplied buffer with random bytes. +The shape mirrors `crypto.getRandomValues` (minus the return value) so that the canonical browser/Node entropy source and a `@endo/chacha12`-backed source returned by `makeChaCha12(seed)` are both directly usable as sampler arguments. + +Names follow the TC39 [proposal-random-functions](https://tc39.es/proposal-random-functions/) (Stage 1) translation `Random.method` -> `randomMethod`. + +| TC39 proposal | `@endo/random` | +| -------------------- | --------------------------- | +| `Random.random()` | `random(source)` | +| `Random.int(lo, hi)` | `randomInt(source, lo, hi)` | + +## Install + +```sh +npm install @endo/random +``` + +## `RandomSource` interface + +```ts +type RandomSource = (out: Uint8Array) => void; +``` + +A `RandomSource` writes random bytes into `out`. +Implementations MUST mutate the buffer in place and MUST NOT retain the buffer reference after the call returns. +Block-stream PRNGs such as `@endo/chacha12` provide this shape via the `fillRandomBytes` method on the `ChaCha12Generator` returned by `makeChaCha12(seed)`, internally managing block buffering. + +## Subpath exports + +| Path | Exports | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `@endo/random` | `random`, `randomInt`, `randomUint8`, `randomUint16`, `randomUint24`, `randomUint32`, `randomUint53` | +| `@endo/random/random.js` | `random` | +| `@endo/random/int.js` | `randomInt` | +| `@endo/random/uint.js` | `randomUint8`, `randomUint16`, `randomUint24`, `randomUint32`, `randomUint53` | +| `@endo/random/seeds.js` | `bobsCoffee32` (canonical 32-byte fuzz seed) | + +For an integration test that drives `@endo/chacha12` directly through `fast-check`'s `randomType` parameter (matching the `pure-rand@8` `RandomGenerator` contract), see the sibling [`@endo/chacha12-fast-check-test`](../chacha12-fast-check-test/) package. + +## Determinism + +`random` ensures that each respective returned value from streams with the same seed is equal across runs and engines by internally constructing a 53-bit integer and dividing it by `2 ** 53` (which avoids engine-dependent rounding). + +`randomInt(source, lo, hi)` uses range-aware rejection sampling: single-byte draws for ranges up to 128 (or exactly 256), two-byte draws up to 32768, and so on. +The per-draw reject probability `p` never exceeds 0.5, and consecutive draws are independent, so the probability of needing more than `k` draws is `p ** k` (decays exponentially) and the probability of an unbounded reject sequence is 0 in the limit. +The expected number of draws per call is `1 / (1 - p)`, bounded by 2. + +Different `RandomSource` implementations consume bytes at different rates, so the sequence of sampled values is determined by the seed _together with_ the source choice. +Switching backends produces a different stream. + +## Hardening + +Every exported function is hardened with `@endo/harden` and is safe to invoke from a SES vat or compartment. +The samplers are pure functions over their `source` argument; module state is limited to a single 8-byte scratch buffer that is zeroed after each call. diff --git a/packages/random/SECURITY.md b/packages/random/SECURITY.md new file mode 100644 index 0000000000..9dbbb79534 --- /dev/null +++ b/packages/random/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported Versions + +The SES package and associated Endo packages are still undergoing development and security review, and all +users are encouraged to use the latest version available. Security fixes will +be made for the most recent branch only. + +## Coordinated Vulnerability Disclosure of Security Bugs + +SES stands for fearless cooperation, and strong security requires strong collaboration with security researchers. If you believe that you have found a security sensitive bug that should not be disclosed until a fix has been made available, we encourage you to report it. To report a bug in HardenedJS, you have several options that include: + +* Reporting the issue to the [Agoric HackerOne vulnerability rewards program](https://hackerone.com/agoric). + +* Sending an email to security at (@) agoric.com., encrypted or unencrypted. To encrypt, please use @Warner’s personal GPG key [A476E2E6 11880C98 5B3C3A39 0386E81B 11CAA07A](http://www.lothar.com/warner-gpg.html) . + +* Sending a message on Keybase to `@agoric_security`, or sharing code and other log files via Keybase’s encrypted file system. ((_keybase_private/agoric_security,$YOURNAME). + +* It is important to be able to provide steps that reproduce the issue and demonstrate its impact with a Proof of Concept example in an initial bug report. Before reporting a bug, a reporter may want to have another trusted individual reproduce the issue. + +* A bug reporter can expect acknowledgment of a potential vulnerability reported through [security@agoric.com](mailto:security@agoric.com) within one business day of submitting a report. If an acknowledgement of an issue is not received within this time frame, especially during a weekend or holiday period, please reach out again. Any issues reported to the HackerOne program will be acknowledged within the time frames posted on the program page. + * The bug triage team and Agoric code maintainers are primarily located in the San Francisco Bay Area with business hours in [Pacific Time](https://www.timeanddate.com/worldclock/usa/san-francisco) . + +* For the safety and security of those who depend on the code, bug reporters should avoid publicly sharing the details of a security bug on Twitter, Discord, Telegram, or in public Github issues during the coordination process. + +* Once a vulnerability report has been received and triaged: + * Agoric code maintainers will confirm whether it is valid, and will provide updates to the reporter on validity of the report. + * It may take up to 72 hours for an issue to be validated, especially if reported during holidays or on weekends. + +* When the Agoric team has verified an issue, remediation steps and patch release timeline information will be shared with the reporter. + * Complexity, severity, impact, and likelihood of exploitation are all vital factors that determine the amount of time required to remediate an issue and distribute a software patch. + * If an issue is Critical or High Severity, Agoric code maintainers will release a security advisory to notify impacted parties to prepare for an emergency patch. + * While the current industry standard for vulnerability coordination resolution is 90 days, Agoric code maintainers will strive to release a patch as quickly as possible. + +When a bug patch is included in a software release, the Agoric code maintainers will: + * Confirm the version and date of the software release with the reporter. + * Provide information about the security issue that the software release resolves. + * Credit the bug reporter for discovery by adding thanks in release notes, securing a CVE designation, or adding the researcher’s name to a Hall of Fame. diff --git a/packages/random/index.js b/packages/random/index.js new file mode 100644 index 0000000000..84e177d9a9 --- /dev/null +++ b/packages/random/index.js @@ -0,0 +1,11 @@ +// @ts-check + +export { random } from './src/random.js'; +export { randomInt } from './src/int.js'; +export { + randomUint8, + randomUint16, + randomUint24, + randomUint32, + randomUint53, +} from './src/uint.js'; diff --git a/packages/random/int.js b/packages/random/int.js new file mode 100644 index 0000000000..ea8b76db63 --- /dev/null +++ b/packages/random/int.js @@ -0,0 +1 @@ +export { randomInt } from './src/int.js'; diff --git a/packages/random/package.json b/packages/random/package.json new file mode 100644 index 0000000000..aa0ba3c499 --- /dev/null +++ b/packages/random/package.json @@ -0,0 +1,88 @@ +{ + "name": "@endo/random", + "version": "0.1.0", + "description": "Source-agnostic random sampling functions", + "keywords": [ + "random", + "prng", + "sampling", + "endo", + "ses" + ], + "author": "Endo contributors", + "license": "Apache-2.0", + "homepage": "https://github.com/endojs/endo/blob/master/packages/random/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/endojs/endo.git", + "directory": "packages/random" + }, + "bugs": { + "url": "https://github.com/endojs/endo/issues" + }, + "type": "module", + "main": "./index.js", + "module": "./index.js", + "exports": { + ".": "./index.js", + "./random.js": "./random.js", + "./int.js": "./int.js", + "./uint.js": "./uint.js", + "./seeds.js": "./seeds.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "exit 0", + "prepack": "git clean -fX -e node_modules/ && tsc --build tsconfig.build.json", + "postpack": "git clean -fX -e node_modules/", + "bench": "node test/random.bench.js", + "cover": "c8 ses-ava", + "lint": "yarn lint:types && yarn lint:eslint", + "lint-fix": "eslint --fix .", + "lint:eslint": "eslint .", + "lint:types": "tsc", + "test": "ses-ava" + }, + "dependencies": { + "@endo/harden": "workspace:^" + }, + "devDependencies": { + "@endo/chacha12": "workspace:^", + "@endo/eventual-send": "workspace:^", + "@endo/init": "workspace:^", + "@endo/ses-ava": "workspace:^", + "ava": "catalog:dev", + "babel-eslint": "^10.1.0", + "c8": "catalog:dev", + "eslint": "catalog:dev", + "prettier": "^3.5.3", + "ses": "workspace:^", + "typescript": "catalog:dev" + }, + "files": [ + "./*.d.ts", + "./*.js", + "./*.map", + "LICENSE*", + "SECURITY*", + "dist", + "lib", + "src" + ], + "publishConfig": { + "access": "public" + }, + "eslintConfig": { + "extends": [ + "plugin:@endo/internal" + ] + }, + "sesAvaConfigs": { + "lockdown": "../../ava-endo-lockdown.config.mjs", + "unsafe": "../../ava-endo-lockdown-unsafe.config.mjs", + "endo": "../../ava-endo-shims-only.config.mjs" + }, + "typeCoverage": { + "atLeast": 95 + } +} diff --git a/packages/random/random.js b/packages/random/random.js new file mode 100644 index 0000000000..b2b748e36d --- /dev/null +++ b/packages/random/random.js @@ -0,0 +1 @@ +export { random } from './src/random.js'; diff --git a/packages/random/seeds.js b/packages/random/seeds.js new file mode 100644 index 0000000000..021426236e --- /dev/null +++ b/packages/random/seeds.js @@ -0,0 +1,51 @@ +// @ts-check + +// Canonical seeds for Endo deterministic tests and benchmarks. +// +// Chris Hibbert really wanted the default seed to be `Bob's Coffee +// Façade`, which is conveniently exactly 64 bits long, repeated to +// fill the 32-byte ChaCha-family key. + +import harden from '@endo/harden'; + +/** + * 32 bytes: `b0b5c0ffeefacade` repeated four times. The default + * seed for Endo deterministic fuzz tests and benchmarks; share this + * across packages so a single seed change propagates. + */ +export const bobsCoffee32 = harden( + Uint8Array.of( + 0xb0, + 0xb5, + 0xc0, + 0xff, + 0xee, + 0xfa, + 0xca, + 0xde, + 0xb0, + 0xb5, + 0xc0, + 0xff, + 0xee, + 0xfa, + 0xca, + 0xde, + 0xb0, + 0xb5, + 0xc0, + 0xff, + 0xee, + 0xfa, + 0xca, + 0xde, + 0xb0, + 0xb5, + 0xc0, + 0xff, + 0xee, + 0xfa, + 0xca, + 0xde, + ), +); diff --git a/packages/random/src/int.js b/packages/random/src/int.js new file mode 100644 index 0000000000..a066a1cc68 --- /dev/null +++ b/packages/random/src/int.js @@ -0,0 +1,92 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +import harden from '@endo/harden'; + +import { + randomUint8, + randomUint16, + randomUint24, + randomUint32, + randomUint53, +} from './uint.js'; + +/** @import { RandomSource } from '../types.d.ts' */ + +/** + * Returns a uniformly distributed integer in the closed interval + * `[lo, hi]`. Both bounds must be safe integers and `lo <= hi`. + * `hi - lo + 1` must also be a safe integer. + * + * The sampler reads the fewest bytes necessary to either match the + * requested range or cover at least two full repetitions of it, + * discarding draws beyond the last full repetition to eliminate + * modulo bias. The per-draw reject probability `p` never exceeds + * 0.5, and consecutive draws are independent, so the probability + * of needing more than `k` draws is `p ** k` and the probability + * of an unbounded reject sequence is 0 in the limit. The expected + * number of draws per call is `1 / (1 - p)`, bounded by 2. + * + * @param {RandomSource} source + * @param {number} lo + * @param {number} hi + * @returns {number} + */ +export const randomInt = (source, lo, hi) => { + if (!Number.isInteger(lo) || !Number.isInteger(hi)) { + throw TypeError('randomInt: lo and hi must be integers'); + } + if (lo > hi) { + throw RangeError(`randomInt: lo (${lo}) must be <= hi (${hi})`); + } + const range = hi - lo + 1; + if (!Number.isSafeInteger(range)) { + throw RangeError( + `randomInt: range hi - lo + 1 (${range}) must be a safe integer`, + ); + } + + // Pick the smallest draw width that covers `range` with a reject + // fraction below 0.5, and compute the corresponding `limit` (the + // largest multiple of `range` not exceeding the draw width's + // capacity). Draws in `[limit, capacity)` are rejected. + // + // Why the staircase rather than always using the widest draw? + // The narrow tiers conserve keystream bytes: `randomInt(0, 99)` + // reads one byte per draw instead of eight. For test fixtures and + // fuzzing this is largely incidental, but the widths also bound + // the *worst-case* reject fraction. Always using a single fixed + // width (e.g. 32 bits) leaves a pathological window: `randomInt(0, + // 2 ** 31)` would have its limit fall at `2 ** 31 + 1` repetitions, + // discarding nearly half of all draws. Stepping up the width when + // the range crosses a power-of-two ceiling caps the reject + // fraction at 0.5 across the entire safe-integer domain. The + // `range === capacity` short-circuits avoid an off-by-one in the + // exact-power case where `capacity % range === 0`. + let draw; + let limit; + if (range === 0x100 || range <= 0x80) { + draw = randomUint8; + limit = 0x100 - (0x100 % range); + } else if (range === 0x1_0000 || range <= 0x8000) { + draw = randomUint16; + limit = 0x1_0000 - (0x1_0000 % range); + } else if (range === 0x100_0000 || range <= 0x80_0000) { + draw = randomUint24; + limit = 0x100_0000 - (0x100_0000 % range); + } else if (range === 0x1_0000_0000 || range <= 0x8000_0000) { + draw = randomUint32; + limit = 0x1_0000_0000 - (0x1_0000_0000 % range); + } else { + draw = randomUint53; + limit = Math.floor(9_007_199_254_740_992 / range) * range; + } + + for (;;) { + const u = draw(source); + if (u < limit) { + return lo + (u % range); + } + } +}; +harden(randomInt); diff --git a/packages/random/src/random.js b/packages/random/src/random.js new file mode 100644 index 0000000000..db0363820d --- /dev/null +++ b/packages/random/src/random.js @@ -0,0 +1,26 @@ +// @ts-check + +import harden from '@endo/harden'; + +import { randomUint53 } from './uint.js'; + +// 1 / 2 ** 53. Multiplying a 53-bit non-negative integer by this +// produces a float in [0, 1) using deterministic integer arithmetic, +// so the same seed produces the same float across runs and engines. +const POW2_M53 = 1.110_223_024_625_156_5e-16; // = 2 ** -53 + +/** @import { RandomSource } from '../types.d.ts' */ + +/** + * Returns a float in `[0, 1)`. + * + * `random` ensures that each respective returned value from streams + * with the same seed is equal across runs and engines by internally + * constructing a 53-bit integer and dividing it by `2 ** 53` (which + * avoids engine-dependent rounding). + * + * @param {RandomSource} source + * @returns {number} + */ +export const random = source => randomUint53(source) * POW2_M53; +harden(random); diff --git a/packages/random/src/uint.js b/packages/random/src/uint.js new file mode 100644 index 0000000000..4e9caae02a --- /dev/null +++ b/packages/random/src/uint.js @@ -0,0 +1,106 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +// Little-endian unsigned-integer readers over a `RandomSource`. Used +// by `random()` and `randomInt()` to assemble fixed-width unsigned +// values from random bytes. Names follow the `Uint8Array` / +// `Uint16Array` precedent: bit width is part of the function name. +// +// All readers share a single 8-byte `ArrayBuffer` at module scope. +// Each reader has its own pre-allocated zero-offset `Uint8Array` view +// (one per width) so the hot path never calls `subarray`; a previous +// experiment measured per-call `subarray(0, n)` at ~2.5x slower on the +// int paths under Node 22 / x64, attributed to both the per-call +// allocation and the non-zero-offset view confusing V8's element-kind +// specialization. Pre-allocated zero-offset views avoid both. +// +// After each read, the reader zeroes the prefix it used so no +// random bytes linger in module state across calls (in particular +// across mixed-width call chains). The zeroing cost was measured +// at <3% of the per-call total, which is below the security +// tradeoff threshold; we keep it. + +import harden from '@endo/harden'; + +/** @import { RandomSource } from '../types.d.ts' */ + +const BUFFER = new ArrayBuffer(8); +const VIEW = new DataView(BUFFER); + +const BUF1 = new Uint8Array(BUFFER, 0, 1); +const BUF2 = new Uint8Array(BUFFER, 0, 2); +const BUF3 = new Uint8Array(BUFFER, 0, 3); +const BUF4 = new Uint8Array(BUFFER, 0, 4); +const BUF8 = new Uint8Array(BUFFER, 0, 8); + +/** + * @param {RandomSource} source + * @returns {number} unsigned 8-bit integer + */ +export const randomUint8 = source => { + source(BUF1); + const u = BUF1[0]; + BUF1[0] = 0; + return u; +}; +harden(randomUint8); + +/** + * @param {RandomSource} source + * @returns {number} unsigned 16-bit integer + */ +export const randomUint16 = source => { + source(BUF2); + const u = VIEW.getUint16(0, true); + BUF2[0] = 0; + BUF2[1] = 0; + return u; +}; +harden(randomUint16); + +/** + * @param {RandomSource} source + * @returns {number} unsigned 24-bit integer + */ +export const randomUint24 = source => { + source(BUF3); + // No native getUint24; combine 16-bit + 8-bit reads. + const u = VIEW.getUint16(0, true) | (BUF3[2] << 16); + BUF3[0] = 0; + BUF3[1] = 0; + BUF3[2] = 0; + return u >>> 0; +}; +harden(randomUint24); + +/** + * @param {RandomSource} source + * @returns {number} unsigned 32-bit integer + */ +export const randomUint32 = source => { + source(BUF4); + const u = VIEW.getUint32(0, true); + BUF4[0] = 0; + BUF4[1] = 0; + BUF4[2] = 0; + BUF4[3] = 0; + return u; +}; +harden(randomUint32); + +/** + * Assembles an unsigned 53-bit integer from 8 bytes. The high 11 + * bits of the upper 32-bit half are masked to 0 so the result is a + * non-negative IEEE-754 safe integer. + * + * @param {RandomSource} source + * @returns {number} unsigned 53-bit integer + */ +export const randomUint53 = source => { + source(BUF8); + const lo = VIEW.getUint32(0, true); + const hi21 = VIEW.getUint32(4, true) & 0x1f_ffff; + for (let i = 0; i < 8; i += 1) BUF8[i] = 0; + return hi21 * 4_294_967_296 + lo; +}; +harden(randomUint53); diff --git a/packages/random/test/_chacha20.js b/packages/random/test/_chacha20.js new file mode 100644 index 0000000000..57de59a70c --- /dev/null +++ b/packages/random/test/_chacha20.js @@ -0,0 +1,129 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +// Local ChaCha20 keystream generator used only by the comparative +// benchmark in `random.bench.js`. Mirrors `@endo/chacha12`'s +// `makeChaCha12` byte for byte except the inner loop runs 10 +// double-rounds (20 rounds) instead of 6 (12 rounds); kept inline as +// a comparison baseline rather than as a published package. + +import harden from '@endo/harden'; + +const ROUNDS = 20; +const BLOCK_SIZE = 64; + +const C0 = 0x6170_7865; +const C1 = 0x3320_646e; +const C2 = 0x7962_2d32; +const C3 = 0x6b20_6574; + +const rotl = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0; + +const quarterRound = (state, a, b, c, d) => { + let xa = state[a]; + let xb = state[b]; + let xc = state[c]; + let xd = state[d]; + xa = (xa + xb) >>> 0; + xd = rotl(xd ^ xa, 16); + xc = (xc + xd) >>> 0; + xb = rotl(xb ^ xc, 12); + xa = (xa + xb) >>> 0; + xd = rotl(xd ^ xa, 8); + xc = (xc + xd) >>> 0; + xb = rotl(xb ^ xc, 7); + state[a] = xa; + state[b] = xb; + state[c] = xc; + state[d] = xd; +}; + +const WORKING = new Uint32Array(16); + +const chacha20Block = (state, out) => { + const working = WORKING; + for (let i = 0; i < 16; i += 1) working[i] = state[i]; + for (let i = 0; i < ROUNDS; i += 2) { + quarterRound(working, 0, 4, 8, 12); + quarterRound(working, 1, 5, 9, 13); + quarterRound(working, 2, 6, 10, 14); + quarterRound(working, 3, 7, 11, 15); + quarterRound(working, 0, 5, 10, 15); + quarterRound(working, 1, 6, 11, 12); + quarterRound(working, 2, 7, 8, 13); + quarterRound(working, 3, 4, 9, 14); + } + // Match @endo/chacha12's shift-OR write strategy so the bench + // compares the algorithms, not the byte-store strategies. + for (let i = 0; i < 16; i += 1) { + const v = (working[i] + state[i]) >>> 0; + const off = i * 4; + out[off] = v & 0xff; + out[off + 1] = (v >>> 8) & 0xff; + out[off + 2] = (v >>> 16) & 0xff; + out[off + 3] = (v >>> 24) & 0xff; + } + for (let i = 0; i < 16; i += 1) working[i] = 0; +}; + +/** + * Returns a `(out: Uint8Array) => void` keystream source: same shape + * as `@endo/chacha12`'s `makeChaCha12`, so the bench can drive both + * uniformly. The allocation of `baseState` here is unavoidable: the + * state array carries the keystream across calls and cannot live at + * module scope without making the factory non-reentrant. + * + * @param {Uint8Array} key 32-byte key. + * @returns {(out: Uint8Array) => void} + */ +export const makeChaCha20 = key => { + if (!(key instanceof Uint8Array) || key.length !== 32) { + throw TypeError('chacha20 key must be a 32-byte Uint8Array'); + } + const baseState = new Uint32Array(16); + baseState[0] = C0; + baseState[1] = C1; + baseState[2] = C2; + baseState[3] = C3; + for (let i = 0; i < 8; i += 1) { + const off = i * 4; + baseState[4 + i] = + (key[off] | + (key[off + 1] << 8) | + (key[off + 2] << 16) | + (key[off + 3] << 24)) >>> + 0; + } + const block = new Uint8Array(BLOCK_SIZE); + let offset = BLOCK_SIZE; + let counter = 0; + const refill = () => { + if (counter >= 0x1_0000_0000) { + throw RangeError('chacha20 counter overflow'); + } + baseState[12] = counter >>> 0; + chacha20Block(baseState, block); + counter += 1; + offset = 0; + }; + const fillRandomBytes = out => { + if (!(out instanceof Uint8Array)) { + throw TypeError('chacha20 source: out must be a Uint8Array'); + } + let i = 0; + const end = out.length; + while (i < end) { + if (offset >= BLOCK_SIZE) refill(); + const available = BLOCK_SIZE - offset; + const want = end - i; + const n = available < want ? available : want; + for (let k = 0; k < n; k += 1) { + out[i + k] = block[offset + k]; + } + offset += n; + i += n; + } + }; + return harden(fillRandomBytes); +}; +harden(makeChaCha20); diff --git a/packages/random/test/_make-source.js b/packages/random/test/_make-source.js new file mode 100644 index 0000000000..f65b7e9fb2 --- /dev/null +++ b/packages/random/test/_make-source.js @@ -0,0 +1,25 @@ +// @ts-check + +// Test helper: build a ChaCha12-backed `RandomSource`. + +import { makeChaCha12 } from '@endo/chacha12'; + +const seedAll = (() => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) seed[i] = i; + return seed; +})(); + +const seedRev = (() => { + const seed = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) seed[i] = 31 - i; + return seed; +})(); + +/** @param {Uint8Array} seed */ +export const makeSource = seed => makeChaCha12(seed).fillRandomBytes; + +export const seedA = seedAll; +export const seedB = seedRev; + +export const cloneSeed = s => Uint8Array.from(s); diff --git a/packages/random/test/_xorshift.js b/packages/random/test/_xorshift.js new file mode 100644 index 0000000000..b608d1592c --- /dev/null +++ b/packages/random/test/_xorshift.js @@ -0,0 +1,118 @@ +// @ts-check +/* eslint no-bitwise:[0] */ + +// xorshift128+ baseline used only by the comparative bench in +// `random.bench.js`. Wraps the underlying 64-bit-int generator in a +// `(out: Uint8Array) => void` filling function so the bench can drive +// xorshift, ChaCha20, and ChaCha12 through the same source contract. +// +// Underlying 64-bit core forked from +// https://github.com/AndreasMadsen/xorshift/blob/d60ca9ca341957a9824908f733f30ce4592c9af4/xorshift.js + +import harden from '@endo/harden'; + +/** + * Underlying 64-bit xorshift128+ state. + * + * @param {number[]} seed "128-bit" integer, composed of 4x32-bit + * integers in big endian order. + */ +const makeCore = seed => { + if (!Array.isArray(seed) || seed.length !== 4) { + throw TypeError('seed must be an array with 4 numbers'); + } + let state0U = seed[0] | 0; + let state0L = seed[1] | 0; + let state1U = seed[2] | 0; + let state1L = seed[3] | 0; + + /** @returns {[number, number]} */ + const randomint = () => { + let s1U = state0U; + let s1L = state0L; + const s0U = state1U; + const s0L = state1L; + + const sumL = (s0L >>> 0) + (s1L >>> 0); + const resU = (s0U + s1U + ((sumL / 2) >>> 31)) >>> 0; + const resL = sumL >>> 0; + + state0U = s0U; + state0L = s0L; + + let t1U = 0; + let t1L = 0; + let t2U = 0; + let t2L = 0; + + const a1 = 23; + const m1 = 0xffff_ffff << (32 - a1); + t1U = (s1U << a1) | ((s1L & m1) >>> (32 - a1)); + t1L = s1L << a1; + s1U ^= t1U; + s1L ^= t1L; + + t1U = s1U ^ s0U; + t1L = s1L ^ s0L; + const a2 = 18; + const m2 = 0xffff_ffff >>> (32 - a2); + t2U = s1U >>> a2; + t2L = (s1L >>> a2) | ((s1U & m2) << (32 - a2)); + t1U ^= t2U; + t1L ^= t2L; + const a3 = 5; + const m3 = 0xffff_ffff >>> (32 - a3); + t2U = s0U >>> a3; + t2L = (s0L >>> a3) | ((s0U & m3) << (32 - a3)); + t1U ^= t2U; + t1L ^= t2L; + + state1U = t1U; + state1L = t1L; + + return [resU, resL]; + }; + + return { randomint }; +}; + +/** + * Returns a `(out: Uint8Array) => void` source backed by xorshift128+. + * Each 8-byte chunk consumes one 64-bit draw; trailing partial chunks + * use only as many bytes as needed and discard the rest. + * + * @param {number[]} seed + * @returns {(out: Uint8Array) => void} + */ +export const makeXorShift = seed => { + const core = makeCore(seed); + const block = new Uint8Array(8); + const view = new DataView(block.buffer); + let offset = 8; + const refill = () => { + const [u, l] = core.randomint(); + view.setUint32(0, u >>> 0, true); + view.setUint32(4, l >>> 0, true); + offset = 0; + }; + const fillRandomBytes = out => { + if (!(out instanceof Uint8Array)) { + throw TypeError('xorshift source: out must be a Uint8Array'); + } + let i = 0; + const end = out.length; + while (i < end) { + if (offset >= 8) refill(); + const available = 8 - offset; + const want = end - i; + const n = available < want ? available : want; + for (let k = 0; k < n; k += 1) { + out[i + k] = block[offset + k]; + } + offset += n; + i += n; + } + }; + return harden(fillRandomBytes); +}; +harden(makeXorShift); diff --git a/packages/random/test/int.test.js b/packages/random/test/int.test.js new file mode 100644 index 0000000000..44b100bb29 --- /dev/null +++ b/packages/random/test/int.test.js @@ -0,0 +1,212 @@ +// @ts-check + +import test from '@endo/ses-ava/test.js'; + +import { randomInt } from '../src/int.js'; +import { makeSource, seedA, cloneSeed } from './_make-source.js'; + +test('randomInt(source, lo, hi) yields integers in the closed interval', t => { + const source = makeSource(cloneSeed(seedA)); + for (let i = 0; i < 1000; i += 1) { + const x = randomInt(source, -7, 11); + t.true(Number.isInteger(x), `integer (got ${x})`); + t.true(x >= -7 && x <= 11, `in [-7, 11] (got ${x})`); + } +}); + +test('randomInt(source, lo, hi) covers both endpoints', t => { + const source = makeSource(cloneSeed(seedA)); + let sawLo = false; + let sawHi = false; + for (let i = 0; i < 5000 && !(sawLo && sawHi); i += 1) { + const x = randomInt(source, 0, 3); + if (x === 0) sawLo = true; + if (x === 3) sawHi = true; + } + t.true(sawLo, 'observed lo endpoint'); + t.true(sawHi, 'observed hi endpoint'); +}); + +test('randomInt with lo === hi returns lo', t => { + const source = makeSource(cloneSeed(seedA)); + for (let i = 0; i < 8; i += 1) { + t.is(randomInt(source, 7, 7), 7); + } +}); + +test('randomInt rejects non-integer / inverted bounds', t => { + const source = makeSource(cloneSeed(seedA)); + t.throws(() => randomInt(source, 1.5, 10), { instanceOf: TypeError }); + t.throws(() => randomInt(source, 0, 10.5), { instanceOf: TypeError }); + t.throws(() => randomInt(source, /** @type {any} */ ('x'), 10), { + instanceOf: TypeError, + }); + t.throws(() => randomInt(source, 10, 5), { instanceOf: RangeError }); +}); + +test('randomInt rejects unsafe range', t => { + const source = makeSource(cloneSeed(seedA)); + t.throws(() => randomInt(source, -(2 ** 53), 2 ** 53), { + instanceOf: RangeError, + }); +}); + +test('determinism: same seed produces same randomInt sequence', t => { + const a = makeSource(cloneSeed(seedA)); + const b = makeSource(cloneSeed(seedA)); + for (let i = 0; i < 32; i += 1) { + t.is(randomInt(a, 0, 999), randomInt(b, 0, 999)); + } +}); + +/** @typedef {import('../types.d.ts').RandomSource} RandomSource */ + +// Build a counting wrapper around a `RandomSource`. Tracks the +// total number of bytes drawn so far so tests can assert how many +// keystream bytes a sampler consumed. +/** @param {RandomSource} inner */ +const makeCountingSource = inner => { + let bytes = 0; + /** @type {RandomSource} */ + const wrapped = out => { + inner(out); + bytes += out.length; + }; + return { + source: wrapped, + bytes: () => bytes, + reset: () => { + bytes = 0; + }, + }; +}; + +// Range-aware rejection sampling: each tier of the draw-width +// staircase covers a specific range domain. This test exercises the +// boundaries: single-byte (range <= 128 and exact 256), two-byte (up +// to 32768 and exact 65536), three-byte (up to 8388608), four-byte +// (up to 2^31 and exact 2^32), and the 53-bit slow path (range > +// 2^32). Each branch produces values in the requested closed +// interval. +test('randomInt covers expected ranges across draw widths', t => { + const source = makeSource(cloneSeed(seedA)); + // single-byte path with reject set + for (let i = 0; i < 100; i += 1) { + const x = randomInt(source, 0, 99); + t.true(x >= 0 && x <= 99); + } + // single-byte path, exact power of two — no rejection + for (let i = 0; i < 100; i += 1) { + const x = randomInt(source, 0, 255); + t.true(x >= 0 && x <= 255); + } + // two-byte path + for (let i = 0; i < 100; i += 1) { + const x = randomInt(source, 0, 9999); + t.true(x >= 0 && x <= 9999); + } + // three-byte path: range 1_000_000 sits in (2 ** 16, 2 ** 23] + for (let i = 0; i < 100; i += 1) { + const x = randomInt(source, 0, 999_999); + t.true(x >= 0 && x <= 999_999); + } + // four-byte path: range 2 ** 30 sits in (2 ** 23, 2 ** 31] + const HI32 = 0x4000_0000; + for (let i = 0; i < 50; i += 1) { + const x = randomInt(source, 0, HI32 - 1); + t.true(x >= 0 && x < HI32); + } + // 53-bit slow path: range > 2 ** 32 + const HI53_LO = -(2 ** 40); + const HI53_HI = 2 ** 40; + for (let i = 0; i < 50; i += 1) { + const x = randomInt(source, HI53_LO, HI53_HI); + t.true(x >= HI53_LO && x <= HI53_HI); + t.true(Number.isSafeInteger(x)); + } +}); + +// Byte-count expectations: each tier of the staircase reads a +// specific number of bytes per draw. When the range is an exact +// power of two (or 256/65536/...) every draw is accepted; the +// per-call byte count is the draw width exactly. These assertions +// pin the staircase classification, so a regression that bumps a +// range into the wrong tier (and thus over-reads keystream bytes per +// call) fails this test rather than silently degrading throughput. +test('randomInt reads the expected number of bytes per draw width', t => { + const expectations = [ + { lo: 0, hi: 0xff, bytesPerDraw: 1, label: '8-bit (exact 256)' }, + { + lo: 0, + hi: 0x7f, + bytesPerDraw: 1, + label: '8-bit (range 128, no rejection)', + }, + { lo: 0, hi: 0xffff, bytesPerDraw: 2, label: '16-bit (exact 65536)' }, + { + lo: 0, + hi: 0x7fff, + bytesPerDraw: 2, + label: '16-bit (range 32768, no rejection)', + }, + { lo: 0, hi: 0xff_ffff, bytesPerDraw: 3, label: '24-bit (exact 16M)' }, + { + lo: 0, + hi: 0x7f_ffff, + bytesPerDraw: 3, + label: '24-bit (range 2^23, no rejection)', + }, + { lo: 0, hi: 0xffff_ffff, bytesPerDraw: 4, label: '32-bit (exact 2^32)' }, + { + lo: 0, + hi: 0x7fff_ffff, + bytesPerDraw: 4, + label: '32-bit (range 2^31, no rejection)', + }, + { + lo: -(2 ** 40), + hi: 2 ** 40 - 1, + bytesPerDraw: 8, + label: '53-bit slow path (range 2^41, no rejection)', + }, + ]; + for (const { lo, hi, bytesPerDraw, label } of expectations) { + const counted = makeCountingSource(makeSource(cloneSeed(seedA))); + const draws = 32; + for (let i = 0; i < draws; i += 1) { + randomInt(counted.source, lo, hi); + } + // No rejection on these ranges, so byte count is exact. + t.is( + counted.bytes(), + draws * bytesPerDraw, + `${label}: ${draws} draws should read ${draws * bytesPerDraw} bytes`, + ); + } +}); + +// When the range forces rejection, the byte count is at least +// `draws * bytesPerDraw` (every draw reads its width) but may be +// higher when the source returns a value above the acceptance limit. +// We assert the lower bound and a reasonable upper bound derived +// from the staircase's reject-fraction cap of 0.5. +test('randomInt with rejection reads at least the draw width per call', t => { + // Range 100: 1-byte staircase, reject set is [200, 256), so + // reject fraction 56/256 ~ 0.22. + const counted = makeCountingSource(makeSource(cloneSeed(seedA))); + const draws = 1000; + for (let i = 0; i < draws; i += 1) { + randomInt(counted.source, 0, 99); + } + t.true( + counted.bytes() >= draws, + `at least ${draws} bytes for ${draws} 1-byte draws`, + ); + // The staircase caps the reject fraction at 0.5, so expected + // bytes <= 2 * draws. Allow a generous margin for stochastic + // tail behavior in the seeded sequence. + t.true( + counted.bytes() <= draws * 4, + `should not exceed 4x byte count, got ${counted.bytes()} bytes`, + ); +}); diff --git a/packages/random/test/random.bench.js b/packages/random/test/random.bench.js new file mode 100644 index 0000000000..6fd894ced9 --- /dev/null +++ b/packages/random/test/random.bench.js @@ -0,0 +1,218 @@ +/* eslint-disable no-bitwise, @endo/restrict-comparison-operands */ +/* global globalThis */ + +// Benchmark: comparison of three seedable PRNGs across three +// workloads, all driven through `@endo/random`'s sampler functions: +// +// 1. Pulling 1 MiB of random bytes (filling a Uint8Array directly +// via the source). +// 2. 1 000 000 `random(source)` calls. +// 3. 1 000 000 `randomInt(source, 0, 99)` calls. +// +// Implementations: +// +// A. xorshift128+: the local copy in `_xorshift.js`, exposing a +// `(out: Uint8Array) => void` source. +// B. ChaCha20: local pure-JS ChaCha20 keystream in `_chacha20.js`, +// same shape. +// C. ChaCha12: `@endo/chacha12`'s `makeChaCha12`, same shape. +// +// All three sources implement the same function-shaped contract, so +// the bench drives them uniformly without per-source adapters. +// +// Run from `packages/random/`: +// node test/random.bench.js +// +// The bench file is named `*.bench.js` (not `*.test.js`) so the +// ses-ava test runner ignores it. + +import { makeChaCha12 } from '@endo/chacha12'; + +import { random } from '../src/random.js'; +import { randomInt } from '../src/int.js'; +import { makeChaCha20 } from './_chacha20.js'; +import { makeXorShift } from './_xorshift.js'; + +// Engine-portable nanosecond timer. +const hasHrtime = + typeof globalThis.process === 'object' && + globalThis.process !== null && + typeof globalThis.process.hrtime === 'function' && + typeof globalThis.process.hrtime.bigint === 'function'; +const nowNs = hasHrtime + ? () => Number(globalThis.process.hrtime.bigint()) + : () => Date.now() * 1_000_000; + +const seedShort = [0xb0b5_c0ff, 0xeefa_cade, 0xb0b5_c0ff, 0xeefa_cade]; +const seedBytes = (() => { + const s = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) s[i] = i; + return s; +})(); + +const time = (label, iters, fn) => { + // Warm-up. + fn(); + fn(); + const start = nowNs(); + for (let i = 0; i < iters; i += 1) fn(); + const elapsedNs = nowNs() - start; + const totalSec = elapsedNs / 1e9; + const perIterUs = elapsedNs / iters / 1000; + return { label, totalSec, perIterUs, elapsedNs, iters }; +}; + +const pad = (s, w) => { + let out = String(s); + while (out.length < w) out = ` ${out}`; + return out; +}; + +const printRow = ({ label, totalSec, perIterUs }, extra) => { + console.log( + ` ${label}${' '.repeat(Math.max(1, 32 - label.length))}${pad( + perIterUs.toFixed(3), + 11, + )} us/iter total ${pad(totalSec.toFixed(3), 6)} s${ + extra ? ` ${extra}` : '' + }`, + ); +}; + +const runBench = () => { + console.log( + `Node ${globalThis.process?.versions?.node || '?'} on ${ + globalThis.process?.platform || '?' + } / ${globalThis.process?.arch || '?'}`, + ); + console.log(''); + + // 1. Bulk bytes (1 MiB per call). + console.log('Workload: fill 1 MiB, 8 iterations'); + const N = 1 << 20; + const ITERS_BYTES = 8; + const bulk = {}; + { + const fillRandomBytes = makeXorShift([...seedShort]); + const out = new Uint8Array(N); + bulk.xorshift = time('xorshift128+', ITERS_BYTES, () => + fillRandomBytes(out), + ); + printRow( + bulk.xorshift, + `${((ITERS_BYTES * N) / 1024 / 1024).toFixed(0)} MiB total`, + ); + } + { + const fillRandomBytes = makeChaCha20(Uint8Array.from(seedBytes)); + const out = new Uint8Array(N); + bulk.chacha20 = time('chacha20 (20 rounds)', ITERS_BYTES, () => + fillRandomBytes(out), + ); + printRow( + bulk.chacha20, + `${((ITERS_BYTES * N) / 1024 / 1024).toFixed(0)} MiB total`, + ); + } + { + const { fillRandomBytes } = makeChaCha12(Uint8Array.from(seedBytes)); + const out = new Uint8Array(N); + bulk.chacha12 = time('chacha12 (12 rounds)', ITERS_BYTES, () => + fillRandomBytes(out), + ); + printRow( + bulk.chacha12, + `${((ITERS_BYTES * N) / 1024 / 1024).toFixed(0)} MiB total`, + ); + } + console.log(''); + + // 2. random() (1 000 000 calls). + console.log('Workload: random() x 1 000 000'); + const ITERS_RANDOM = 1_000_000; + { + const source = makeXorShift([...seedShort]); + printRow( + time('xorshift128+', 1, () => { + for (let i = 0; i < ITERS_RANDOM; i += 1) random(source); + }), + ); + } + { + const source = makeChaCha20(Uint8Array.from(seedBytes)); + printRow( + time('chacha20 (20 rounds)', 1, () => { + for (let i = 0; i < ITERS_RANDOM; i += 1) random(source); + }), + ); + } + { + const { fillRandomBytes: source } = makeChaCha12( + Uint8Array.from(seedBytes), + ); + printRow( + time('chacha12 (12 rounds)', 1, () => { + for (let i = 0; i < ITERS_RANDOM; i += 1) random(source); + }), + ); + } + console.log(''); + + // 3. randomInt(0, 99) (1 000 000 calls). + console.log('Workload: randomInt(0, 99) x 1 000 000'); + const ITERS_INT = 1_000_000; + { + const source = makeXorShift([...seedShort]); + printRow( + time('xorshift128+', 1, () => { + for (let i = 0; i < ITERS_INT; i += 1) randomInt(source, 0, 99); + }), + ); + } + { + const source = makeChaCha20(Uint8Array.from(seedBytes)); + printRow( + time('chacha20 (20 rounds)', 1, () => { + for (let i = 0; i < ITERS_INT; i += 1) randomInt(source, 0, 99); + }), + ); + } + { + const { fillRandomBytes: source } = makeChaCha12( + Uint8Array.from(seedBytes), + ); + printRow( + time('chacha12 (12 rounds)', 1, () => { + for (let i = 0; i < ITERS_INT; i += 1) randomInt(source, 0, 99); + }), + ); + } + console.log(''); + + // Summary: ChaCha12 throughput vs ChaCha20 on the bulk-bytes + // workload. + const totalBytes = ITERS_BYTES * N; + const mbPerSec = ({ elapsedNs }) => totalBytes / 1e6 / (elapsedNs / 1e9); + const nsPerByte = ({ elapsedNs }) => elapsedNs / totalBytes; + console.log('Bulk throughput (1 MiB fills):'); + console.log( + ` chacha20: ${mbPerSec(bulk.chacha20).toFixed(2)} MB/s ${nsPerByte( + bulk.chacha20, + ).toFixed(2)} ns/byte`, + ); + console.log( + ` chacha12: ${mbPerSec(bulk.chacha12).toFixed(2)} MB/s ${nsPerByte( + bulk.chacha12, + ).toFixed(2)} ns/byte`, + ); + const speedup = bulk.chacha20.elapsedNs / bulk.chacha12.elapsedNs; + console.log( + ` chacha12 / chacha20 speedup: ${speedup.toFixed(2)}x (${( + (speedup - 1) * + 100 + ).toFixed(1)}% faster)`, + ); + console.log(''); +}; + +runBench(); diff --git a/packages/random/test/random.test.js b/packages/random/test/random.test.js new file mode 100644 index 0000000000..b7d9d6fd17 --- /dev/null +++ b/packages/random/test/random.test.js @@ -0,0 +1,136 @@ +// @ts-check + +import test from '@endo/ses-ava/test.js'; + +import { random } from '../src/random.js'; +import { makeSource, seedA, seedB, cloneSeed } from './_make-source.js'; + +test('random(source) yields values in [0, 1)', t => { + const source = makeSource(cloneSeed(seedA)); + for (let i = 0; i < 1000; i += 1) { + const x = random(source); + t.true(Number.isFinite(x), 'finite'); + t.true(x >= 0, `x >= 0 (got ${x})`); + t.true(x < 1, `x < 1 (got ${x})`); + } +}); + +test('determinism: same seed produces same random() sequence', t => { + const a = makeSource(cloneSeed(seedA)); + const b = makeSource(cloneSeed(seedA)); + for (let i = 0; i < 32; i += 1) { + t.is(random(a), random(b), `random mismatch at index ${i}`); + } +}); + +test('different seeds produce different random() sequences', t => { + const a = makeSource(cloneSeed(seedA)); + const b = makeSource(cloneSeed(seedB)); + let differs = false; + for (let i = 0; i < 8; i += 1) { + if (random(a) !== random(b)) differs = true; + } + t.true(differs); +}); + +test('mean of 10000 random() samples is close to 0.5', t => { + const source = makeSource(cloneSeed(seedA)); + const n = 10_000; + let sum = 0; + for (let i = 0; i < n; i += 1) sum += random(source); + const mean = sum / n; + // True uniform stddev ~ sqrt(1/12) / sqrt(10000) ~= 0.00289. + t.true(Math.abs(mean - 0.5) < 0.05, `mean=${mean}`); +}); + +// We expect random() to achieve a uniform distribution by scaling down +// randomUint53() output by exactly `2 ** -53`. We exercise the scaling at +// four distinct bit-pattern sources (one per test below): every bit set, +// every bit clear, the 52 lowest bits of the 53-bit integer set, and all 53 +// bits set with the don't-care upper bits of the byte buffer cleared. The +// four sources pin both endpoints of the [0, 1) range and the midpoint, and +// demonstrate that the result is invariant under the 11 don't-care bits in +// the high octets of the 8-byte randomUint53 buffer (the implementation +// masks the upper 32 bits to 21, so bits 21..31 of the high half cannot +// influence the output). +// +// These tests are deliberately tight against the current randomUint53 +// recipe (8 little-endian octets, high 11 bits of the upper half masked). +// The brittleness is a feature, not a bug: a refactor that changes how +// octets are folded into the 53-bit integer (different endianness, +// different consumption width, different mask shape) will trip these tests +// and force an explicit decision rather than silently changing the +// float-extraction behavior. + +test('random() with all-bits-set source returns 1 - 2 ** -53', t => { + /** @param {Uint8Array} out */ + const allSetSource = out => { + for (let i = 0; i < out.length; i += 1) out[i] = 0xff; + }; + t.is(random(allSetSource), 1 - 2 ** -53); +}); + +test('random() with all-bits-clear source returns 0', t => { + /** @param {Uint8Array} out */ + const allClearSource = out => { + for (let i = 0; i < out.length; i += 1) out[i] = 0x00; + }; + t.is(random(allClearSource), 0); +}); + +test('random() with low-52-bits-set source returns 0.5 - 2 ** -53', t => { + // Low 52 bits of the 53-bit integer set, bit 52 clear. With the + // little-endian, low-32-then-high-21 recipe in randomUint53, the low + // 32-bit half is bytes 0..3 and the high 21-bit half is bytes 4..7 + // masked to 0x1fffff. Setting bytes 0..5 fully (32 + 16 = 48 bits) and + // byte 6 to 0x0f (4 more bits in positions 16..19 of the high half) + // yields hi21 = 0x0fffff and lo = 0xffffffff, so the integer is + // 2 ** 52 - 1 and the float is 0.5 - 2 ** -53. + /** @param {Uint8Array} out */ + const lo52SetSource = out => { + for (let i = 0; i < out.length; i += 1) out[i] = 0x00; + for (let i = 0; i < 6; i += 1) out[i] = 0xff; + if (out.length > 6) out[6] = 0x0f; + }; + t.is(random(lo52SetSource), 0.5 - 2 ** -53); +}); + +test('random() with all-53-bits-set source returns 1 - 2 ** -53', t => { + // All 53 bits of the 53-bit integer set, the 11 don't-care bits of the + // 8-byte buffer (bits 21..31 of the high 32-bit half) clear. Byte 6's + // low 5 bits contribute to hi21 and its high 3 bits do not; byte 7 is + // entirely don't-care. Setting bytes 0..5 fully and byte 6 to 0x1f + // (low 5 bits) with byte 7 clear yields hi21 = 0x1fffff and lo = + // 0xffffffff, so the integer is 2 ** 53 - 1 and the float is + // 1 - 2 ** -53. This source has the same float result as allSetSource + // but a different byte pattern, which is precisely the don't-care-bit + // invariance we want to lock in. + /** @param {Uint8Array} out */ + const all53SetSource = out => { + for (let i = 0; i < out.length; i += 1) out[i] = 0x00; + for (let i = 0; i < 6; i += 1) out[i] = 0xff; + if (out.length > 6) out[6] = 0x1f; + }; + t.is(random(all53SetSource), 1 - 2 ** -53); +}); + +// Pinned golden vector: first random() outputs for a fixed seed. +// Computed from the implementation the day this test was authored. +// If a future change silently alters the keystream or the +// float-extraction recipe, this fails. +// +// These values come from running the pure-JavaScript path with +// seed = [0..31]. The keystream itself is independently exercised +// by the Strombergson ChaCha12 vector tests in +// `@endo/chacha12/test/chacha12.test.js`; this test pins the +// float-extraction recipe specifically. +test('golden vector: random() is deterministic for a fixed seed', t => { + const source = makeSource(cloneSeed(seedA)); + const expected = [ + 0.202_492_713_878_710_48, 0.028_544_973_487_591_55, + 0.210_785_924_224_731_08, 0.815_777_666_479_445_7, + ]; + for (let i = 0; i < expected.length; i += 1) { + t.is(random(source), expected[i], `random[${i}] matches golden`); + } +}); diff --git a/packages/random/tsconfig.build.json b/packages/random/tsconfig.build.json new file mode 100644 index 0000000000..3e3877ed37 --- /dev/null +++ b/packages/random/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "./tsconfig.json", + "../../tsconfig-build-options.json" + ], + "compilerOptions": { + "allowJs": true + }, + "exclude": [ + "test/" + ] +} diff --git a/packages/random/tsconfig.composite.json b/packages/random/tsconfig.composite.json new file mode 100644 index 0000000000..5628aa177c --- /dev/null +++ b/packages/random/tsconfig.composite.json @@ -0,0 +1,12 @@ +// DO NOT EDIT! THIS FILE IS AUTO-GENERATED BY scripts/generate-composite-tsconfigs.mjs +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../harden/tsconfig.composite.json" + } + ] +} diff --git a/packages/random/tsconfig.json b/packages/random/tsconfig.json new file mode 100644 index 0000000000..d384c00b91 --- /dev/null +++ b/packages/random/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.eslint-base.json", + "include": [ + "*.js", + "*.ts", + "src", + "test", + "types.d.ts" + ] +} diff --git a/packages/random/typedoc.json b/packages/random/typedoc.json new file mode 100644 index 0000000000..af8565696f --- /dev/null +++ b/packages/random/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["index.js"] +} diff --git a/packages/random/types.d.ts b/packages/random/types.d.ts new file mode 100644 index 0000000000..60560e907d --- /dev/null +++ b/packages/random/types.d.ts @@ -0,0 +1,14 @@ +export {}; + +/** + * A `RandomSource` is a function that fills a `Uint8Array` with random + * bytes. The shape mirrors `crypto.getRandomValues` (minus the + * return value), so the canonical browser/Node entropy source and the + * `fillRandomBytes` method of a `@endo/chacha12`-backed + * `ChaCha12Generator` (returned by `makeChaCha12(seed)`) are both + * directly usable wherever a `RandomSource` is expected. + * + * Implementations MUST set every element of the supplied `Uint8Array` + * and MUST NOT retain any reference after the call returns. + */ +export type RandomSource = (out: Uint8Array) => void; diff --git a/packages/random/uint.js b/packages/random/uint.js new file mode 100644 index 0000000000..85e522c89f --- /dev/null +++ b/packages/random/uint.js @@ -0,0 +1,7 @@ +export { + randomUint8, + randomUint16, + randomUint24, + randomUint32, + randomUint53, +} from './src/uint.js'; From 538a66c987cb628f26c9f8f1a0dd364e380fe60b Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:51 -0700 Subject: [PATCH 02/10] feat(chacha12): add @endo/chacha12 pure-JS ChaCha12 keystream Introduce @endo/chacha12: a pure-JS ChaCha12 keystream package providing a fill-random-bytes source compatible with @endo/random's sampler interface. Includes README, BENCH.md, oracle vectors, fast-check parity tests, and a fill-random-bytes benchmark. Implementation and tests ship together. --- packages/chacha12/BENCH.md | 237 +++++++++++ packages/chacha12/CHANGELOG.md | 1 + packages/chacha12/LICENSE | 201 +++++++++ packages/chacha12/README.md | 124 ++++++ packages/chacha12/SECURITY.md | 38 ++ packages/chacha12/index.js | 3 + packages/chacha12/package.json | 83 ++++ packages/chacha12/src/chacha12.js | 400 ++++++++++++++++++ packages/chacha12/test/_oracle-spec.json | 135 ++++++ packages/chacha12/test/_oracle-vectors.json | 293 +++++++++++++ packages/chacha12/test/chacha12.test.js | 394 +++++++++++++++++ .../chacha12/test/fill-random-bytes.bench.js | 287 +++++++++++++ packages/chacha12/test/oracle-vectors.test.js | 97 +++++ packages/chacha12/tsconfig.build.json | 9 + packages/chacha12/tsconfig.composite.json | 12 + packages/chacha12/tsconfig.json | 9 + packages/chacha12/typedoc.json | 4 + 17 files changed, 2327 insertions(+) create mode 100644 packages/chacha12/BENCH.md create mode 100644 packages/chacha12/CHANGELOG.md create mode 100644 packages/chacha12/LICENSE create mode 100644 packages/chacha12/README.md create mode 100644 packages/chacha12/SECURITY.md create mode 100644 packages/chacha12/index.js create mode 100644 packages/chacha12/package.json create mode 100644 packages/chacha12/src/chacha12.js create mode 100644 packages/chacha12/test/_oracle-spec.json create mode 100644 packages/chacha12/test/_oracle-vectors.json create mode 100644 packages/chacha12/test/chacha12.test.js create mode 100644 packages/chacha12/test/fill-random-bytes.bench.js create mode 100644 packages/chacha12/test/oracle-vectors.test.js create mode 100644 packages/chacha12/tsconfig.build.json create mode 100644 packages/chacha12/tsconfig.composite.json create mode 100644 packages/chacha12/tsconfig.json create mode 100644 packages/chacha12/typedoc.json diff --git a/packages/chacha12/BENCH.md b/packages/chacha12/BENCH.md new file mode 100644 index 0000000000..ef415b5926 --- /dev/null +++ b/packages/chacha12/BENCH.md @@ -0,0 +1,237 @@ +# `@endo/chacha12` benchmark report + +Two measurement campaigns, both on the same workstation: + +1. [**ChaCha12 vs ChaCha20 throughput**](#chacha12-vs-chacha20-throughput) + with a `xorshift128+` baseline. + Drives the keystream through `@endo/random`'s sampler functions. + Harness: `packages/random/test/random.bench.js`. +2. [**`fillRandomBytes` inner-loop comparison**](#fillrandombytes-inner-loop-comparison) + measuring the byte-copy alternatives (current byte-by-byte loop vs + Duff-device unroll vs `Uint8Array.set` vs hybrids). + Harness: `packages/chacha12/test/fill-random-bytes.bench.js`. + +## Test bed + +Both campaigns ran on the same machine, so the test-bed table applies +to both unless a section below overrides it. + +| Field | Value | +| ----- | ------------------------------------------------ | +| CPU | AMD Ryzen AI MAX+ 395 w/ Radeon 8060S (32 vCPU) | +| RAM | 128 GiB | +| OS | Linux 6.14 (Ubuntu 24.04) | +| Node | 22.22.2 | +| Arch | x64 | + +The test bed is a developer workstation, not an isolated performance +lab. +Absolute numbers carry meaningful noise (±15% on the bulk-bytes +workload across 10 runs; about ±1% on the inner-loop bench, larger +for the smallest n where per-call timer overhead dominates). +Variant-vs-baseline ratios are the more stable comparison since both +share warm-up, allocation, and call-site shape. + +Each measurement includes two warm-up calls before the timed loop. +Numbers below are the **median of 10 independent runs** (each +re-launching the Node process). + +## ChaCha12 vs ChaCha20 throughput + +Harness: `packages/random/test/random.bench.js`. + +### Methodology + +Three workloads, run back-to-back within each process invocation, +via the samplers in `@endo/random`: + +1. **Bulk bytes**: the source is invoked directly as `source(out)` + with a 1 MiB pre-allocated `Uint8Array`, 8 times (8 MiB total + per source). +2. **`random(source)`**: 1 000 000 calls, single timed loop. +3. **`randomInt(source, 0, 99)`**: 1 000 000 calls, single timed + loop. + +The ChaCha20 keystream used here is bundled as +`packages/random/test/_chacha20.js`: the same algorithm referenced +by the test vectors, inlined as a comparison baseline. +Both ChaCha20 and ChaCha12 expose the `(out: Uint8Array) => void` +shape that `@endo/random`'s samplers consume directly. + +### Results + +#### Bulk bytes (1 MiB per call, 8 calls = 8 MiB) + +| PRNG | us/iter (median) | MB/s (median) | ns/byte (median) | +| ------------ | ---------------: | ------------: | ---------------: | +| xorshift128+ | 1507 | 696 | 1.44 | +| ChaCha20 | 5530 | 190 | 5.27 | +| ChaCha12 | 3726 | 281 | 3.55 | + +ChaCha12 / ChaCha20 speedup: **median 1.48x** across 10 runs +(range 1.39–2.07x; the high end is a single noisy chacha20 run, +the bottom-quartile speedup was still 1.43x). + +#### `random()` (1 million calls) + +| PRNG | total us (median) | ns/call (median) | +| ------------ | ----------------: | ---------------: | +| xorshift128+ | 17216 | 17.2 | +| ChaCha20 | 45355 | 45.4 | +| ChaCha12 | 35123 | 35.1 | + +ChaCha12 / ChaCha20 speedup: **1.29x**. +This is the cleanest measurement: a single hot loop with no +per-iteration allocation. +`random()` now drives a range-aware staircase that reads only as many +keystream bytes as the target precision needs, so xorshift no longer +benefits from the old single-word fast path and chacha pays for +proportionally fewer keystream bytes per call. + +#### `int(0, 99)` (1 million calls) + +| PRNG | total us (median) | ns/call (median) | +| ------------ | ----------------: | ---------------: | +| xorshift128+ | 11097 | 11.1 | +| ChaCha20 | 15650 | 15.6 | +| ChaCha12 | 14277 | 14.3 | + +ChaCha12 / ChaCha20 speedup: **1.10x**. +With `@endo/random`'s range-aware rejection sampling, +`randomInt(0, 99)` reads exactly **one** keystream byte per draw (not +four), so the chacha implementations no longer round-trip through +`random()` or pull a four-byte word. +The per-call cost is dominated by the rejection-sampling envelope +(state, mask, and reject-loop), which is shared across all three +sources, and the chacha12-vs-chacha20 gap correspondingly narrows. + +### Interpretation + +ChaCha12 is roughly **1.5x faster** than ChaCha20 on the +keystream-bound bulk workload, **~1.3x** on `random()`, and **~1.1x** +on `randomInt(0, 99)` in pure-JavaScript on this Node 22 / x64 +workstation. +The naive expectation from round-count alone would be 20 / 12 = 1.67x; +the realized bulk speedup is lower because per-block fixed costs +(state initialization, final state-add and little-endian write, +output buffering) are identical between the two and dilute the +savings on the inner loop. +The sampler workloads narrow the gap further because the per-call +envelope (range-aware staircase for `random()`, mask-and-reject for +`randomInt`) is shared across all sources and amortizes the keystream +difference. + +For a **PRNG** (not a cipher) the choice between ChaCha12 and +ChaCha20 is essentially a security-margin-vs-throughput knob. +Bernstein's original analysis (the eSTREAM submission) introduced +ChaCha8 / ChaCha12 / ChaCha20 as a graded family. +ChaCha12 retains a comfortable margin against the best published +attacks (no public attack improves over brute force on the full +12-round version) and has been used in performance-sensitive +contexts. +This package is **not** a cryptographic-cipher recommendation; when +the seed is caller-supplied and the consumer is a deterministic test +harness, the extra rounds in ChaCha20 buy nothing useful and the +throughput wins. + +For cipher use cases, prefer a 20-round implementation; for +deterministic test fixtures, property-based testing, fuzzing, and +simulation `@endo/chacha12` is the better tradeoff. + +## fillRandomBytes inner-loop comparison + +Harness: `packages/chacha12/test/fill-random-bytes.bench.js`. +Re-run with `node test/fill-random-bytes.bench.js` from +`packages/chacha12/`. + +This campaign measures the byte-copy inner loop in `fillRandomBytes` +against four alternatives and a hybrid. +Every variant is byte-identity-checked against the current +implementation across ~1500 `(offset, n, i)` shapes covering all +block-boundary positions. + +### Variants + +- **current**: `for (let k = 0; k < n; k += 1) out[i + k] = block[offset + k];` + (the live code). +- **duff8**: 8-way unrolled body with switch-fallthrough head for the + trailing 1-7 bytes (the classic Duff shape). +- **set**: `out.set(block.subarray(offset, offset + n), i)` — defers + to the JIT's typed-array memmove. +- **unroll4**: 4-way unrolled body with byte-by-byte tail. +- **copywin**: same as `set` (sanity probe; included to confirm the + `set` numbers are not a fluke). +- **hybrid32**: byte-by-byte for `n < 32`, else `set` — the obvious + "best of both" candidate. + +### Results: inner copy in isolation (median ns/call) + +| variant | tiny n=1 | tiny n=4 | tiny n=16 | medium n=64 | large n=1024 | large n=4096 | +|----------|-------------:|-------------:|-------------:|-------------:|--------------:|---------------:| +| current | 3.84 (1.00x) | 6.53 (1.00x) | 13.66 (1.00x)| 43.25 (1.00x)| 638.86 (1.00x)| 2504.28 (1.00x)| +| duff8 | 5.04 (1.31x) | 8.43 (1.29x) | 11.00 (0.81x)| 30.03 (0.69x)| 407.45 (0.64x)| 1616.34 (0.65x)| +| set | 22.05 (5.74x)| 22.30 (3.41x)| 22.21 (1.63x)| 22.23 (0.51x)| 29.12 (0.05x)| 51.53 (0.02x)| +| unroll4 | 5.22 (1.36x) | 6.36 (0.97x) | 12.59 (0.92x)| 39.34 (0.91x)| 581.74 (0.91x)| 2282.56 (0.91x)| +| copywin | 22.03 (5.74x)| 22.39 (3.43x)| 22.26 (1.63x)| 22.30 (0.52x)| 28.55 (0.04x)| 49.69 (0.02x)| +| hybrid32 | 4.75 (1.24x) | 6.52 (1.00x) | 14.11 (1.03x)| 23.45 (0.54x)| 30.38 (0.05x)| 52.23 (0.02x)| + +### Results: integrated `fillRandomBytes` (median ns/call) + +This includes the surrounding `chacha12Block` / refill / offset +bookkeeping, which is what callers actually pay. + +| variant | tiny n=1 | tiny n=4 | tiny n=16 | medium n=64 | large n=1024 | large n=4096 | +|----------|--------------:|--------------:|--------------:|--------------:|-----------------:|------------------:| +| current | 8.09 (1.00x) | 18.75 (1.00x) | 53.45 (1.00x) | 200.72 (1.00x)| 3158.74 (1.00x) | 12775.90 (1.00x) | +| duff8 | 10.89 (1.35x) | 20.44 (1.09x) | 51.13 (0.96x) | 185.99 (0.93x)| 2986.53 (0.95x) | 11858.69 (0.93x) | +| set | 26.69 (3.30x) | 34.87 (1.86x) | 66.58 (1.25x) | 183.94 (0.92x)| 2935.79 (0.93x) | 11776.74 (0.92x) | +| unroll4 | 9.62 (1.19x) | 18.70 (1.00x) | 52.56 (0.98x) | 196.21 (0.98x)| 3128.11 (0.99x) | 12501.76 (0.98x) | +| copywin | 26.48 (3.27x) | 34.71 (1.85x) | 66.08 (1.24x) | 182.93 (0.91x)| 2924.05 (0.93x) | 11744.19 (0.92x) | +| hybrid32 | 9.27 (1.14x) | 18.58 (0.99x) | 53.59 (1.00x) | 184.24 (0.92x)| 2951.88 (0.93x) | 11828.48 (0.93x) | + +### Verdict + +**Keep current; do not ship a change.** + +Two reasons. + +First, the inner-copy microbench numbers are spectacular for `set` +(50x faster on n=4096) and impressive for Duff (1.55x faster on +n=4096), but the *integrated* numbers, which is what real callers +pay, show only a 7-9% improvement on bulk fills. +The byte-copy is not the bottleneck; `chacha12Block` (12 rounds of +quarter-round arithmetic plus the LE write at the end) is. +A 50x win on a 5% subroutine is a 4% wall-clock win, which is what we +see. + +Second, `set` and Duff both regress meaningfully on the smallest +workloads. +The integrated `set` is **3.30x slower on n=1** (8.09 → 26.69 ns) and +**1.86x slower on n=4** (18.75 → 34.87 ns). +The n=4 case is real traffic: the `next()` slow path falls back to +`fillRandomBytes(buf)` with a 4-byte buffer whenever a 32-bit read +crosses a block boundary, which on average happens 1 in 16 calls when +block-aligned. +Duff is less bad (1.35x on n=1) but still regresses on the small end. + +The `hybrid32` variant (byte-copy below the threshold, `set` above) +avoids the small-fill cliff and matches `set` at large fills; its +integrated win on bulk is 7-9%, with a residual ~1.14x regression on +n=1 from the threshold branch itself. +That is the candidate worth shipping if any of these is. + +But the recommendation is **keep current** because: + +1. The 7-9% bulk-fill win is on a workload (`fillRandomBytes(out)` + with `out.length >= 64`) that is measurable but not the typical + PRNG use case here; `pure-rand`-style consumers go through `next()` + 4 bytes at a time, not bulk. +2. Adding a perf-only branch with a magic threshold introduces a + maintenance surface and a "why 32" question that does not pay back + in user-visible time. +3. If a future caller ever does want bulk-fill throughput, the bench + file is now in-tree and the obvious one-line change with the + integrated numbers above is on file. + +`unroll4` is a wash (within noise on every workload). +`copywin` matches `set` exactly, confirming the `set` numbers. diff --git a/packages/chacha12/CHANGELOG.md b/packages/chacha12/CHANGELOG.md new file mode 100644 index 0000000000..bc48de26cc --- /dev/null +++ b/packages/chacha12/CHANGELOG.md @@ -0,0 +1 @@ +# @endo/chacha12 diff --git a/packages/chacha12/LICENSE b/packages/chacha12/LICENSE new file mode 100644 index 0000000000..f855f661ed --- /dev/null +++ b/packages/chacha12/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Endo Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/chacha12/README.md b/packages/chacha12/README.md new file mode 100644 index 0000000000..b8cafaf1e0 --- /dev/null +++ b/packages/chacha12/README.md @@ -0,0 +1,124 @@ +# `@endo/chacha12` + +`@endo/chacha12` is a small, pure-JavaScript implementation of the +ChaCha12 keystream: the 12-round variant of Daniel J. Bernstein's +ChaCha family. +Given a 32-byte key it produces a deterministic, statistically +high-quality stream of bytes suitable for deterministic test +fixtures, property-based testing, and fuzzing. + +The package exposes two public entry points: `makeChaCha12` and +`makeChaCha12FromState`. + +`makeChaCha12(key)` returns a `ChaCha12Generator` record with four +methods: + +- `next()` returns a signed 32-bit integer in + `[-0x80000000, 0x7fffffff]` (the next 4 keystream bytes interpreted + little-endian) and advances the keystream by 4 bytes. +- `getState()` returns a serializable `readonly number[]` snapshot of + the generator's full state. + Pass it to `makeChaCha12FromState` to reconstruct an independent + generator that produces the same subsequent keystream. +- `clone()` returns a fully independent generator at the same + keystream position. +- `fillRandomBytes(out)` fills `out` with successive bytes of the + keystream. + This method matches `crypto.getRandomValues` (minus the return + value) and conforms to `@endo/random`'s `RandomSource` type. + +The `next` / `clone` / `getState` shape matches the `RandomGenerator` +contract that [`pure-rand`](https://github.com/dubzzz/pure-rand) v8 +exposes (and that `fast-check@4` consumes via its `randomType` +parameter), so a `ChaCha12Generator` can plug directly into a +property-based test framework that expects a `pure-rand`-style +generator. + +`makeChaCha12FromState(state)` reconstructs a generator from a state +snapshot returned by a previous `getState()` call. +See `@endo/chacha12-fast-check-test` for an integration test that +drives `fast-check`'s `RandomGenerator` adapter through this surface. + +The ChaCha12 block function is identical to +[ChaCha20](https://datatracker.ietf.org/doc/html/rfc8439) modulo the +loop count: 6 double-rounds (12 rounds) instead of 10 (20 rounds). +The reduced round count trades cryptographic safety margin for speed. + +For cipher use cases, prefer ChaCha20 or another 20-round +implementation. +ChaCha20 has a larger published security margin and remains the +cryptographer's first choice for cipher work. +ChaCha12 has no public attack that improves on brute force, but the +12-round version is best understood as a PRNG choice (a +throughput-vs-margin knob), not a cipher recommendation. + +ChaCha12 (like ChaCha20) **must not be used to derive cryptographic +keys** when the seed is caller-supplied. +This package is a PRNG keystream, not a key-derivation function. + +## Install + +```sh +npm install @endo/chacha12 +``` + +## Usage + +```js +import { makeChaCha12, makeChaCha12FromState } from '@endo/chacha12'; +import { random } from '@endo/random/random.js'; +import { randomInt } from '@endo/random/int.js'; + +// Seed: 32-byte Uint8Array (ChaCha12 key). +const seed = new Uint8Array(32); +seed[0] = 0x42; + +const gen = makeChaCha12(seed); + +// Use the byte-fill entry point as a `RandomSource`. +const buffer = new Uint8Array(16); +gen.fillRandomBytes(buffer); + +// Or pass `fillRandomBytes` to @endo/random samplers. +const { fillRandomBytes } = makeChaCha12(seed); +random(fillRandomBytes); // float in [0, 1) +randomInt(fillRandomBytes, 0, 99); // integer in [0, 99] + +// Use the int32 entry point (matches pure-rand v8 RandomGenerator). +const v = gen.next(); // signed int32 + +// Snapshot and resume. +const snapshot = gen.getState(); +const resumed = makeChaCha12FromState(snapshot); +// `resumed` produces the same subsequent keystream as `gen`. +``` + +The seed must be a 32-byte `Uint8Array`; `makeChaCha12` throws +`TypeError` on any other shape. +The returned generator and all of its methods are hardened with +`@endo/harden`. + +## Bound on keystream length + +A given source can produce at most 256 GiB of keystream; calls beyond +that throw `RangeError`. +In practice no test suite consumes anywhere close to this. + +## Verification + +The keystream is cross-checked against three published ChaCha12 test +vectors from +[`draft-strombergson-chacha-test-vectors-01`](https://datatracker.ietf.org/doc/html/draft-strombergson-chacha-test-vectors-01) +(TC1, TC4, TC8) by `test/chacha12.test.js`. +The sampling functions in `@endo/random` carry their own determinism +vectors. + +## ChaCha12 vs ChaCha20 + +ChaCha12 is faster than ChaCha20 by roughly the ratio of round counts +(12 / 20 = 0.6), modulo per-call overhead. +The benchmark that measures both keystreams (and an `xorshift128+` +baseline) side by side lives in `@endo/random/test/random.bench.js`, +since it drives the keystreams through `@endo/random`'s sampler +functions. +See `BENCH.md` in this directory for a recent measurement. diff --git a/packages/chacha12/SECURITY.md b/packages/chacha12/SECURITY.md new file mode 100644 index 0000000000..9dbbb79534 --- /dev/null +++ b/packages/chacha12/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported Versions + +The SES package and associated Endo packages are still undergoing development and security review, and all +users are encouraged to use the latest version available. Security fixes will +be made for the most recent branch only. + +## Coordinated Vulnerability Disclosure of Security Bugs + +SES stands for fearless cooperation, and strong security requires strong collaboration with security researchers. If you believe that you have found a security sensitive bug that should not be disclosed until a fix has been made available, we encourage you to report it. To report a bug in HardenedJS, you have several options that include: + +* Reporting the issue to the [Agoric HackerOne vulnerability rewards program](https://hackerone.com/agoric). + +* Sending an email to security at (@) agoric.com., encrypted or unencrypted. To encrypt, please use @Warner’s personal GPG key [A476E2E6 11880C98 5B3C3A39 0386E81B 11CAA07A](http://www.lothar.com/warner-gpg.html) . + +* Sending a message on Keybase to `@agoric_security`, or sharing code and other log files via Keybase’s encrypted file system. ((_keybase_private/agoric_security,$YOURNAME). + +* It is important to be able to provide steps that reproduce the issue and demonstrate its impact with a Proof of Concept example in an initial bug report. Before reporting a bug, a reporter may want to have another trusted individual reproduce the issue. + +* A bug reporter can expect acknowledgment of a potential vulnerability reported through [security@agoric.com](mailto:security@agoric.com) within one business day of submitting a report. If an acknowledgement of an issue is not received within this time frame, especially during a weekend or holiday period, please reach out again. Any issues reported to the HackerOne program will be acknowledged within the time frames posted on the program page. + * The bug triage team and Agoric code maintainers are primarily located in the San Francisco Bay Area with business hours in [Pacific Time](https://www.timeanddate.com/worldclock/usa/san-francisco) . + +* For the safety and security of those who depend on the code, bug reporters should avoid publicly sharing the details of a security bug on Twitter, Discord, Telegram, or in public Github issues during the coordination process. + +* Once a vulnerability report has been received and triaged: + * Agoric code maintainers will confirm whether it is valid, and will provide updates to the reporter on validity of the report. + * It may take up to 72 hours for an issue to be validated, especially if reported during holidays or on weekends. + +* When the Agoric team has verified an issue, remediation steps and patch release timeline information will be shared with the reporter. + * Complexity, severity, impact, and likelihood of exploitation are all vital factors that determine the amount of time required to remediate an issue and distribute a software patch. + * If an issue is Critical or High Severity, Agoric code maintainers will release a security advisory to notify impacted parties to prepare for an emergency patch. + * While the current industry standard for vulnerability coordination resolution is 90 days, Agoric code maintainers will strive to release a patch as quickly as possible. + +When a bug patch is included in a software release, the Agoric code maintainers will: + * Confirm the version and date of the software release with the reporter. + * Provide information about the security issue that the software release resolves. + * Credit the bug reporter for discovery by adding thanks in release notes, securing a CVE designation, or adding the researcher’s name to a Hall of Fame. diff --git a/packages/chacha12/index.js b/packages/chacha12/index.js new file mode 100644 index 0000000000..1b6345bcd6 --- /dev/null +++ b/packages/chacha12/index.js @@ -0,0 +1,3 @@ +// @ts-check + +export { makeChaCha12, makeChaCha12FromState } from './src/chacha12.js'; diff --git a/packages/chacha12/package.json b/packages/chacha12/package.json new file mode 100644 index 0000000000..4791fd0845 --- /dev/null +++ b/packages/chacha12/package.json @@ -0,0 +1,83 @@ +{ + "name": "@endo/chacha12", + "version": "0.1.0", + "description": "ChaCha12 keystream primitive and block-stream source", + "keywords": [ + "chacha12", + "chacha", + "keystream", + "prng", + "endo", + "ses" + ], + "author": "Endo contributors", + "license": "Apache-2.0", + "homepage": "https://github.com/endojs/endo/blob/master/packages/chacha12/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/endojs/endo.git", + "directory": "packages/chacha12" + }, + "bugs": { + "url": "https://github.com/endojs/endo/issues" + }, + "type": "module", + "main": "./index.js", + "module": "./index.js", + "exports": { + ".": "./index.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "exit 0", + "prepack": "git clean -fX -e node_modules/ && tsc --build tsconfig.build.json", + "postpack": "git clean -fX -e node_modules/", + "cover": "c8 ses-ava", + "lint": "yarn lint:types && yarn lint:eslint", + "lint-fix": "eslint --fix .", + "lint:eslint": "eslint .", + "lint:types": "tsc", + "test": "ses-ava" + }, + "dependencies": { + "@endo/harden": "workspace:^" + }, + "devDependencies": { + "@endo/eventual-send": "workspace:^", + "@endo/init": "workspace:^", + "@endo/ses-ava": "workspace:^", + "ava": "catalog:dev", + "babel-eslint": "^10.1.0", + "c8": "catalog:dev", + "eslint": "catalog:dev", + "prettier": "^3.5.3", + "ses": "workspace:^", + "typescript": "catalog:dev" + }, + "files": [ + "./*.d.ts", + "./*.js", + "./*.map", + "LICENSE*", + "SECURITY*", + "dist", + "lib", + "src" + ], + "publishConfig": { + "access": "public" + }, + "eslintConfig": { + "extends": [ + "plugin:@endo/internal" + ] + }, + "sesAvaConfigs": { + "lockdown": "../../ava-endo-lockdown.config.mjs", + "unsafe": "../../ava-endo-lockdown-unsafe.config.mjs", + "endo": "../../ava-endo-shims-only.config.mjs" + }, + "typeCoverage": { + "atLeast": 95 + } +} diff --git a/packages/chacha12/src/chacha12.js b/packages/chacha12/src/chacha12.js new file mode 100644 index 0000000000..e6df4f47b5 --- /dev/null +++ b/packages/chacha12/src/chacha12.js @@ -0,0 +1,400 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +// Pure-JavaScript ChaCha12 keystream generator. +// +// ChaCha12 is the 12-round variant of Daniel J. Bernstein's ChaCha +// stream cipher. The block function is otherwise identical to +// ChaCha20 (same quarter round, same state layout, same "expand +// 32-byte k" constants, same little-endian conventions, same final +// state add). The only difference is the loop count: 6 +// double-rounds in ChaCha12 (= 12 rounds), 10 in ChaCha20. +// +// `makeChaCha12(key)` returns a `ChaCha12Generator` record with +// `next`, `getState`, `clone`, and `fillRandomBytes` methods. The +// shape was chosen to align with `pure-rand@8`'s `RandomGenerator` +// interface (the contract `fast-check@4` uses to drive property-based +// tests): +// +// interface RandomGenerator { +// next(): number; // signed int32 +// clone(): RandomGenerator; // independent copy +// getState(): readonly number[]; // serializable snapshot +// } +// +// `fillRandomBytes(out)` is the pre-existing byte-keystream entry +// point preserved for `@endo/random` and other consumers that want a +// `(out: Uint8Array) => void` `RandomSource`. +// +// `makeChaCha12FromState(state)` reconstructs a generator at the +// position recorded by a previous `getState()`, completing the +// pure-rand convention of paired `xxxFromState(state)` factories. +// +// `chacha12Block(state, out)` and `chacha12State(key, nonce?, +// counter?)` are exported for known-answer testing against +// block-function test vectors that supply a non-zero nonce and +// counter. + +import harden from '@endo/harden'; + +const ROUNDS = 12; + +/** + * The size in bytes of one ChaCha12 keystream block. Exported for + * callers that want to align allocation with block boundaries. + */ +export const BLOCK_SIZE = 64; + +// "expand 32-byte k", little-endian u32 of "expa", "nd 3", "2-by", +// "te k". Same constants as ChaCha20. +const C0 = 0x6170_7865; +const C1 = 0x3320_646e; +const C2 = 0x7962_2d32; +const C3 = 0x6b20_6574; + +// `getState()` shape: [16 base words, counter, offset, 16 block words]. +// Total length is 34 numbers. Both the counter and the offset are +// always present so the array shape is uniform regardless of whether +// the generator is mid-block or block-aligned. When `offset` is at +// `BLOCK_SIZE` the trailing 16 block words are zero (the next call +// will refill). +const STATE_LENGTH = 34; + +const rotl = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0; + +const quarterRound = (state, a, b, c, d) => { + let xa = state[a]; + let xb = state[b]; + let xc = state[c]; + let xd = state[d]; + xa = (xa + xb) >>> 0; + xd = rotl(xd ^ xa, 16); + xc = (xc + xd) >>> 0; + xb = rotl(xb ^ xc, 12); + xa = (xa + xb) >>> 0; + xd = rotl(xd ^ xa, 8); + xc = (xc + xd) >>> 0; + xb = rotl(xb ^ xc, 7); + state[a] = xa; + state[b] = xb; + state[c] = xc; + state[d] = xd; +}; + +// Module-scope working buffer for `chacha12Block`. Reused across +// every block invocation; cleared in place rather than reallocated. +const WORKING = new Uint32Array(16); + +/** + * Computes one ChaCha12 keystream block. `state` is a 16-word + * Uint32Array organized like ChaCha20 (4 constants, 8 key words, 1 + * counter, 3 nonce). `out` receives 64 bytes of little-endian + * keystream. Caller is responsible for incrementing the counter + * between calls. + * + * Exported for known-answer testing against ChaCha12 vectors. + * + * @param {Uint32Array} state + * @param {Uint8Array} out + */ +export const chacha12Block = (state, out) => { + if (state.length !== 16) { + throw TypeError('chacha12 state must be 16 u32 words'); + } + if (out.length !== BLOCK_SIZE) { + throw TypeError(`chacha12 output must be ${BLOCK_SIZE} bytes`); + } + const working = WORKING; + for (let i = 0; i < 16; i += 1) working[i] = state[i]; + // 6 column-round + diagonal-round pairs = 12 rounds total. + for (let i = 0; i < ROUNDS; i += 2) { + // Column round. + quarterRound(working, 0, 4, 8, 12); + quarterRound(working, 1, 5, 9, 13); + quarterRound(working, 2, 6, 10, 14); + quarterRound(working, 3, 7, 11, 15); + // Diagonal round. + quarterRound(working, 0, 5, 10, 15); + quarterRound(working, 1, 6, 11, 12); + quarterRound(working, 2, 7, 8, 13); + quarterRound(working, 3, 4, 9, 14); + } + // Manual little-endian u32 writes. A `DataView` is a clearer + // expression of "endian-correct u32 store", but constructing one + // per block-function call costs more than the writes save: a + // microbenchmark on Node 22 / x64 found `new DataView` + 16 + // `setUint32` to be ~12% slower than scalar byte writes here. + for (let i = 0; i < 16; i += 1) { + const v = (working[i] + state[i]) >>> 0; + const off = i * 4; + out[off] = v & 0xff; + out[off + 1] = (v >>> 8) & 0xff; + out[off + 2] = (v >>> 16) & 0xff; + out[off + 3] = (v >>> 24) & 0xff; + } + // Clear working state so no keystream-derived bits linger between + // calls. + for (let i = 0; i < 16; i += 1) working[i] = 0; +}; +harden(chacha12Block); + +/** + * Builds a 16-word ChaCha12 state from a 32-byte key, optional + * 12-byte nonce, and optional 32-bit counter. Exported for ChaCha12 + * test vectors and reused by `makeChaCha12` below. + * + * @param {Uint8Array} key 32 bytes + * @param {Uint8Array} [nonce] 12 bytes + * @param {number} [counter] unsigned 32-bit + * @returns {Uint32Array} + */ +export const chacha12State = (key, nonce = undefined, counter = 0) => { + if (!(key instanceof Uint8Array) || key.length !== 32) { + throw TypeError('chacha12 key must be a 32-byte Uint8Array'); + } + if (nonce && (!(nonce instanceof Uint8Array) || nonce.length !== 12)) { + throw TypeError('chacha12 nonce must be 12 bytes'); + } + const state = new Uint32Array(16); + state[0] = C0; + state[1] = C1; + state[2] = C2; + state[3] = C3; + // Manual little-endian u32 reads. As with `chacha12Block` above, + // constructing a `DataView` per call costs more than the reads + // save: the bench measured `chacha12State(key, nonce)` ~3.4x + // slower with DataView than with scalar byte loads on Node 22. + for (let i = 0; i < 8; i += 1) { + const off = i * 4; + state[4 + i] = + (key[off] | + (key[off + 1] << 8) | + (key[off + 2] << 16) | + (key[off + 3] << 24)) >>> + 0; + } + state[12] = counter >>> 0; + if (nonce) { + state[13] = + (nonce[0] | (nonce[1] << 8) | (nonce[2] << 16) | (nonce[3] << 24)) >>> 0; + state[14] = + (nonce[4] | (nonce[5] << 8) | (nonce[6] << 16) | (nonce[7] << 24)) >>> 0; + state[15] = + (nonce[8] | (nonce[9] << 8) | (nonce[10] << 16) | (nonce[11] << 24)) >>> + 0; + } + return state; +}; +harden(chacha12State); + +/** + * @typedef {object} ChaCha12Generator + * @property {() => number} next Returns a signed 32-bit integer in + * `[-0x80000000, 0x7fffffff]` drawn from the next 4 keystream + * bytes (little-endian), advancing the keystream by 4 bytes. This + * matches the `pure-rand` v8 `RandomGenerator.next` contract. + * @property {() => readonly number[]} getState Returns a serializable + * snapshot of the generator's full state: `[base0..base15, counter, + * offset, block0..block15]`, 34 numbers in total. Pass to + * `makeChaCha12FromState` to reconstruct an independent generator + * that produces the same subsequent keystream. + * @property {() => ChaCha12Generator} clone Returns a fully + * independent generator at the same keystream position. Calling + * `next` / `fillRandomBytes` on the clone does not affect this + * generator and vice versa. + * @property {(out: Uint8Array) => void} fillRandomBytes Fills `out` + * with successive bytes of the keystream. Conforms to + * `@endo/random`'s `RandomSource` and to `crypto.getRandomValues` + * (minus the return value), so this method is interchangeable with + * either as a byte source. + */ + +// Internal builder shared by `makeChaCha12` and +// `makeChaCha12FromState`. Takes the three pieces of mutable state +// (base, counter, offset) plus the current block buffer (which may +// be empty when offset === BLOCK_SIZE) and returns the public +// generator. +/** + * @param {Uint32Array} baseState 16 u32 words, ownership transferred + * to the generator (must not be retained by the caller). + * @param {number} initialCounter + * @param {number} initialOffset + * @param {Uint8Array} initialBlock 64 bytes, ownership transferred + * (must not be retained by the caller). + * @returns {ChaCha12Generator} + */ +const makeGenerator = ( + baseState, + initialCounter, + initialOffset, + initialBlock, +) => { + let counter = initialCounter; + let offset = initialOffset; + const block = initialBlock; + + const refill = () => { + // Correctness guard at 256 GiB of keystream; not test-reachable. + /* c8 ignore start */ + if (counter >= 0x1_0000_0000) { + throw RangeError('chacha12 counter overflow (2^32 blocks exhausted)'); + } + /* c8 ignore stop */ + baseState[12] = counter >>> 0; + chacha12Block(baseState, block); + counter += 1; + offset = 0; + }; + + /** @param {Uint8Array} out */ + const fillRandomBytes = out => { + let i = 0; + const end = out.length; + while (i < end) { + if (offset >= BLOCK_SIZE) refill(); + const available = BLOCK_SIZE - offset; + const want = end - i; + const n = available < want ? available : want; + for (let k = 0; k < n; k += 1) { + out[i + k] = block[offset + k]; + } + offset += n; + i += n; + } + }; + + // 32-bit signed integer reader. Pulls 4 little-endian bytes from + // the keystream and reinterprets them as int32 with `| 0`. This is + // the `pure-rand` v8 `next()` contract: values in + // `[-0x80000000, 0x7fffffff]`, mutating the generator state. + const next = () => { + if (offset + 4 <= BLOCK_SIZE) { + // Hot path: 4 bytes available in the current block. + const b0 = block[offset]; + const b1 = block[offset + 1]; + const b2 = block[offset + 2]; + const b3 = block[offset + 3]; + offset += 4; + return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) | 0; + } + // Slow path: spans a block boundary (or starts at the boundary). + // Defer to `fillRandomBytes` so the cross-block plumbing lives in + // exactly one place. + const buf = new Uint8Array(4); + fillRandomBytes(buf); + return buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24) | 0; + }; + + const getState = () => { + const snapshot = new Array(STATE_LENGTH); + for (let i = 0; i < 16; i += 1) snapshot[i] = baseState[i]; + snapshot[16] = counter; + snapshot[17] = offset; + // Block buffer encoded as 16 little-endian u32 words. When + // `offset === BLOCK_SIZE` the block bytes are unused but we copy + // them anyway to keep the array shape uniform; the + // reconstruction path will then refill on first use. + for (let i = 0; i < 16; i += 1) { + const off = i * 4; + snapshot[18 + i] = + (block[off] | + (block[off + 1] << 8) | + (block[off + 2] << 16) | + (block[off + 3] << 24)) >>> + 0; + } + return harden(snapshot); + }; + + const clone = () => { + const baseCopy = new Uint32Array(16); + for (let i = 0; i < 16; i += 1) baseCopy[i] = baseState[i]; + const blockCopy = new Uint8Array(BLOCK_SIZE); + for (let i = 0; i < BLOCK_SIZE; i += 1) blockCopy[i] = block[i]; + return makeGenerator(baseCopy, counter, offset, blockCopy); + }; + + return harden({ next, getState, clone, fillRandomBytes }); +}; + +/** + * Creates a ChaCha12-backed `RandomGenerator` keyed by `key`, with + * counter starting at 0 and an all-zero nonce. + * + * The returned `ChaCha12Generator` exposes both the `pure-rand` v8 + * `RandomGenerator` interface (`next` / `clone` / `getState`) and the + * pre-existing byte-fill `fillRandomBytes` method that conforms to + * `@endo/random`'s `RandomSource` and `crypto.getRandomValues`-style + * ergonomics. Callers can pick whichever entry point matches the + * downstream consumer. + * + * After `2 ** 32` blocks (256 GiB of keystream) the counter would + * wrap; the generator throws `RangeError` instead. + * + * `makeChaCha12` reads the key bytes once, into a private state + * vector, and does not retain the supplied `Uint8Array` reference. + * Callers do not need to defensively copy the key; passing a frozen + * or shared key array is safe. + * + * @param {Uint8Array} key 32-byte key. + * @returns {ChaCha12Generator} + */ +export const makeChaCha12 = key => { + const baseState = chacha12State(key); + const block = new Uint8Array(BLOCK_SIZE); + // First call refills. Empty mid-block buffer is represented by + // offset === BLOCK_SIZE. + return makeGenerator(baseState, 0, BLOCK_SIZE, block); +}; +harden(makeChaCha12); + +/** + * Reconstructs a `ChaCha12Generator` from a state snapshot returned + * by a previous `getState()` call. The reconstructed generator + * produces exactly the same subsequent keystream as the generator + * whose state was captured, and is fully independent of any other + * generator (including the original). + * + * The state shape is `[base0..base15, counter, offset, + * block0..block15]`, 34 numbers total: 16 u32 base-state words, the + * next-block counter, the byte offset within the current block + * (0..64), and 16 u32 words encoding the 64-byte current block + * (little-endian). When `offset === BLOCK_SIZE` the block words are + * unused (the next read refills); they are included for shape + * uniformity. + * + * Throws `TypeError` for malformed state. + * + * @param {readonly number[]} state + * @returns {ChaCha12Generator} + */ +export const makeChaCha12FromState = state => { + if (!Array.isArray(state) || state.length !== STATE_LENGTH) { + throw TypeError( + `chacha12 state must be a ${STATE_LENGTH}-element number array`, + ); + } + const offset = Number(state[17]); + if (!Number.isInteger(offset) || offset < 0 || offset > BLOCK_SIZE) { + throw TypeError( + `chacha12 state offset must be an integer in [0, ${BLOCK_SIZE}]`, + ); + } + const counter = Number(state[16]); + if (!Number.isInteger(counter) || counter < 0 || counter > 0xffff_ffff) { + throw TypeError('chacha12 state counter must be a u32 integer'); + } + const baseState = new Uint32Array(16); + for (let i = 0; i < 16; i += 1) baseState[i] = state[i] >>> 0; + const block = new Uint8Array(BLOCK_SIZE); + for (let i = 0; i < 16; i += 1) { + const w = state[18 + i] >>> 0; + const off = i * 4; + block[off] = w & 0xff; + block[off + 1] = (w >>> 8) & 0xff; + block[off + 2] = (w >>> 16) & 0xff; + block[off + 3] = (w >>> 24) & 0xff; + } + return makeGenerator(baseState, counter, offset, block); +}; +harden(makeChaCha12FromState); diff --git a/packages/chacha12/test/_oracle-spec.json b/packages/chacha12/test/_oracle-spec.json new file mode 100644 index 0000000000..4f80f74aea --- /dev/null +++ b/packages/chacha12/test/_oracle-spec.json @@ -0,0 +1,135 @@ +{ + "_format": { + "description": "ChaCha12 oracle test vector specification. Each entry describes one (key, nonce, counter, length) tuple to be evaluated against multiple independent ChaCha12 implementations; the consensus keystream is captured in _oracle-vectors.json. The IV layout is IETF (RFC 7539 / 8439): a 12-byte nonce paired with a 32-bit counter at state[12]. Some test vectors mirror the DJB-IV vectors from draft-strombergson-chacha-test-vectors-01: those vectors use a 64-bit DJB IV which we map to the trailing 8 bytes of an IETF 12-byte nonce (leading 4 bytes zero), at counter = 0 the two layouts coincide.", + "fields": { + "id": "stable identifier (string)", + "description": "human description", + "key_hex": "32-byte ChaCha key as 64-char hex", + "nonce_hex": "12-byte IETF nonce as 24-char hex", + "counter": "u32 initial block counter", + "length": "number of keystream bytes to emit", + "rfc_provenance": "optional reference vector this matches (informational)" + } + }, + "vectors": [ + { + "id": "tc1-block0", + "description": "All-zero key, all-zero nonce, counter 0, one block", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 block 0" + }, + { + "id": "tc1-block1", + "description": "All-zero key, all-zero nonce, counter 1, one block", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 block 1" + }, + { + "id": "tc1-128", + "description": "All-zero key/nonce, counter 0, two blocks contiguous", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 128, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 blocks 0+1" + }, + { + "id": "tc4-block0", + "description": "All-ones key, all-ones IV (DJB IV maps to trailing 8 bytes of IETF nonce)", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "00000000ffffffffffffffff", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC4 block 0" + }, + { + "id": "tc4-block1", + "description": "All-ones key, all-ones IV, counter 1", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "00000000ffffffffffffffff", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC4 block 1" + }, + { + "id": "tc8-block0", + "description": "Random key, random IV (TC8)", + "key_hex": "c46ec1b18ce8a878725a37e780dfb7351f68ed2e194c79fbc6aebee1a667975d", + "nonce_hex": "000000001ada31d5cf688221", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC8 block 0" + }, + { + "id": "tc8-block1", + "description": "Random key, random IV (TC8), counter 1", + "key_hex": "c46ec1b18ce8a878725a37e780dfb7351f68ed2e194c79fbc6aebee1a667975d", + "nonce_hex": "000000001ada31d5cf688221", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC8 block 1" + }, + { + "id": "key01-counter0-1024", + "description": "Key = 0x01 repeated 32 times, zero nonce, 16 blocks", + "key_hex": "0101010101010101010101010101010101010101010101010101010101010101", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 1024 + }, + { + "id": "keyff-counter0-128", + "description": "Key = 0xff repeated, zero nonce, 2 blocks", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 128 + }, + { + "id": "rotating-counter0-64", + "description": "Key = 0x00 0x01 0x02 ... 0x1f, zero nonce, one block", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 64 + }, + { + "id": "rotating-counter-mid-64", + "description": "Same key, counter at 2^31 (mid-range block boundary)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 2147483648, + "length": 64 + }, + { + "id": "rotating-counter-near-max-64", + "description": "Same key, counter at 2^32-2 (last block-pair before the u32 wrap)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 4294967294, + "length": 64 + }, + { + "id": "rotating-nonce-counter1-256", + "description": "Rotating key + non-zero IETF nonce, counter 1, 4 blocks", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "010203040506070809000a0b", + "counter": 1, + "length": 256 + }, + { + "id": "rotating-counter0-4097", + "description": "Rotating key, zero nonce, 4097 bytes (cross multiple blocks with non-aligned tail)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 4097 + } + ] +} diff --git a/packages/chacha12/test/_oracle-vectors.json b/packages/chacha12/test/_oracle-vectors.json new file mode 100644 index 0000000000..0548e43527 --- /dev/null +++ b/packages/chacha12/test/_oracle-vectors.json @@ -0,0 +1,293 @@ +{ + "_format": "ChaCha12 cross-implementation oracle vectors. Each entry's `keystream` was produced by every implementation in `sources`; the consensus value was emitted by all reporting implementations byte-for-byte. The fixture is consumed by `oracle-vectors.test.js` to assert that @endo/chacha12 matches the cross-implementation consensus.", + "_implementations_surveyed": [ + { + "name": "rustcrypto", + "description": "RustCrypto chacha20 crate (ChaCha12)" + }, + { + "name": "c2-chacha", + "description": "c2-chacha crate (DJB ChaCha12, IETF-mapped)" + }, + { + "name": "rand_chacha", + "description": "rand_chacha::ChaCha12Rng" + }, + { + "name": "noble", + "description": "@noble/ciphers chacha12" + }, + { + "name": "python-pure", + "description": "Pure-Python ChaCha12 (RFC 7539 transcription)" + }, + { + "name": "c-djb-ref", + "description": "DJB eSTREAM reference C (rounds=12)" + }, + { + "name": "js-rfc", + "description": "Independent BigInt JS ChaCha12 (RFC 7539 transcription)" + } + ], + "vectors": [ + { + "id": "key01-counter0-1024", + "description": "Key = 0x01 repeated 32 times, zero nonce, 16 blocks", + "key_hex": "0101010101010101010101010101010101010101010101010101010101010101", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 1024, + "keystream": "3301e8d7e754db2cf57b0a4ca73f253c7053ad2bc5398777ba039b258e59ad9dff0e2c7652187dadf95a37b6c44327c0d2bab5ba3820f0f8984fbf706fa435493b713e9f2aff1b587314ba32d65b90fdfb58a4b4783b18c099ef2a95397c437561852b146623529e0e5b5c50643672d7c9a2ccf923c5d1f0232a5d9b23e23afbb9b5d59725d3540ce7c27429a3a6f9420cc9da7c283be186c0bd86e80dbb31a81aaa2b345435e07c804eb505ab7bfe1b773977c7fede39f346c1c65d07f2fa476d5f4727a0d7dc43cc60243faadbb5c2ad49af2703a79835734cee1c4d9062c81f946a021a7d79ff75ebfe1f1e2303a6f871a1fbcc9bdfbec9f5e8256d255b391a0941f6e071c94c23b0bdecc5cbdf648a2c4df9e3ef35a6c2d733865466e92a091ffc4e9959faadc705de2b6e8ac1730b6b7e62ac19b50be5060ec25fa09c08d831c379064fdd6e25e030247feb7cc91fb15f4b6c7177ace92db6990c13e4af55f2792958aa069497657c706a990093daf76f99485b139cad931b2f2acb03e5fb8f78e031a19e2e5e2418afaf8e3760b9b3b2a441d63bb2689060160ec9c169dd7189d8f1dd527db5711cbee12256aed18215a748e20b3f959328279d2582e8a19f2b0c11c4cfd1ee5a3cb8bcfb45492f3f5f0ef40a55748ca8e56c40b9fb56e98d0ede23e135dc405aa0807e3d966fa4cb3ec15affdf1a66ababa5d4965a1d6e121b588627e963ddc53dbb8dcb4b3aebe364db14dd60ee687f9dcf73a27bd6e80c3433c054a121fc874e20b1b1fd2fb51648372a35c416f3f85153813c1f1164cb1666102491554187f831f08e58618f1d5e6cf860acbcf850192d0b3a6b6eab8b55c768b74558187aeb9f65e80fa9ada342dd7ced9d2ca531484a2d4cdc66e12c3bcf7666c3f171733ec4b7d7a20b90270cdabcb315f9a46197e89c56cd79b9f2a259eaabd0d4b7de3a12e3864bae7e2c8185cf3a57d93a94f1d0fe4c82291a9d4a44b64986bcde41529c6d0bd1bde75d96b4eba77a06c97d13fe03899668533f3953d002fea69209cfb41ff91ea75a493f629aa139e9188fd21118f86603ddc3bcd1369fe67375d8ba71e614fd5b460ef4368f4f4131f49ad1bc8e0197e992221722e8104b5841b3ba03aa3b25773545c2e15cc1c4b3512c231396169d191fabd082c9babb5b0f2169b02ba888629f426993bf08a767ca12f2fa69185b81a4ea2a6ee17bfa9896f5ccc87fa61a2dd19a5edb5a7a94bbb21b2e2938f41f7ead8ace1ae0f4ef1e3d39be28530b99e4b909f5ec3e3f4c18ced5f34a0c68c0cc09d08be93601c1eb14be263048dcad833867971b675389b48afd2719a8cf3803b131a0a9839b0ae2de8b55dcd7d79a8b56d870d81ceb644020f6e0260ffd9f66501166cc89f0e6064163eb573f5c8be38e49dd0a3251600e474c0177d228d9c2", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "keyff-counter0-128", + "description": "Key = 0xff repeated, zero nonce, 2 blocks", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 128, + "keystream": "8635f9eed93699b5dd0c471dacc22bf1978aaeda48345d2c646ad8bd3cd6a52d58082168f1d2de099a170d5f5cb2de4b6de731471428491592941c2ff9f2f95bc155dedbd1b24ad6b9cdf21bcc8e2864128b9737c3657b43ddeb451d73ba8979184c0b8f07ae43a790c40842457a790f90547d345edf64fdaae303dbf1daf920", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "rotating-counter-mid-64", + "description": "Same key, counter at 2^31 (mid-range block boundary)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 2147483648, + "length": 64, + "keystream": "77b29cc6e5ee769976e81430717297fd1501436b94fa96b6ef6d320e3dc7f85c60102f8485defe4fe89c965ae7630d01583c34cbfcfaad48b376d7794c0a7784", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "rotating-counter-near-max-64", + "description": "Same key, counter at 2^32-2 (last block-pair before the u32 wrap)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 4294967294, + "length": 64, + "keystream": "dd3b525fd2385c76b625428bb6738f6ad00d337a7302b56d100704fc9eef06d35360d1430f8e5a2aee679833224e2e06069c9dabd555f21a634d5dd4ee843e2f", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "rotating-counter0-4097", + "description": "Rotating key, zero nonce, 4097 bytes (cross multiple blocks with non-aligned tail)", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 4097, + "keystream": "f231f9ffd17ac65e4405f325d7e940aa4913601fc2be46bce9c3cac3d91a1a365940b308c2857c9f29d6e2548528d49a612b1b0ae6765d16e585aefb463688796cfa9aa0833b72e0db5c15523dd18346358e0ceb2e1b6448049d30327eee851622c65ea358aab7d50d49d2d9151bebc0d9d4261f48cc6c657f8a2b3ce7e08f88fe769153ca48a6f44c778ee379c8783d035da5d9969d22db0209962b74d41715b335ddd9bc9c6d374448c3be176d97871118fef03017bf1702201e35cccdf04145678a22ab58e907ab41780ba4675d0750688c7c3b06579e180c27629b33827be633b89bd5039035393a9866cbb09f00135c36fa5794f5457938255a2166624ba56ae9567476535db047d1da0440e9ade94e5f467f2df0f3316491d53fce2cc01f48ff77045d0ee5b50cd511940450536fec7b831c322fd0329960b6d1dc076381899a71c45a8b21d3f0a9fadd36c29bac5633a38dd34f413f0c11b422e60996013940a2272bd662f7184825d8c846dfcb566bff6c37336e1cf72548df0a999c9652a94e769d480fbbe917843684e8b8b710031bd9762d222779f117e0294e97830a91900b1526fd1960dccc2249f8e8ea5bd4079f975b4ca5babdab4a885c305eea95a692f7ef2ddeac8886c5a247c5a00d798213155a354d19247f53e632a5349deb7f1f14736c39e3343387430114c1d9006188cdb005aa1684d79057206d1d8e28722967dd66c9e006a1dfabd3b8a4a9b66b2cec6a342adb2856e04c95e6dc3f9b6d838a8221ebbe1450d0115c07270244988471357e4c92acdad15c2587443e14af47955eac7bd46c0e9095818263508adf8107734e40c3fc4a6959cbf8532ff1187cb9152276aec18e52b344f99f4d4f11575fcd25a9f34757d6dbb6f89219cffcbea045272376f1838d3c1b5da88f8c6ffaaf4eaae5d4b9aba9992fb89c53f4f9a48d36432823283b4245d2bae058e88b7373eec3a93a5daef4530fcb7376579aec91e53379af2deef83c8f1a6f5c8bd414bb68de5b33bb409328c618086cc4af3ff6ac3980ce7466209735a3e8b843bbfdbde046cce2112ae3d83a56e9a0ee93c8a08ee6b05ba2fda207493596b9a6022ebf9fd90888124afe0124037adff4a6868533153cfe3667be8d8f1ac19ec9d87953965a61d1f4dfe71910536c24dc226becbbc1251acbf40f5ae006e344a18ee8febf3502982c31da1bdfac4eb35f00fcad7d6362ce5b38574078b5dc90d29dc516d688df192e35031a82d9feb4bfef5714294039ffa607776c89ddfaa956627868f5bef8abd01260ed9c5660867171f2e200f91e3068ed8ba862e505c7cbbd1e77f551352edfb019974c40272b790ef99c04dc48049ad7b57246967725b8955faaf8c438c02719baabeb06c698f754b4a412c82c2f8fd2692b5831e117e5a931ce30ed7a6c8bb6b912b8a431b3fe797a9a77e49016f8560be90f46fedf16a65230b65bdd1bc434ce02cba8c5e88664316ce874c9af436d3f3d3948ed63ff210577ec11c7142fb1e0ab89955c4452e06d03ec5876ef00d45a39286fa621aa6f382f8c0f20b15fc9a7e480e749b5b4aa0ff7ed11a2c23af39ed394f7d318fd1df0131c4358bf3c1db53f349d55c62e76249e17a4154b9ea5f1f3b008c07e5b4f871ce2d327b97b031f2c27dcb77b1c7ca3d188c8c51d1991b3776b6e31ec4a276afe06e6c6668e8ae9d3fb8efc26e5448b03955951de4fd3f5a12d76924fbb73f4fc3753b44748b1bf429a3227e15fd0b60635cf297fb1532f1d35e2ba2f6734169f777d382e818d46782123c81860d9a342b37231df71807eb444e5be0745a7979b81fab4705747e719c87d38a9941b6081d1ecd2594233ea6eb850c08b222124a3c60be6dfaf92b3cb29775398322894385ce1de3d33b2b4b2f9661dcfb5f8e0b7a85aefa0f9c410de95d8e3ac4278a23667ac04bc3118057931cec53b386082988f329ed10dbce64bc55ff8b6337078b55fa9d7448b62c1279e401b09e8f8faf1efdaeb58b4e6e837be6349e7f7bfce252f1fb0232769c7495dfe91cbcdeabe8a83b7817a6aa0366492c4f15ac5215b0489cc1ad279d511be35ae96cf61ee7cdaade83a54860cb8c69765b1357c144ed704947ee13c74e46e9ef19bcf0a6e3fbd6a8ffc3748b6dbcacace7442b43b1f39dde1c94977b84e98c74b5b11611f6fed3b79c74c187c792e9aa0ede5912a501479bf40b7bb5e2ff4b0d15023d295c3b2d23660dcf28a9d7575a6686fb7b9f1262d075054130052d7fc5db41ce2217dad497a69a423d198de2b580146d20c29a16975ff792ca4d3980920141886cafcb53513949f5f51049d2b21ea96a1d3df3db66972369c36ee24195a96d01579a45e7dcd2cebea0eecefd5b90aeabe66ca77578537c192f92e3e4e4434f420f7bd761f0bfd30717bd2f4624f117373f39088afe396f8eadffbacefdf5d77306632376b8e2694d5d4498302a16fa4dc371d06bf7bfa1881e2bca953664f1162eb50191c7ad56be8e943c25ceeebb48dc9afd19731533c58fa0f9239b414847c30b1812a0de601cfc38ff650b989dd72a12f518a9388168418315e65917926b8880480387cff9152a36c335031e67882dc4a509bb374f7651543473e29553813f17b2782c1ab3e7bf7bd5bc95546e211b1ea7e55f016c7d2ff4c07a552f999735f5f4a494776d7595fd8ecce30888ac11a994c8d99b7ce93f039fcbf4cfae391c50ef8de6547b95073d775496972d99397d9d13f95999e3a3f54ee6bf80d45d63d9a2c5a8407196584f99f86a8c3cd5876113ee3a598d71f282f1e69cfe0d6cd00464bf5d3e1eee92daf26a73a236348b460668daaf908b5ebe0c9ef9e7fc1ba447b9bcd6aa55b4bf3dd0ab5221b8a405c1b49678b7cf9341438e79fa098751d9564669e7b245134f540f7c1bf818d929876f80006d299650ec9da00b9550d2467d42fbeb0720495ddf4eca9070e75b9ea8dc4f655dbf3ae51b465e01986d68998678205ceeec8115ce7fa29912480e4dcd8ae6a1d862d274b7fdb718a7559127e9d9b8040854e77d0ea5d9d8d8eb020135517f339d541c46e5c546c5477b33eebef13a028e56a336c4452ab28f5eae4aa73982ca44b5d967b1a04a9a02c50b83fc284131498a8d622c09860d216fe515ef409ef5cf180d1dd9fd08f6dab9da270092bf6d7a465d9074a9d2df97abd43ca062b98fcae7a8374df1c310b3bbea42e4cd7a64c91a9627c48028125d2ece9c98ffb7328cd6d1c34c720af8ab5150e17f9d4b05957662c097651a8919c74d20996404a5b2cf61eaa96013fe4c6e7d05bd7ba9566681bfb92783718a902a8965867e54157c01c5bc813d5f191e59f7e62997790c150e2706777f15766556954b347910fd801a132644d72fbe4a0fcd969060b77431daf8c0ea5c26e9c564338ae259ff91a4755f36d76ca6822aabd204a50a1cfa72f27186aa13f211f049d88c50acf3601923054dc50e74a4e5ad4d82fa0c6cc92b83725ec6cd4a790e1ce84224558a3be69a3bacd22ab763325d7cd68c9351c70ff833a1c6d2120fbef050b86e95b6490d82082e98a1af3c20cd75e0c72f25395cdb72ebbc3333cc4575aa7471c405ebbe0657659adf209b27ebfeb36e3793571152af912f7b490ba930ae2f4994af445fd4efb15f406f0eb38d1d7512d833389ce99c6b529dcd437e421ae0340daf12b5168ecc8ae8d5ebd71e59443e66b12d4c21e4ace65688c9b68aa93f9eb2e2956d47c1f904356766afca75875008f5c94e4d3ee45159357b8f66c34bfd70e53a85d43008084effaecea0c36fe71b1a9c841746abef58b0cc1bcc84666e11c29e84c0dae3e4c385403201b7768ad52e2bb6fc909ed1009de167d73000677c9dcfbacb33fa92c48db6eaddc0eca65c14ed29599562ab8d61a4e7aa4db7985941c96ff8e02a287fbbad158c289d33137c1cd9a1ee8f6e09b29130b36b586c578ee93ad677b2e38d62a44e657db114dd22ed37a586e33a34e81c0565f187438be836e40d14168efd48c79d5cc9e6b0f6b74231e11797048ef622ee79c1faf128ce22964577407468537442a825c4fdf06402873d38fc2766165ae5b4b059f358d84cc2c2b4a2ebb400ccf817aa87a91bee0de1549e51d54a1a2c91599f42b947154e7a366ea7d3f6e3265768cb31a562f9fdd1df14ce4ed4d84c6f774e60d4e69d1a77642d8768ad04e5291c0915af40a0f94afdd5e5470904b32eeda21818b675be4d155eba076b68252da856a3e6144d73644db128c369e2dd4f23e767ba9cc12d6ab46b4aadc520db0bb5cbb474d63cab106921d736c49f22c58faff78e3d2a13d17dd54fcf10d12b37d30de9292d8ec2eafcc53eafdb5f6de419397da5e28e0adeb7418b38e1d86a3e07c174303c7088a57c1058df48643d23bb23f62d9bd1aed8b3ff3b83a0bdd5d819b0e85abf816dd1d33567aa079a636fec909f1d0de3af050462cb1ea5d4f273a5c11f6dcaca2e2d1c20673a461e551fba417b5a5206394f9afef1ee5c483447526ad2db21141cee5db258871b1280013ac3a52d33da0822de914514cbc851e0451042cb27f0dc004c392016767b5e16b9ff493de53fa1e12b4acc9eea5f4ef1aa685e9798e6fe435e0f8ed0a1c1cc3059ba1b8f5d0925c6a26a0c08372e7e356d70a978434e0f3145a4f161e7c5a9f7f7641b77e9f482f70a12a1e144b25645044d12e1358c4bef4602c9fc9e8f114b310f9bf9e8cfbdab547a8e0e6cdac3aff89c8e6147b1f6ea194258fd07ce7d2adcbb5fdfbf754df27c1151752f509ccaf71169c25817a179cbbad6ca7bac2be6cf1f41b01ab9252b5329ef061d4ef34fe64d1b2ed1e1a0a4fe0ab783f60857520ffdb2c782c54d7fdb5a36894a37c6871179004d57675031892ec0d01b851ec35b641bf1539ae65bb4c4e2392f79be098f1231f3cf8416bc4ec1bc39e73ae78a82c6099b3375039097f4eff2cb9fba27344dd87cadb951cc2f1678cc321d51c74d3f5b84c8c2890c4e944a1b9dd940b04ded3aa9f73e509f2dc7743f2b54e7eef09affc3f440f13730d584925cd19bcedcd0c9b2d30f16b057771af3ab597e22421ceb2fc2fd107e25d4d7a775040e9df7fc013802c2cbd7cb23252e0b0ee4367d322f119b6eb85b44fe2f8f04a9c7372d7cae2daf78cbf2cfb2e88edf24215306e6bc823132778e29094fdabfae2d7d087fe74f776a6953187b19b2437d40022602a50e5cf3c4ed6fec6729ff5a2e63ef0a4ae752423ec793f518d697be4c29e4418e5acc20e6de3daf89b6759c17b380e27201dc78c278126c0e545cb14a66801d19aad1899e3e464a9e3dc004448e2bd8c5873af3ca5e598a9039a47edb98680135c6eef08498057468f50c2b1f06cd7599c5c1c4302e5ee591caa97d27f809fee20e32502989781738029549f620501169c0e4822d646a70d17389071c6e32d72293aa1e6ded0ff2f9e6a9c44a52e1213f5c2c676035fb3097d8d7d28c54cebc8169334bfe81a30ef8a5656254fb42132acf4fa0cb78d032419fb72919685165ebfa3518cb2968acc3a3879359e522058d9887c559c6e3f361f2c817b3391ac7704c31fa8cc6f0c0327776951092707c46171fa31627724a5dbd708cdb7b1c657320fc5db5da96351cecbab764ba7bc9d64223e324e2ecc7afc52a0482675cc3b84d18fea1213001c9d461509cf10337016f5027079b7c276a857e4d6981b95421a6a3ef6eeb3fb6ed156f3cf9fa95bedbfd34ba1182ec5b773631daa597f", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "rotating-counter0-64", + "description": "Key = 0x00 0x01 0x02 ... 0x1f, zero nonce, one block", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 64, + "keystream": "f231f9ffd17ac65e4405f325d7e940aa4913601fc2be46bce9c3cac3d91a1a365940b308c2857c9f29d6e2548528d49a612b1b0ae6765d16e585aefb46368879", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "rotating-nonce-counter1-256", + "description": "Rotating key + non-zero IETF nonce, counter 1, 4 blocks", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "010203040506070809000a0b", + "counter": 1, + "length": 256, + "keystream": "653a01a000e30fb0eafb628a2a68c659f41b9f51998da89373a0e64c213a6412446cbac83db66d67ac70296c47b8c5ee0698e9647f72d42a4792d1450d082c0ee54c071d536e82462330c5248a360669c9ef4b026257987f8b278aeb22ab071eb108a147358cf29a3014d14fe046feb43cf528cd2091c963b540f981669385ddc790645a8da24005e37b71c092f20e69ce8a514eeebb16ff7a04a2a224cda0353d75e794697c1566d59cdefa8ef763e25418cd1592d923dbecf0dff9afa75994bf2a5f36fe837acd32337c22ed2733f0803f60e5e86ea211bee24d4834e8680e2aef45934ada643b531da37abbd6be86a884c1b39ea61a9346b4ab3a2b974a1a", + "sources": [ + "c-djb-ref", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc1-128", + "description": "All-zero key/nonce, counter 0, two blocks contiguous", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 128, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 blocks 0+1", + "keystream": "9bf49a6a0755f953811fce125f2683d50429c3bb49e074147e0089a52eae155f0564f879d27ae3c02ce82834acfa8c793a629f2ca0de6919610be82f411326be0bd58841203e74fe86fc71338ce0173dc628ebb719bdcbcc151585214cc089b442258dcda14cf111c602b8971b8cc843e91e46ca905151c02744a6b017e69316", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc1-block0", + "description": "All-zero key, all-zero nonce, counter 0, one block", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 block 0", + "keystream": "9bf49a6a0755f953811fce125f2683d50429c3bb49e074147e0089a52eae155f0564f879d27ae3c02ce82834acfa8c793a629f2ca0de6919610be82f411326be", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc1-block1", + "description": "All-zero key, all-zero nonce, counter 1, one block", + "key_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "nonce_hex": "000000000000000000000000", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC1 block 1", + "keystream": "0bd58841203e74fe86fc71338ce0173dc628ebb719bdcbcc151585214cc089b442258dcda14cf111c602b8971b8cc843e91e46ca905151c02744a6b017e69316", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc4-block0", + "description": "All-ones key, all-ones IV (DJB IV maps to trailing 8 bytes of IETF nonce)", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "00000000ffffffffffffffff", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC4 block 0", + "keystream": "04bf88dae8e47a228fa47b7e6379434ba664a7d28f4dab84e5f8b464add20c3acaa69c5ab221a23a57eb5f345c96f4d1322d0a2ff7a9cd43401cd536639a615a", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc4-block1", + "description": "All-ones key, all-ones IV, counter 1", + "key_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce_hex": "00000000ffffffffffffffff", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC4 block 1", + "keystream": "5c9429b55ca3c1b55354559669a154aca46cd761c41ab8ace385363b95675f068e18db5a673c11291bd4187892a9a3a33514f3712b26c13026103298ed76bc9a", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc8-block0", + "description": "Random key, random IV (TC8)", + "key_hex": "c46ec1b18ce8a878725a37e780dfb7351f68ed2e194c79fbc6aebee1a667975d", + "nonce_hex": "000000001ada31d5cf688221", + "counter": 0, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC8 block 0", + "keystream": "1482072784bc6d06b4e73bdc118bc0103c7976786ca918e06986aa251f7e9cc1b2749a0a16ee83b4242d2e99b08d7c20092b80bc466c87283b61b1b39d0ffbab", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + }, + { + "id": "tc8-block1", + "description": "Random key, random IV (TC8), counter 1", + "key_hex": "c46ec1b18ce8a878725a37e780dfb7351f68ed2e194c79fbc6aebee1a667975d", + "nonce_hex": "000000001ada31d5cf688221", + "counter": 1, + "length": 64, + "rfc_provenance": "draft-strombergson-chacha-test-vectors-01 TC8 block 1", + "keystream": "d94b116bc1ebdb329b9e4f620db695544a8e3d9b68473d0c975a46ad966ed631e42aff530ad5eac7d8047adfa1e5113c91f3e3b883f1d189ac1c8fe07ba5a42b", + "sources": [ + "c-djb-ref", + "c2-chacha", + "js-rfc", + "noble", + "python-pure", + "rand_chacha", + "rustcrypto" + ] + } + ] +} diff --git a/packages/chacha12/test/chacha12.test.js b/packages/chacha12/test/chacha12.test.js new file mode 100644 index 0000000000..d88ce348e3 --- /dev/null +++ b/packages/chacha12/test/chacha12.test.js @@ -0,0 +1,394 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +import test from '@endo/ses-ava/test.js'; + +import { + BLOCK_SIZE, + chacha12Block, + chacha12State, + makeChaCha12, + makeChaCha12FromState, +} from '../src/chacha12.js'; + +// Inline hex helpers: `@endo/hex` already depends on `@endo/chacha12` +// in this workspace, so chacha12 cannot devDep on hex without a +// cycle. + +/** @param {string} hex */ +const fromHex = hex => { + const clean = hex.replace(/\s+/g, ''); + if (clean.length % 2 !== 0) throw Error('odd hex'); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i += 1) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +}; + +/** @param {Uint8Array} bytes */ +const encodeHex = bytes => { + let s = ''; + for (let i = 0; i < bytes.length; i += 1) { + const b = bytes[i]; + s += b < 16 ? `0${b.toString(16)}` : b.toString(16); + } + return s; +}; + +// `draft-strombergson-chacha-test-vectors-01` (an Internet-Draft +// covering ChaCha8/12/20 with 128-bit and 256-bit keys) provides +// reference test vectors for the bare ChaCha block function. Those +// vectors use the original Bernstein "DJB" IV layout: state[12..13] +// is a 64-bit counter and state[14..15] is a 64-bit nonce. This +// implementation uses the IETF (RFC 7539 / 8439) layout: state[12] +// is a 32-bit counter and state[13..15] is a 96-bit nonce. +// +// At counter = 0 the two layouts coincide for a vector whose DJB +// IV is treated as the trailing 8 bytes of an IETF 12-byte nonce +// with the leading 4 bytes set to zero, which is what we do below. +// The published keystream bytes match block-for-block. + +// TC1: all-zero 256-bit key, all-zero IV. +test('Strombergson ChaCha12 TC1 (all zero key, all zero IV)', t => { + const key = new Uint8Array(32); + const nonce = new Uint8Array(12); + const expected0 = fromHex( + '9bf49a6a 0755f953 811fce12 5f2683d5' + + ' 0429c3bb 49e07414 7e0089a5 2eae155f' + + ' 0564f879 d27ae3c0 2ce82834 acfa8c79' + + ' 3a629f2c a0de6919 610be82f 411326be', + ); + const expected1 = fromHex( + '0bd58841 203e74fe 86fc7133 8ce0173d' + + ' c628ebb7 19bdcbcc 15158521 4cc089b4' + + ' 42258dcd a14cf111 c602b897 1b8cc843' + + ' e91e46ca 905151c0 2744a6b0 17e69316', + ); + const out = new Uint8Array(64); + + chacha12Block(chacha12State(key, nonce, 0), out); + t.is(encodeHex(out), encodeHex(expected0), 'block 0'); + + chacha12Block(chacha12State(key, nonce, 1), out); + t.is(encodeHex(out), encodeHex(expected1), 'block 1'); +}); + +// TC4: all-ones 256-bit key, all-ones 64-bit IV. +test('Strombergson ChaCha12 TC4 (all-ones key, all-ones IV)', t => { + const key = new Uint8Array(32).fill(0xff); + const nonce = new Uint8Array(12); + // Map DJB 8-byte IV to the trailing 8 bytes of the IETF nonce. + for (let i = 4; i < 12; i += 1) nonce[i] = 0xff; + const expected0 = fromHex( + '04bf88da e8e47a22 8fa47b7e 6379434b' + + ' a664a7d2 8f4dab84 e5f8b464 add20c3a' + + ' caa69c5a b221a23a 57eb5f34 5c96f4d1' + + ' 322d0a2f f7a9cd43 401cd536 639a615a', + ); + const expected1 = fromHex( + '5c9429b5 5ca3c1b5 53545596 69a154ac' + + ' a46cd761 c41ab8ac e385363b 95675f06' + + ' 8e18db5a 673c1129 1bd41878 92a9a3a3' + + ' 3514f371 2b26c130 26103298 ed76bc9a', + ); + const out = new Uint8Array(64); + + chacha12Block(chacha12State(key, nonce, 0), out); + t.is(encodeHex(out), encodeHex(expected0), 'block 0'); + + chacha12Block(chacha12State(key, nonce, 1), out); + t.is(encodeHex(out), encodeHex(expected1), 'block 1'); +}); + +// TC8: random 256-bit key, random 64-bit IV. +test('Strombergson ChaCha12 TC8 (random key, random IV)', t => { + const key = fromHex( + 'c46ec1b1 8ce8a878 725a37e7 80dfb735' + + ' 1f68ed2e 194c79fb c6aebee1 a667975d', + ); + const ivDjb = fromHex('1ada31d5 cf688221'); + const nonce = new Uint8Array(12); + nonce.set(ivDjb, 4); + const expected0 = fromHex( + '14820727 84bc6d06 b4e73bdc 118bc010' + + ' 3c797678 6ca918e0 6986aa25 1f7e9cc1' + + ' b2749a0a 16ee83b4 242d2e99 b08d7c20' + + ' 092b80bc 466c8728 3b61b1b3 9d0ffbab', + ); + const expected1 = fromHex( + 'd94b116b c1ebdb32 9b9e4f62 0db69554' + + ' 4a8e3d9b 68473d0c 975a46ad 966ed631' + + ' e42aff53 0ad5eac7 d8047adf a1e5113c' + + ' 91f3e3b8 83f1d189 ac1c8fe0 7ba5a42b', + ); + const out = new Uint8Array(64); + + chacha12Block(chacha12State(key, nonce, 0), out); + t.is(encodeHex(out), encodeHex(expected0), 'block 0'); + + chacha12Block(chacha12State(key, nonce, 1), out); + t.is(encodeHex(out), encodeHex(expected1), 'block 1'); +}); + +test('makeChaCha12 rejects bad keys', t => { + t.throws(() => makeChaCha12(/** @type {any} */ (null)), { + instanceOf: TypeError, + }); + t.throws(() => makeChaCha12(new Uint8Array(31)), { + instanceOf: TypeError, + }); + t.throws(() => makeChaCha12(new Uint8Array(33)), { + instanceOf: TypeError, + }); +}); + +test('makeChaCha12 first 64 bytes match Strombergson TC1 block 0', t => { + const gen = makeChaCha12(new Uint8Array(32)); + const out = new Uint8Array(64); + gen.fillRandomBytes(out); + const expected = fromHex( + '9bf49a6a 0755f953 811fce12 5f2683d5' + + ' 0429c3bb 49e07414 7e0089a5 2eae155f' + + ' 0564f879 d27ae3c0 2ce82834 acfa8c79' + + ' 3a629f2c a0de6919 610be82f 411326be', + ); + t.is(encodeHex(out), encodeHex(expected)); +}); + +test('makeChaCha12 advances across blocks monotonically', t => { + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i; + const gen = makeChaCha12(key); + const a = new Uint8Array(64); + const b = new Uint8Array(64); + gen.fillRandomBytes(a); + gen.fillRandomBytes(b); + t.not(encodeHex(a), encodeHex(b)); +}); + +test('makeChaCha12 fills any length, crossing block boundaries', t => { + // 192 bytes (3 blocks) pulled in one call should equal the same + // number drawn piecewise across irregular chunks from a fresh + // twin. + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i; + const single = new Uint8Array(192); + makeChaCha12(key).fillRandomBytes(single); + const twin = makeChaCha12(key); + const piecewise = new Uint8Array(192); + let off = 0; + for (const n of [7, 57, 64, 32, 32]) { + const chunk = piecewise.subarray(off, off + n); + twin.fillRandomBytes(chunk); + off += n; + } + t.is(off, 192); + t.deepEqual([...single], [...piecewise]); +}); + +test('chacha12Block validates state and out lengths', t => { + t.throws(() => chacha12Block(new Uint32Array(15), new Uint8Array(64)), { + instanceOf: TypeError, + }); + t.throws(() => chacha12Block(new Uint32Array(16), new Uint8Array(63)), { + instanceOf: TypeError, + }); +}); + +test('chacha12State validates key and nonce', t => { + t.throws( + () => + chacha12State(/** @type {any} */ ('not bytes'), new Uint8Array(12), 0), + { instanceOf: TypeError }, + ); + t.throws(() => chacha12State(new Uint8Array(31), new Uint8Array(12), 0), { + instanceOf: TypeError, + }); + t.throws(() => chacha12State(new Uint8Array(32), new Uint8Array(11), 0), { + instanceOf: TypeError, + }); +}); + +test('makeChaCha12 fillRandomBytes matches @endo/random RandomSource shape', t => { + // Compile-time check that the `fillRandomBytes` method on the + // generator matches `@endo/random`'s `RandomSource` shape (a + // function `(out: Uint8Array) => void`). We restate the type + // locally because chacha12 cannot devDepend on @endo/random + // without a cycle (random already devDeps on chacha12). tsc + // rejects this assignment if the shape ever drifts. + /** @type {(out: Uint8Array) => void} */ + const fillRandomBytes = makeChaCha12(new Uint8Array(32)).fillRandomBytes; + const out = new Uint8Array(8); + fillRandomBytes(out); + t.is(out.length, 8); +}); + +// `getState` / `clone` / `makeChaCha12FromState` together implement +// the keystream-introspection surface required by `pure-rand` v8's +// `RandomGenerator` contract (and adjacent fast-check use). The +// tests below validate the round-trip and clone-independence +// properties that contract requires. + +test('next returns a signed int32 in [-0x80000000, 0x7fffffff]', t => { + const gen = makeChaCha12(new Uint8Array(32)); + for (let i = 0; i < 100; i += 1) { + const v = gen.next(); + t.is(typeof v, 'number'); + t.is(v | 0, v, 'next() result is int32'); + t.true(v >= -0x8000_0000); + t.true(v <= 0x7fff_ffff); + } +}); + +test('next reads the same little-endian u32 sequence as fillRandomBytes', t => { + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i + 1; + const a = makeChaCha12(key); + const b = makeChaCha12(key); + const buf = new Uint8Array(4); + for (let i = 0; i < 50; i += 1) { + b.fillRandomBytes(buf); + const expected = + buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24) | 0; + t.is(a.next(), expected, `index ${i}`); + } +}); + +test('next correctly crosses a block boundary', t => { + // Drain to within 2 bytes of a block boundary, then call `next()` + // (which needs 4) and confirm the result matches the + // byte-equivalent draw from a parallel fresh generator. + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = (i * 7) & 0xff; + const a = makeChaCha12(key); + const b = makeChaCha12(key); + // Drain 62 bytes from `a`; the next `next()` call spans bytes + // [62, 63, 64, 65] which crosses into block 1. + const drain = new Uint8Array(62); + a.fillRandomBytes(drain); + // Drain the same 62 bytes from `b`, then read a 4-byte int the + // long way and compare. + b.fillRandomBytes(drain); + const four = new Uint8Array(4); + b.fillRandomBytes(four); + const expected = + four[0] | (four[1] << 8) | (four[2] << 16) | (four[3] << 24) | 0; + t.is(a.next(), expected); +}); + +test('getState / makeChaCha12FromState round-trip reproduces the keystream', t => { + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i; + const original = makeChaCha12(key); + // Advance by an irregular amount: 70 bytes (mid-block 2), then + // capture state. + const skip = new Uint8Array(70); + original.fillRandomBytes(skip); + const snapshot = original.getState(); + // Snapshot is a plain serializable readonly array. + t.true(Array.isArray(snapshot)); + t.is(snapshot.length, 34); + for (const v of snapshot) { + t.is(typeof v, 'number'); + } + // JSON round-trip survives. + const json = JSON.stringify(snapshot); + const restored = makeChaCha12FromState(JSON.parse(json)); + // Subsequent draws agree byte-for-byte. + const a = new Uint8Array(200); + const b = new Uint8Array(200); + original.fillRandomBytes(a); + restored.fillRandomBytes(b); + t.deepEqual([...a], [...b]); +}); + +test('getState round-trip works at every offset across a block boundary', t => { + // For every offset 0..64, snapshot-and-restore must yield the + // same subsequent stream as the unsnapshot original. This guards + // against off-by-one errors at the block boundary in the snapshot + // shape (offset === BLOCK_SIZE is the "empty / next call refills" + // sentinel and must round-trip correctly too). + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = (0xff - i) & 0xff; + for (let pre = 0; pre <= BLOCK_SIZE; pre += 1) { + const a = makeChaCha12(key); + const b = makeChaCha12(key); + if (pre > 0) { + const skip = new Uint8Array(pre); + a.fillRandomBytes(skip); + b.fillRandomBytes(skip); + } + const restored = makeChaCha12FromState(a.getState()); + const x = new Uint8Array(150); + const y = new Uint8Array(150); + b.fillRandomBytes(x); + restored.fillRandomBytes(y); + t.deepEqual([...x], [...y], `pre=${pre}`); + } +}); + +test('clone produces an independent generator', t => { + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i * 3; + const a = makeChaCha12(key); + // Advance original by a non-block-aligned amount. + const skip = new Uint8Array(13); + a.fillRandomBytes(skip); + const b = a.clone(); + // Both generators yield the same bytes from this point. + const x = new Uint8Array(100); + const y = new Uint8Array(100); + a.fillRandomBytes(x); + b.fillRandomBytes(y); + t.deepEqual([...x], [...y]); + // Subsequent draws on `a` do not affect `b`. + const xMore = new Uint8Array(50); + const yMore = new Uint8Array(50); + a.fillRandomBytes(xMore); + // `b` should still be at position +100 from its clone time, same + // as `a` was before the latest draw. + b.fillRandomBytes(yMore); + t.deepEqual([...xMore], [...yMore]); +}); + +test('clone interleaves: alternating next() on parent and clone yields a paired run', t => { + // A typical fast-check shrinking-style use: snapshot a generator, + // explore one branch, then resume the other branch from the + // clone. The two branches must each produce the same prefix that + // a single uninterrupted run would have produced. + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = (i ^ 0x5a) & 0xff; + const a = makeChaCha12(key); + const reference = makeChaCha12(key); + const b = a.clone(); + const refSeq = []; + for (let i = 0; i < 16; i += 1) refSeq.push(reference.next()); + const aSeq = []; + for (let i = 0; i < 16; i += 1) aSeq.push(a.next()); + const bSeq = []; + for (let i = 0; i < 16; i += 1) bSeq.push(b.next()); + t.deepEqual(aSeq, refSeq); + t.deepEqual(bSeq, refSeq); +}); + +test('makeChaCha12FromState rejects malformed states', t => { + t.throws(() => makeChaCha12FromState(/** @type {any} */ (null)), { + instanceOf: TypeError, + }); + t.throws(() => makeChaCha12FromState(/** @type {any} */ ('not an array')), { + instanceOf: TypeError, + }); + t.throws(() => makeChaCha12FromState([]), { instanceOf: TypeError }); + t.throws(() => makeChaCha12FromState(new Array(33).fill(0)), { + instanceOf: TypeError, + }); + // Bad offset (out of range). + const bad = new Array(34).fill(0); + bad[17] = 65; + t.throws(() => makeChaCha12FromState(bad), { instanceOf: TypeError }); + // Bad counter (negative). + bad[17] = 0; + bad[16] = -1; + t.throws(() => makeChaCha12FromState(bad), { instanceOf: TypeError }); +}); diff --git a/packages/chacha12/test/fill-random-bytes.bench.js b/packages/chacha12/test/fill-random-bytes.bench.js new file mode 100644 index 0000000000..cfb5971d4b --- /dev/null +++ b/packages/chacha12/test/fill-random-bytes.bench.js @@ -0,0 +1,287 @@ +/* eslint-disable no-bitwise, @endo/restrict-comparison-operands, no-fallthrough, default-case, no-plusplus, no-continue */ +/* global process */ + +// Benchmark: candidate inner-loop variants for the byte copy in +// `fillRandomBytes` (`packages/chacha12/src/chacha12.js`). +// The current implementation copies keystream bytes one at a time: +// +// for (let k = 0; k < n; k += 1) out[i + k] = block[offset + k]; +// +// Candidates: +// +// `current`: byte-by-byte for-loop (the live code). +// `duff8` : 8-way unrolled Duff's-device with a switch-fallthrough head. +// `set` : `out.set(block.subarray(offset, offset + n), i)`. +// `unroll4`: 4-way unrolled body with a byte-by-byte tail. +// `hybrid32`: byte-by-byte under 32 bytes, `set` otherwise. +// +// Both the inner copy in isolation and the integrated `fillRandomBytes` +// path are measured per variant so the numbers reflect the real call +// site (block refill, offset bookkeeping, and the inner copy together). +// +// Run from `packages/chacha12/`: +// node test/fill-random-bytes.bench.js + +import { BLOCK_SIZE, chacha12Block, chacha12State } from '../src/chacha12.js'; + +const nowNs = () => Number(process.hrtime.bigint()); + +const seedBytes = Uint8Array.from({ length: 32 }, (_, i) => i); + +// Each variant copies `n` bytes from `block[offset..]` into `out[i..]`. +// These are isolated functions so the loop can be microbenchmarked +// outside the full fillRandomBytes path. + +const copyCurrent = (out, i, block, offset, n) => { + for (let k = 0; k < n; k += 1) out[i + k] = block[offset + k]; +}; + +const copyDuff8 = (out, i, block, offset, n) => { + let k = n; + let oi = i; + let bi = offset; + const r = k % 8; + switch (r) { + case 7: + out[oi++] = block[bi++]; + case 6: + out[oi++] = block[bi++]; + case 5: + out[oi++] = block[bi++]; + case 4: + out[oi++] = block[bi++]; + case 3: + out[oi++] = block[bi++]; + case 2: + out[oi++] = block[bi++]; + case 1: + out[oi++] = block[bi++]; + case 0: + } + k -= r; + while (k > 0) { + out[oi + 0] = block[bi + 0]; + out[oi + 1] = block[bi + 1]; + out[oi + 2] = block[bi + 2]; + out[oi + 3] = block[bi + 3]; + out[oi + 4] = block[bi + 4]; + out[oi + 5] = block[bi + 5]; + out[oi + 6] = block[bi + 6]; + out[oi + 7] = block[bi + 7]; + oi += 8; + bi += 8; + k -= 8; + } +}; + +const copySet = (out, i, block, offset, n) => { + out.set(block.subarray(offset, offset + n), i); +}; + +const copyUnroll4 = (out, i, block, offset, n) => { + const limit = n - (n % 4); + let k = 0; + while (k < limit) { + out[i + k + 0] = block[offset + k + 0]; + out[i + k + 1] = block[offset + k + 1]; + out[i + k + 2] = block[offset + k + 2]; + out[i + k + 3] = block[offset + k + 3]; + k += 4; + } + while (k < n) { + out[i + k] = block[offset + k]; + k += 1; + } +}; + +const HYBRID_THRESHOLD = 32; +const copyHybrid = (out, i, block, offset, n) => { + if (n >= HYBRID_THRESHOLD) { + out.set(block.subarray(offset, offset + n), i); + } else { + for (let k = 0; k < n; k += 1) out[i + k] = block[offset + k]; + } +}; + +const VARIANTS = [ + { name: 'current', fn: copyCurrent }, + { name: 'duff8', fn: copyDuff8 }, + { name: 'set', fn: copySet }, + { name: 'unroll4', fn: copyUnroll4 }, + { name: 'hybrid32', fn: copyHybrid }, +]; + +// Sanity check: every variant must produce identical bytes to current. +const sanityCheck = () => { + const block = Uint8Array.from( + { length: BLOCK_SIZE }, + (_, i) => (i * 31 + 7) & 0xff, + ); + + const shapes = []; + for (let n = 0; n <= 64; n += 1) { + for (const offset of [0, 1, 7, 8, 15, 16, 31, 33, 63]) { + if (offset + n > BLOCK_SIZE) continue; + for (const i of [0, 1, 7, 8, 17, 33]) { + shapes.push({ offset, n, i }); + } + } + } + + const baselineOut = new Uint8Array(128); + const candidateOut = new Uint8Array(128); + + for (const { offset, n, i } of shapes) { + baselineOut.fill(0xa5); + copyCurrent(baselineOut, i, block, offset, n); + for (const { name, fn } of VARIANTS) { + candidateOut.fill(0xa5); + fn(candidateOut, i, block, offset, n); + for (let k = 0; k < 128; k += 1) { + if (baselineOut[k] !== candidateOut[k]) { + throw Error( + `variant ${name} disagreed at byte ${k} for shape ` + + `offset=${offset} n=${n} i=${i}: baseline=${baselineOut[k]} ` + + `candidate=${candidateOut[k]}`, + ); + } + } + } + } +}; + +// Inner-copy microbenchmark. +// +// For each (variant, n) we fill a 4 KiB source block buffer with +// deterministic bytes, allocate a destination buffer the size of the +// workload, and do `iters` calls to the variant, rotating offset +// within the block and i within the output buffer to avoid degenerate +// cache patterns. +const benchInner = (variant, n, iters) => { + const block = Uint8Array.from( + { length: 4096 }, + (_, i) => (i * 17 + 3) & 0xff, + ); + const dst = new Uint8Array(Math.max(4096, n * 64)); + const dstSpan = dst.length - n; + const blockSpan = block.length - n; + // Two warm-up calls. + variant.fn(dst, 0, block, 0, n); + variant.fn(dst, 0, block, 0, n); + + const start = nowNs(); + let dstOff = 0; + let blockOff = 0; + for (let k = 0; k < iters; k += 1) { + variant.fn(dst, dstOff, block, blockOff, n); + dstOff = (dstOff + n) % (dstSpan + 1); + blockOff = (blockOff + n) % (blockSpan + 1); + } + const elapsedNs = nowNs() - start; + return { elapsedNs, perCallNs: elapsedNs / iters }; +}; + +// Integrated fillRandomBytes microbenchmark. +// +// Rebuilds a fillRandomBytes loop that mirrors the live `chacha12.js` +// implementation byte-for-byte, except the inner copy is parameterized +// on the variant. Each filler maintains its own (block, offset, +// baseState, counter), refilling via `chacha12Block`. Variant +// comparison here reflects the real integrated cost. +const makeFillerFromVariant = copyFn => { + const baseState = chacha12State(seedBytes); + const block = new Uint8Array(BLOCK_SIZE); + let counter = 0; + let offset = BLOCK_SIZE; + const refill = () => { + baseState[12] = counter >>> 0; + chacha12Block(baseState, block); + counter += 1; + offset = 0; + }; + return out => { + let i = 0; + const end = out.length; + while (i < end) { + if (offset >= BLOCK_SIZE) refill(); + const available = BLOCK_SIZE - offset; + const want = end - i; + const n = available < want ? available : want; + copyFn(out, i, block, offset, n); + offset += n; + i += n; + } + }; +}; + +const benchIntegrated = (variant, n, iters) => { + const filler = makeFillerFromVariant(variant.fn); + const out = new Uint8Array(n); + filler(out); + filler(out); + const start = nowNs(); + for (let k = 0; k < iters; k += 1) filler(out); + const elapsedNs = nowNs() - start; + return { elapsedNs, perCallNs: elapsedNs / iters }; +}; + +const pad = (s, w) => String(s).padStart(w); + +const runBench = () => { + console.log( + `Node ${process.versions.node} on ${process.platform} / ${process.arch}`, + ); + console.log(''); + + console.log('Sanity check ...'); + sanityCheck(); + console.log(' all variants byte-identical to current.'); + console.log(''); + + // Workloads, in (n, iters) pairs. Tiny fills get more iters to + // push past timer granularity; large fills get fewer to keep + // wall-clock reasonable. + const workloads = [ + { name: 'tiny n=1', n: 1, iters: 5_000_000 }, + { name: 'tiny n=4', n: 4, iters: 5_000_000 }, + { name: 'tiny n=16', n: 16, iters: 5_000_000 }, + { name: 'medium n=64 (one block)', n: 64, iters: 2_000_000 }, + { name: 'large n=1024', n: 1024, iters: 200_000 }, + { name: 'large n=4096', n: 4096, iters: 100_000 }, + ]; + + for (const wl of workloads) { + console.log(`Workload: ${wl.name}, ${wl.iters} iters`); + const baseline = benchInner(VARIANTS[0], wl.n, wl.iters); + for (const variant of VARIANTS) { + const result = benchInner(variant, wl.n, wl.iters); + const ratio = result.elapsedNs / baseline.elapsedNs; + console.log( + ` ${variant.name.padEnd(12)}${pad(result.perCallNs.toFixed(2), 8)} ns/call ` + + `total ${pad((result.elapsedNs / 1e6).toFixed(2), 8)} ms ` + + `${pad(ratio.toFixed(2), 5)}x vs current`, + ); + } + console.log(''); + } + + // Integrated full-fill timing per variant. + for (const wl of workloads) { + console.log(`Integrated fillRandomBytes: ${wl.name}`); + const itersInt = Math.min(wl.iters, 1_000_000); + const baseline = benchIntegrated(VARIANTS[0], wl.n, itersInt); + for (const variant of VARIANTS) { + const r = benchIntegrated(variant, wl.n, itersInt); + const ratio = r.elapsedNs / baseline.elapsedNs; + console.log( + ` int.${variant.name.padEnd(12)}${pad(r.perCallNs.toFixed(2), 8)} ns/call ${pad( + ratio.toFixed(2), + 5, + )}x vs current`, + ); + } + console.log(''); + } +}; + +runBench(); diff --git a/packages/chacha12/test/oracle-vectors.test.js b/packages/chacha12/test/oracle-vectors.test.js new file mode 100644 index 0000000000..be2437ff2a --- /dev/null +++ b/packages/chacha12/test/oracle-vectors.test.js @@ -0,0 +1,97 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +// Cross-implementation oracle keystream tests. The fixture +// `_oracle-vectors.json` was produced by surveying multiple +// independent ChaCha12 implementations (Rust RustCrypto, c2-chacha, +// rand_chacha, @noble/ciphers, a pure-Python RFC 7539 transcription, +// the Bernstein eSTREAM C reference with rounds=12, and an +// independent BigInt-based JS RFC 7539 transcription) over the spec +// in `_oracle-spec.json`. Each fixture entry carries the consensus +// keystream and the list of implementations that produced it. This +// test asserts that `@endo/chacha12` reproduces the consensus +// keystream byte-for-byte on every fixture entry; any disagreement is +// a real divergence that needs investigating, not a snapshot to mask. +// +// See `_oracle-spec.json` for the (key, nonce, counter, length) tuple +// list and the rationale behind each entry. + +import test from '@endo/ses-ava/test.js'; +import fs from 'fs'; +import path from 'path'; + +import { BLOCK_SIZE, chacha12Block, chacha12State } from '../src/chacha12.js'; + +// Inline hex helpers, same convention as `chacha12.test.js`. + +/** @param {string} hex */ +const fromHex = hex => { + const clean = hex.replace(/\s+/g, ''); + if (clean.length % 2 !== 0) throw Error('odd hex'); + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i += 1) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +}; + +/** @param {Uint8Array} bytes */ +const encodeHex = bytes => { + let s = ''; + for (let i = 0; i < bytes.length; i += 1) { + const b = bytes[i]; + s += b < 16 ? `0${b.toString(16)}` : b.toString(16); + } + return s; +}; + +// Resolve the fixture path relative to this test file so AVA's cwd +// does not affect the lookup. `import` attributes for JSON would +// be cleaner, but the SES-AVA harness in this workspace does not +// yet support them uniformly. +// eslint-disable-next-line no-underscore-dangle +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const fixture = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '_oracle-vectors.json'), 'utf8'), +); + +/** + * Drives the package's own `chacha12Block` + `chacha12State` to + * produce `length` bytes of keystream starting at `(key, nonce, + * counter)`. Mirrors what `makeChaCha12` does internally but lets + * us start at an arbitrary u32 counter without going through the + * convenience wrapper (which always starts at counter 0). + * + * @param {Uint8Array} key + * @param {Uint8Array} nonce + * @param {number} counter + * @param {number} length + * @returns {Uint8Array} + */ +const ourKeystream = (key, nonce, counter, length) => { + const state = chacha12State(key, nonce, counter); + const out = new Uint8Array(length); + const block = new Uint8Array(BLOCK_SIZE); + let off = 0; + let ctr = counter >>> 0; + while (off < length) { + state[12] = ctr; + chacha12Block(state, block); + const remaining = length - off; + const n = remaining < BLOCK_SIZE ? remaining : BLOCK_SIZE; + for (let i = 0; i < n; i += 1) out[off + i] = block[i]; + off += n; + ctr = (ctr + 1) >>> 0; + } + return out; +}; + +for (const v of fixture.vectors) { + const sources = v.sources.join(', '); + test(`oracle ${v.id} (consensus of ${v.sources.length}: ${sources})`, t => { + const key = fromHex(v.key_hex); + const nonce = fromHex(v.nonce_hex); + const ks = ourKeystream(key, nonce, v.counter, v.length); + t.is(encodeHex(ks), v.keystream); + }); +} diff --git a/packages/chacha12/tsconfig.build.json b/packages/chacha12/tsconfig.build.json new file mode 100644 index 0000000000..9cd34b05a3 --- /dev/null +++ b/packages/chacha12/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "./tsconfig.json", + "../../tsconfig-build-options.json" + ], + "exclude": [ + "test/" + ] +} diff --git a/packages/chacha12/tsconfig.composite.json b/packages/chacha12/tsconfig.composite.json new file mode 100644 index 0000000000..5628aa177c --- /dev/null +++ b/packages/chacha12/tsconfig.composite.json @@ -0,0 +1,12 @@ +// DO NOT EDIT! THIS FILE IS AUTO-GENERATED BY scripts/generate-composite-tsconfigs.mjs +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../harden/tsconfig.composite.json" + } + ] +} diff --git a/packages/chacha12/tsconfig.json b/packages/chacha12/tsconfig.json new file mode 100644 index 0000000000..1f01375650 --- /dev/null +++ b/packages/chacha12/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.eslint-base.json", + "include": [ + "*.js", + "*.ts", + "src", + "test" + ] +} diff --git a/packages/chacha12/typedoc.json b/packages/chacha12/typedoc.json new file mode 100644 index 0000000000..af8565696f --- /dev/null +++ b/packages/chacha12/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["index.js"] +} From fc11fc0f1d96296bcaa43503c33670fa93fe9f37 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:51 -0700 Subject: [PATCH 03/10] feat(chacha12-fast-check-test): adopt test-package shape Introduce @endo/chacha12-fast-check-test: a test-package that cross-checks @endo/chacha12 against fast-check's reference generators. Test-package shape (no public docs; not published). Implementation and tests ship together. --- packages/chacha12-fast-check-test/LICENSE | 201 ++++++++++++++++++ packages/chacha12-fast-check-test/SECURITY.md | 38 ++++ .../chacha12-fast-check-test/package.json | 79 +++++++ .../test/_random-type.js | 62 ++++++ .../test/fast-check.test.js | 181 ++++++++++++++++ .../tsconfig.build.json | 9 + .../tsconfig.composite.json | 15 ++ .../chacha12-fast-check-test/tsconfig.json | 16 ++ 8 files changed, 601 insertions(+) create mode 100644 packages/chacha12-fast-check-test/LICENSE create mode 100644 packages/chacha12-fast-check-test/SECURITY.md create mode 100644 packages/chacha12-fast-check-test/package.json create mode 100644 packages/chacha12-fast-check-test/test/_random-type.js create mode 100644 packages/chacha12-fast-check-test/test/fast-check.test.js create mode 100644 packages/chacha12-fast-check-test/tsconfig.build.json create mode 100644 packages/chacha12-fast-check-test/tsconfig.composite.json create mode 100644 packages/chacha12-fast-check-test/tsconfig.json diff --git a/packages/chacha12-fast-check-test/LICENSE b/packages/chacha12-fast-check-test/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/packages/chacha12-fast-check-test/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/chacha12-fast-check-test/SECURITY.md b/packages/chacha12-fast-check-test/SECURITY.md new file mode 100644 index 0000000000..9dbbb79534 --- /dev/null +++ b/packages/chacha12-fast-check-test/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported Versions + +The SES package and associated Endo packages are still undergoing development and security review, and all +users are encouraged to use the latest version available. Security fixes will +be made for the most recent branch only. + +## Coordinated Vulnerability Disclosure of Security Bugs + +SES stands for fearless cooperation, and strong security requires strong collaboration with security researchers. If you believe that you have found a security sensitive bug that should not be disclosed until a fix has been made available, we encourage you to report it. To report a bug in HardenedJS, you have several options that include: + +* Reporting the issue to the [Agoric HackerOne vulnerability rewards program](https://hackerone.com/agoric). + +* Sending an email to security at (@) agoric.com., encrypted or unencrypted. To encrypt, please use @Warner’s personal GPG key [A476E2E6 11880C98 5B3C3A39 0386E81B 11CAA07A](http://www.lothar.com/warner-gpg.html) . + +* Sending a message on Keybase to `@agoric_security`, or sharing code and other log files via Keybase’s encrypted file system. ((_keybase_private/agoric_security,$YOURNAME). + +* It is important to be able to provide steps that reproduce the issue and demonstrate its impact with a Proof of Concept example in an initial bug report. Before reporting a bug, a reporter may want to have another trusted individual reproduce the issue. + +* A bug reporter can expect acknowledgment of a potential vulnerability reported through [security@agoric.com](mailto:security@agoric.com) within one business day of submitting a report. If an acknowledgement of an issue is not received within this time frame, especially during a weekend or holiday period, please reach out again. Any issues reported to the HackerOne program will be acknowledged within the time frames posted on the program page. + * The bug triage team and Agoric code maintainers are primarily located in the San Francisco Bay Area with business hours in [Pacific Time](https://www.timeanddate.com/worldclock/usa/san-francisco) . + +* For the safety and security of those who depend on the code, bug reporters should avoid publicly sharing the details of a security bug on Twitter, Discord, Telegram, or in public Github issues during the coordination process. + +* Once a vulnerability report has been received and triaged: + * Agoric code maintainers will confirm whether it is valid, and will provide updates to the reporter on validity of the report. + * It may take up to 72 hours for an issue to be validated, especially if reported during holidays or on weekends. + +* When the Agoric team has verified an issue, remediation steps and patch release timeline information will be shared with the reporter. + * Complexity, severity, impact, and likelihood of exploitation are all vital factors that determine the amount of time required to remediate an issue and distribute a software patch. + * If an issue is Critical or High Severity, Agoric code maintainers will release a security advisory to notify impacted parties to prepare for an emergency patch. + * While the current industry standard for vulnerability coordination resolution is 90 days, Agoric code maintainers will strive to release a patch as quickly as possible. + +When a bug patch is included in a software release, the Agoric code maintainers will: + * Confirm the version and date of the software release with the reporter. + * Provide information about the security issue that the software release resolves. + * Credit the bug reporter for discovery by adding thanks in release notes, securing a CVE designation, or adding the researcher’s name to a Hall of Fame. diff --git a/packages/chacha12-fast-check-test/package.json b/packages/chacha12-fast-check-test/package.json new file mode 100644 index 0000000000..f951aee79f --- /dev/null +++ b/packages/chacha12-fast-check-test/package.json @@ -0,0 +1,79 @@ +{ + "name": "@endo/chacha12-fast-check-test", + "version": "0.1.0", + "private": true, + "description": "Integration test that drives `@endo/chacha12` as a `fast-check` `RandomGenerator`.", + "keywords": [ + "chacha12", + "fast-check", + "pure-rand", + "endo", + "ses" + ], + "author": "Endo contributors", + "license": "Apache-2.0", + "homepage": "https://github.com/endojs/endo/tree/master/packages/chacha12-fast-check-test#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/endojs/endo.git", + "directory": "packages/chacha12-fast-check-test" + }, + "bugs": { + "url": "https://github.com/endojs/endo/issues" + }, + "type": "module", + "exports": { + "./package.json": "./package.json" + }, + "scripts": { + "build": "exit 0", + "lint": "yarn lint:types && yarn lint:eslint", + "lint-fix": "yarn lint:eslint --fix && yarn lint:types", + "lint:eslint": "eslint .", + "lint:types": "tsc", + "test": "ses-ava", + "test:c8": "c8 ${C8_OPTIONS:-} ses-ava", + "test:xs": "exit 0" + }, + "dependencies": { + "@endo/chacha12": "workspace:^", + "@endo/harden": "workspace:^", + "fast-check": "^4.0.0" + }, + "devDependencies": { + "@endo/eventual-send": "workspace:^", + "@endo/init": "workspace:^", + "@endo/ses-ava": "workspace:^", + "ava": "catalog:dev", + "c8": "catalog:dev", + "eslint": "catalog:dev", + "prettier": "^3.5.3", + "ses": "workspace:^", + "typescript": "catalog:dev" + }, + "files": [ + "./*.d.ts", + "./*.js", + "./*.map", + "LICENSE*", + "SECURITY*", + "src", + "test" + ], + "publishConfig": { + "access": "public" + }, + "eslintConfig": { + "extends": [ + "plugin:@endo/internal" + ] + }, + "sesAvaConfigs": { + "lockdown": "../../ava-endo-lockdown.config.mjs", + "unsafe": "../../ava-endo-lockdown-unsafe.config.mjs", + "endo": "../../ava-endo-shims-only.config.mjs" + }, + "typeCoverage": { + "atLeast": 95 + } +} diff --git a/packages/chacha12-fast-check-test/test/_random-type.js b/packages/chacha12-fast-check-test/test/_random-type.js new file mode 100644 index 0000000000..435c0cfd26 --- /dev/null +++ b/packages/chacha12-fast-check-test/test/_random-type.js @@ -0,0 +1,62 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +// Integration-test package only. The adapter exported here exists +// for the test in `test/fast-check.test.js`; it is intentionally +// not published as a reusable library surface. + +import harden from '@endo/harden'; + +import { makeChaCha12, makeChaCha12FromState } from '@endo/chacha12'; + +// Local restatement of the public `ChaCha12Generator` shape from +// `@endo/chacha12`. The package does not expose the typedef under +// its `exports` map, so we re-state it here for the integration +// test's type signature only. +/** + * @typedef {object} ChaCha12Generator + * @property {() => number} next + * @property {() => readonly number[]} getState + * @property {() => ChaCha12Generator} clone + * @property {(out: Uint8Array) => void} fillRandomBytes + */ + +/** + * Builds a fast-check `randomType` callback from `@endo/chacha12`. + * The returned callback takes a 32-bit signed seed (the shape + * `fast-check` invokes its `randomType` with) and produces a + * `ChaCha12Generator` whose `next` / `clone` / `getState` surface is + * structurally compatible with `pure-rand@8`'s `RandomGenerator` + * (and therefore with `fast-check@4`'s `randomType` parameter). + * + * The seed is broadcast across the full 32-byte ChaCha12 key by + * little-endian writes into 8 successive u32 slots. This integration + * test package intentionally inlines that broadcast rather than + * shipping a reusable fast-check adapter; downstream consumers that + * need such an adapter can lift this code or recreate it from the + * `pure-rand@8` `RandomGenerator` contract. + * + * Because `@endo/chacha12` now exposes a real keystream snapshot + * (`getState` / `makeChaCha12FromState`) and a real fully-independent + * `clone`, the returned generator satisfies the v8 contract directly + * with no "alias clone" or "empty getState" placeholder. + * + * @returns {(seed: number) => ChaCha12Generator} + */ +export const makeChaCha12RandomType = () => { + /** @param {number} seed */ + const randomType = seed => { + const key = new Uint8Array(32); + const view = new DataView(key.buffer); + for (let i = 0; i < 8; i += 1) { + view.setInt32(i * 4, seed | 0, true); + } + return makeChaCha12(key); + }; + return harden(randomType); +}; +harden(makeChaCha12RandomType); + +// Re-export so consumers (currently just the test) can import +// everything from one place. +export { makeChaCha12, makeChaCha12FromState }; diff --git a/packages/chacha12-fast-check-test/test/fast-check.test.js b/packages/chacha12-fast-check-test/test/fast-check.test.js new file mode 100644 index 0000000000..1222951fc9 --- /dev/null +++ b/packages/chacha12-fast-check-test/test/fast-check.test.js @@ -0,0 +1,181 @@ +// @ts-check + +// Integration test: drive `@endo/chacha12` as a `fast-check@4` +// `RandomGenerator` via the `randomType` parameter, and validate +// that: +// +// 1. `fast-check` accepts the `ChaCha12Generator` shape directly +// through `randomType` (i.e. the v8-compatible `next` / +// `clone` / `getState` surface plugs in without an adapter +// layer). +// 2. Property-based runs are seed-stable: two runs of the same +// `fc.assert` call with the same `seed` and `randomType` cover +// the same shrinking trajectory. +// 3. The keystream `getState()` snapshot can be captured mid-run and +// a `makeChaCha12FromState`-restored generator continues the same +// stream that `fast-check` would have observed. + +import test from '@endo/ses-ava/test.js'; + +import fc from 'fast-check'; + +import { + makeChaCha12, + makeChaCha12FromState, + makeChaCha12RandomType, +} from './_random-type.js'; + +test('fast-check accepts ChaCha12Generator as a randomType source', t => { + const randomType = makeChaCha12RandomType(); + + // Run a property that always succeeds; the assertion here is that + // `fast-check` runs to completion against our generator. If the + // structural compatibility ever regresses, fast-check will throw + // at run start (e.g. "next is not a function"). + fc.assert( + fc.property(fc.integer(), n => Number.isInteger(n)), + { randomType, seed: 42, numRuns: 100 }, + ); + t.pass(); +}); + +test('fast-check randomType run is reproducible with the same seed', t => { + const randomType = makeChaCha12RandomType(); + + const collect = () => { + const seen = []; + fc.assert( + fc.property(fc.integer({ min: 0, max: 1_000_000 }), n => { + seen.push(n); + return true; + }), + { randomType, seed: 12_345, numRuns: 50 }, + ); + return seen; + }; + + const runA = collect(); + const runB = collect(); + t.deepEqual(runA, runB); + // And not trivial: the generator actually walked the space. + t.true(runA.length > 1); +}); + +test('fast-check randomType: different seeds produce different runs', t => { + const randomType = makeChaCha12RandomType(); + const collect = seed => { + const seen = []; + fc.assert( + fc.property(fc.integer({ min: 0, max: 1_000_000 }), n => { + seen.push(n); + return true; + }), + { randomType, seed, numRuns: 50 }, + ); + return seen; + }; + t.notDeepEqual(collect(1), collect(2)); +}); + +test('fast-check shrinking with chacha12 randomType reaches a stable counterexample', t => { + // A property that is false for any n > 100; fast-check should + // shrink toward 101 (or some small value > 100) deterministically + // given the same seed and randomType. + const randomType = makeChaCha12RandomType(); + const findCounterexample = () => { + const distinctResults = new Set(); + const r = fc.check( + fc.property(fc.integer({ min: 0, max: 1000 }), n => { + const pass = n <= 100; + distinctResults.add(pass); + return pass; + }), + { randomType, seed: 7, numRuns: 200 }, + ); + t.deepEqual( + [...distinctResults].sort(), + [false, true], + 'fast-check explored the domain', + ); + return r.failed ? r.counterexample : null; + }; + const a = findCounterexample(); + const b = findCounterexample(); + t.truthy(a, 'fast-check found a counterexample'); + t.deepEqual(a, b, 'shrinking is deterministic across runs'); +}); + +test('keystream getState round-trip matches the byte stream observed mid-run', t => { + // Build a generator, advance it through a `fast-check`-style + // workload (lots of `next()` calls), snapshot, and confirm that + // a generator restored from the snapshot continues the same + // int32 stream. + const randomType = makeChaCha12RandomType(); + const gen = randomType(99); + // Burn through 250 ints (~ 1000 bytes ~ 16 blocks) so we land + // mid-block at a position fast-check might naturally reach. + const prefix = []; + for (let i = 0; i < 250; i += 1) prefix.push(gen.next()); + + const snapshot = gen.getState(); + const restored = makeChaCha12FromState(snapshot); + + // Both `gen` and `restored` should produce the same next 1000 + // ints. + const tail = []; + const tailRestored = []; + for (let i = 0; i < 1000; i += 1) tail.push(gen.next()); + for (let i = 0; i < 1000; i += 1) tailRestored.push(restored.next()); + t.deepEqual(tail, tailRestored); + // And `prefix` was non-trivial. + t.true(new Set(prefix).size > 1); +}); + +test('clone independence under fast-check-style mixed access', t => { + // Simulates what fast-check's shrinking does internally: take an + // independent clone of the generator at a chosen point, drain the + // clone exhaustively, and confirm the original advances exactly as + // it would have without the clone existing. + const randomType = makeChaCha12RandomType(); + const reference = randomType(2026); + + const gen = randomType(2026); + // Drain `gen` past a block boundary into a half-used block. + const prefix = []; + for (let i = 0; i < 19; i += 1) prefix.push(gen.next()); + + // Clone, then exhaustively drain the clone. + const cloned = gen.clone(); + const clonedTail = []; + for (let i = 0; i < 200; i += 1) clonedTail.push(cloned.next()); + + // `gen` continuing from the clone point should produce the same + // 200 ints as the clone did. + const genTail = []; + for (let i = 0; i < 200; i += 1) genTail.push(gen.next()); + t.deepEqual(genTail, clonedTail); + + // And the full sequence (prefix + tail) should match a single + // uninterrupted reference run. + const refSeq = []; + for (let i = 0; i < 19 + 200; i += 1) refSeq.push(reference.next()); + t.deepEqual([...prefix, ...genTail], refSeq); +}); + +test('makeChaCha12 outside the randomType wrapper conforms to the same interface', t => { + // Sanity: the generator returned by `makeChaCha12` directly (no + // seed-broadcast wrapper) is also a valid fast-check randomType + // source. This documents that fast-check users with their own + // 32-byte keying material can plug `makeChaCha12(key)` straight + // in as a one-shot `randomType: () => makeChaCha12(key)` (the + // `seed` argument is ignored when the generator is pre-keyed). + const key = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) key[i] = i + 1; + /** @param {number} _seed */ + const oneShotRandomType = _seed => makeChaCha12(key); + fc.assert( + fc.property(fc.string(), s => typeof s === 'string'), + { randomType: oneShotRandomType, seed: 1, numRuns: 50 }, + ); + t.pass(); +}); diff --git a/packages/chacha12-fast-check-test/tsconfig.build.json b/packages/chacha12-fast-check-test/tsconfig.build.json new file mode 100644 index 0000000000..9cd34b05a3 --- /dev/null +++ b/packages/chacha12-fast-check-test/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "./tsconfig.json", + "../../tsconfig-build-options.json" + ], + "exclude": [ + "test/" + ] +} diff --git a/packages/chacha12-fast-check-test/tsconfig.composite.json b/packages/chacha12-fast-check-test/tsconfig.composite.json new file mode 100644 index 0000000000..bc8599e639 --- /dev/null +++ b/packages/chacha12-fast-check-test/tsconfig.composite.json @@ -0,0 +1,15 @@ +// DO NOT EDIT! THIS FILE IS AUTO-GENERATED BY scripts/generate-composite-tsconfigs.mjs +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true + }, + "references": [ + { + "path": "../chacha12/tsconfig.composite.json" + }, + { + "path": "../harden/tsconfig.composite.json" + } + ] +} diff --git a/packages/chacha12-fast-check-test/tsconfig.json b/packages/chacha12-fast-check-test/tsconfig.json new file mode 100644 index 0000000000..f1017b1c7d --- /dev/null +++ b/packages/chacha12-fast-check-test/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.eslint-base.json", + "compilerOptions": { + // skipLibCheck works around a fast-check@4 packaging bug: the shipped + // d.ts uses `readonly value!: T` which TS rejects with TS1255 + // ("definite assignment assertion not permitted in this context"). + // Scoped to this package because fast-check is its only consumer. + "skipLibCheck": true + }, + "include": [ + "*.js", + "*.ts", + "src", + "test" + ] +} From 8e800943ff37be5b619bf794e7e68f0221b2ac2f Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:51 -0700 Subject: [PATCH 04/10] refactor(hex): use @endo/chacha12 keystream + @endo/random/seeds for bench inputs Swap the hex package's in-tree xorshift test helper for the @endo/chacha12 keystream + @endo/random/seeds combination, so the bench inputs come from a maintained, documented seed/cipher pair rather than a private copy. --- packages/hex/package.json | 2 + packages/hex/test/_xorshift.js | 109 ------------------------------ packages/hex/test/decode.bench.js | 21 +++--- packages/hex/test/encode.bench.js | 21 +++--- packages/hex/test/run-benches.sh | 2 +- 5 files changed, 19 insertions(+), 136 deletions(-) delete mode 100644 packages/hex/test/_xorshift.js diff --git a/packages/hex/package.json b/packages/hex/package.json index eb6b213640..322992b6b2 100644 --- a/packages/hex/package.json +++ b/packages/hex/package.json @@ -40,8 +40,10 @@ "@endo/harden": "workspace:^" }, "devDependencies": { + "@endo/chacha12": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/init": "workspace:^", + "@endo/random": "workspace:^", "@endo/ses-ava": "workspace:^", "ava": "catalog:dev", "babel-eslint": "^10.1.0", diff --git a/packages/hex/test/_xorshift.js b/packages/hex/test/_xorshift.js deleted file mode 100644 index 5c4742f3de..0000000000 --- a/packages/hex/test/_xorshift.js +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint no-bitwise:[0] */ - -// This file is a verbatim copy of `packages/ocapn/test/_xorshift.js`. -// If either copy is updated, the other should be kept in sync, and -// ideally the PRNG would be factored out into a shared test helper. -// -// Forked from CommonJS version at -// https://github.com/AndreasMadsen/xorshift/blob/d60ca9ca341957a9824908f733f30ce4592c9af4/xorshift.js - -/** - * Create a pseudorandom number generator, with a seed. - * - * @param {Array} seed "128-bit" integer, composed of 4x32-bit - * integers in big endian order. - */ -export function XorShift(seed) { - if (!Array.isArray(seed) || seed.length !== 4) { - throw TypeError('seed must be an array with 4 numbers'); - } - - // uint64_t s = [seed ...] - this.state0U = seed[0] | 0; - this.state0L = seed[1] | 0; - this.state1U = seed[2] | 0; - this.state1L = seed[3] | 0; -} - -/** - * Returns a 64bit random number as a 2x32bit array - * - * @returns {Array} - */ -XorShift.prototype.randomint = function randomint() { - // uint64_t s1 = s[0] - let s1U = this.state0U; - let s1L = this.state0L; - // uint64_t s0 = s[1] - const s0U = this.state1U; - const s0L = this.state1L; - - // result = s0 + s1 - const sumL = (s0L >>> 0) + (s1L >>> 0); - const resU = (s0U + s1U + ((sumL / 2) >>> 31)) >>> 0; - const resL = sumL >>> 0; - - // s[0] = s0 - this.state0U = s0U; - this.state0L = s0L; - - // - t1 = [0, 0] - let t1U = 0; - let t1L = 0; - // - t2 = [0, 0] - let t2U = 0; - let t2L = 0; - - // s1 ^= s1 << 23; - // :: t1 = s1 << 23 - const a1 = 23; - const m1 = 0xffff_ffff << (32 - a1); - t1U = (s1U << a1) | ((s1L & m1) >>> (32 - a1)); - t1L = s1L << a1; - // :: s1 = s1 ^ t1 - s1U ^= t1U; - s1L ^= t1L; - - // t1 = ( s1 ^ s0 ^ ( s1 >> 17 ) ^ ( s0 >> 26 ) ) - // :: t1 = s1 ^ s0 - t1U = s1U ^ s0U; - t1L = s1L ^ s0L; - // :: t2 = s1 >> 18 - const a2 = 18; - const m2 = 0xffff_ffff >>> (32 - a2); - t2U = s1U >>> a2; - t2L = (s1L >>> a2) | ((s1U & m2) << (32 - a2)); - // :: t1 = t1 ^ t2 - t1U ^= t2U; - t1L ^= t2L; - // :: t2 = s0 >> 5 - const a3 = 5; - const m3 = 0xffff_ffff >>> (32 - a3); - t2U = s0U >>> a3; - t2L = (s0L >>> a3) | ((s0U & m3) << (32 - a3)); - // :: t1 = t1 ^ t2 - t1U ^= t2U; - t1L ^= t2L; - - // s[1] = t1 - this.state1U = t1U; - this.state1L = t1L; - - // return result - return [resU, resL]; -}; - -/** - * Returns a random number normalized [0, 1), just like Math.random() - * - * @returns {number} - */ -XorShift.prototype.random = function random() { - const t2 = this.randomint(); - // Math.pow(2, -32) = 2.3283064365386963e-10 - // Math.pow(2, -52) = 2.220446049250313e-16 - return ( - t2[0] * 2.328_306_436_538_696_3e-10 + - (t2[1] >>> 12) * 2.220_446_049_250_313e-16 - ); -}; diff --git a/packages/hex/test/decode.bench.js b/packages/hex/test/decode.bench.js index f3b44ea097..2447e24bd3 100644 --- a/packages/hex/test/decode.bench.js +++ b/packages/hex/test/decode.bench.js @@ -12,11 +12,9 @@ // xst test/decode.bench.js (XS direct, ESM) // ./test/run-benches.sh (rolls up + eshost) +import { makeChaCha12 } from '@endo/chacha12'; +import { bobsCoffee32 } from '@endo/random/seeds.js'; import { jsDecodeHex as shippedDecode } from '../src/decode.js'; -// `_xorshift.js` is a copy of `packages/ocapn/test/_xorshift.js`; if -// either is updated, the other should be kept in sync, and ideally we -// should factor the PRNG out into a shared test helper. -import { XorShift } from './_xorshift.js'; const hexAlphabet = '0123456789abcdef'; @@ -187,16 +185,13 @@ const pairMapTableDecode = (string, name = '') => { // input, worst-case throw path). const tableDecode = arrayTableDecode; -// Deterministic PRNG, same seed shape as other Endo fuzz tests. -// eslint-disable-next-line unicorn/numeric-separators-style -- mnemonic seed (BOBSCOFF EEFACADE) -const defaultSeed = [0xb0b5c0ff, 0xeefacade, 0xb0b5c0ff, 0xeefacade]; +// Deterministic PRNG: shared default seed across the hex/ocapn fuzz +// suites; see `@endo/random/seeds.js`. const makeBytes = size => { - const bytes = new Uint8Array(size); - const prng = new XorShift(defaultSeed); - for (let i = 0; i < size; i += 1) { - bytes[i] = Math.floor(prng.random() * 256); - } - return bytes; + const { fillRandomBytes } = makeChaCha12(bobsCoffee32); + const out = new Uint8Array(size); + fillRandomBytes(out); + return out; }; const encode = bytes => { diff --git a/packages/hex/test/encode.bench.js b/packages/hex/test/encode.bench.js index 5658db2a6c..7ac5b77ec9 100644 --- a/packages/hex/test/encode.bench.js +++ b/packages/hex/test/encode.bench.js @@ -30,11 +30,9 @@ // On engines without the native TC39 `Uint8Array.prototype.toHex` // intrinsic, the native variant is skipped. +import { makeChaCha12 } from '@endo/chacha12'; +import { bobsCoffee32 } from '@endo/random/seeds.js'; import { jsEncodeHex } from '../src/encode.js'; -// `_xorshift.js` is a copy of `packages/ocapn/test/_xorshift.js`; if -// either is updated, the other should be kept in sync, and ideally we -// should factor the PRNG out into a shared test helper. -import { XorShift } from './_xorshift.js'; // Engine-portable nanosecond timer. V8/Node prefers process.hrtime; // fall back to Date.now() under XS and other engines. @@ -162,16 +160,13 @@ const toHex = /** @type {any} */ (Uint8Array.prototype).toHex; const nativeToHex = typeof toHex === 'function' ? /** @type {() => string} */ (toHex) : undefined; -// Deterministic PRNG, same seed shape as other Endo fuzz tests. -// eslint-disable-next-line unicorn/numeric-separators-style -- mnemonic seed (BOBSCOFF EEFACADE) -const defaultSeed = [0xb0b5c0ff, 0xeefacade, 0xb0b5c0ff, 0xeefacade]; +// Deterministic PRNG: shared default seed across the hex/ocapn fuzz +// suites; see `@endo/random/seeds.js`. const makeBytes = size => { - const bytes = new Uint8Array(size); - const prng = new XorShift(defaultSeed); - for (let i = 0; i < size; i += 1) { - bytes[i] = Math.floor(prng.random() * 256); - } - return bytes; + const { fillRandomBytes } = makeChaCha12(bobsCoffee32); + const out = new Uint8Array(size); + fillRandomBytes(out); + return out; }; const time = (label, size, iters, fn) => { diff --git a/packages/hex/test/run-benches.sh b/packages/hex/test/run-benches.sh index e6cb7a31f8..483581f6e6 100755 --- a/packages/hex/test/run-benches.sh +++ b/packages/hex/test/run-benches.sh @@ -2,7 +2,7 @@ # Multi-engine bench runner for @endo/hex. # # Bundles `test/encode.bench.js` and `test/decode.bench.js` (each as a -# self-contained IIFE that pulls in @endo/xorshift and the local +# self-contained IIFE bundling its keystream source and the local # polyfill via rollup), then runs each bundle on every engine eshost # knows about (typically xs and v8 — see `packages/benchmark/`). # From fd9ecc4ecae7f799557e2c7ec2ecbfd2b60cbcbc Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 05/10] refactor(ocapn): use @endo/chacha12 + @endo/random for fuzz drivers Swap the ocapn package's in-tree xorshift test helper for the @endo/chacha12 + @endo/random pair, factoring the fuzz drivers onto the same shared infrastructure as @endo/hex. --- packages/ocapn/package.json | 2 + packages/ocapn/test/_xorshift.js | 107 ------------------ .../ocapn/test/codecs/passable-fuzz.test.js | 14 +-- packages/ocapn/test/syrup/fuzz.test.js | 14 +-- 4 files changed, 16 insertions(+), 121 deletions(-) delete mode 100644 packages/ocapn/test/_xorshift.js diff --git a/packages/ocapn/package.json b/packages/ocapn/package.json index 97021d5ef5..bb0c345254 100644 --- a/packages/ocapn/package.json +++ b/packages/ocapn/package.json @@ -52,7 +52,9 @@ "ws": "^8.20.0" }, "devDependencies": { + "@endo/chacha12": "workspace:^", "@endo/lockdown": "workspace:^", + "@endo/random": "workspace:^", "@endo/ses-ava": "workspace:^", "@types/ws": "^8", "ava": "catalog:dev", diff --git a/packages/ocapn/test/_xorshift.js b/packages/ocapn/test/_xorshift.js deleted file mode 100644 index 717bd85c4c..0000000000 --- a/packages/ocapn/test/_xorshift.js +++ /dev/null @@ -1,107 +0,0 @@ -// @ts-check - -/* eslint no-bitwise:[0] */ - -// Forked from CommonJS version at -// https://github.com/AndreasMadsen/xorshift/blob/d60ca9ca341957a9824908f733f30ce4592c9af4/xorshift.js - -/** - * Create a pseudorandom number generator, with a seed. - * - * @param {Array} seed "128-bit" integer, composed of 4x32-bit - * integers in big endian order. - */ -export function XorShift(seed) { - if (!Array.isArray(seed) || seed.length !== 4) { - throw TypeError('seed must be an array with 4 numbers'); - } - - // uint64_t s = [seed ...] - this.state0U = seed[0] | 0; - this.state0L = seed[1] | 0; - this.state1U = seed[2] | 0; - this.state1L = seed[3] | 0; -} - -/** - * Returns a 64bit random number as a 2x32bit array - * - * @returns {Array} - */ -XorShift.prototype.randomint = function randomint() { - // uint64_t s1 = s[0] - let s1U = this.state0U; - let s1L = this.state0L; - // uint64_t s0 = s[1] - const s0U = this.state1U; - const s0L = this.state1L; - - // result = s0 + s1 - const sumL = (s0L >>> 0) + (s1L >>> 0); - const resU = (s0U + s1U + ((sumL / 2) >>> 31)) >>> 0; - const resL = sumL >>> 0; - - // s[0] = s0 - this.state0U = s0U; - this.state0L = s0L; - - // - t1 = [0, 0] - let t1U = 0; - let t1L = 0; - // - t2 = [0, 0] - let t2U = 0; - let t2L = 0; - - // s1 ^= s1 << 23; - // :: t1 = s1 << 23 - const a1 = 23; - const m1 = 0xffff_ffff << (32 - a1); - t1U = (s1U << a1) | ((s1L & m1) >>> (32 - a1)); - t1L = s1L << a1; - // :: s1 = s1 ^ t1 - s1U ^= t1U; - s1L ^= t1L; - - // t1 = ( s1 ^ s0 ^ ( s1 >> 17 ) ^ ( s0 >> 26 ) ) - // :: t1 = s1 ^ s0 - t1U = s1U ^ s0U; - t1L = s1L ^ s0L; - // :: t2 = s1 >> 18 - const a2 = 18; - const m2 = 0xffff_ffff >>> (32 - a2); - t2U = s1U >>> a2; - t2L = (s1L >>> a2) | ((s1U & m2) << (32 - a2)); - // :: t1 = t1 ^ t2 - t1U ^= t2U; - t1L ^= t2L; - // :: t2 = s0 >> 5 - const a3 = 5; - const m3 = 0xffff_ffff >>> (32 - a3); - t2U = s0U >>> a3; - t2L = (s0L >>> a3) | ((s0U & m3) << (32 - a3)); - // :: t1 = t1 ^ t2 - t1U ^= t2U; - t1L ^= t2L; - - // s[1] = t1 - this.state1U = t1U; - this.state1L = t1L; - - // return result - return [resU, resL]; -}; - -/** - * Returns a random number normalized [0, 1), just like Math.random() - * - * @returns {number} - */ -XorShift.prototype.random = function random() { - const t2 = this.randomint(); - // Math.pow(2, -32) = 2.3283064365386963e-10 - // Math.pow(2, -52) = 2.220446049250313e-16 - return ( - t2[0] * 2.328_306_436_538_696_3e-10 + - (t2[1] >>> 12) * 2.220_446_049_250_313e-16 - ); -}; diff --git a/packages/ocapn/test/codecs/passable-fuzz.test.js b/packages/ocapn/test/codecs/passable-fuzz.test.js index 6b7bdb262c..1b6762ecdd 100644 --- a/packages/ocapn/test/codecs/passable-fuzz.test.js +++ b/packages/ocapn/test/codecs/passable-fuzz.test.js @@ -3,9 +3,11 @@ import test from '@endo/ses-ava/test.js'; import harden from '@endo/harden'; +import { makeChaCha12 } from '@endo/chacha12'; import { encodeHex } from '@endo/hex'; +import { random as randomFloat } from '@endo/random/random.js'; +import { bobsCoffee32 } from '@endo/random/seeds.js'; import { makeTagged } from '@endo/pass-style'; -import { XorShift } from '../_xorshift.js'; import { makeSyrupWriter } from '../../src/syrup/encode.js'; import { makeSyrupReader } from '../../src/syrup/decode.js'; import { makeSelector } from '../../src/selector.js'; @@ -120,12 +122,10 @@ function fuzzyPassable(budget, random) { } } -// Chris Hibbert really wanted the default i to be Bob's Coffee Façade, -// which is conveniently exactly 64 bits long. -const defaultSeed = [0xb0b5_c0ff, 0xeefa_cade, 0xb0b5_c0ff, 0xeefa_cade]; - -const prng = new XorShift(defaultSeed); -const random = () => prng.random(); +// Default seed shared across the hex/ocapn fuzz suites; see +// `@endo/random/seeds.js`. +const source = makeChaCha12(bobsCoffee32).fillRandomBytes; +const random = () => randomFloat(source); /** * @param {any} passable diff --git a/packages/ocapn/test/syrup/fuzz.test.js b/packages/ocapn/test/syrup/fuzz.test.js index abf7f7c291..97bf7bf71c 100644 --- a/packages/ocapn/test/syrup/fuzz.test.js +++ b/packages/ocapn/test/syrup/fuzz.test.js @@ -1,8 +1,10 @@ // @ts-check import test from '@endo/ses-ava/test.js'; +import { makeChaCha12 } from '@endo/chacha12'; +import { random as randomFloat } from '@endo/random/random.js'; +import { bobsCoffee32 } from '@endo/random/seeds.js'; import { decodeSyrup, encodeSyrup } from '../../src/syrup/js-representation.js'; -import { XorShift } from '../_xorshift.js'; /** * @param {number} budget @@ -94,12 +96,10 @@ function fuzzySyrupable(budget, random) { } } -// Chris Hibbert really wanted the default i to be Bob's Coffee Façade, -// which is conveniently exactly 64 bits long. -const defaultSeed = [0xb0b5_c0ff, 0xeefa_cade, 0xb0b5_c0ff, 0xeefa_cade]; - -const prng = new XorShift(defaultSeed); -const random = () => prng.random(); +// Default seed shared across the hex/ocapn fuzz suites; see +// `@endo/random/seeds.js`. +const source = makeChaCha12(bobsCoffee32).fillRandomBytes; +const random = () => randomFloat(source); test('fuzz', t => { // This TextDecoder is only used for the fuzz test descriptor so we can allow invalid utf-8 From 7421d6ebd4d3b44b2261cc4e9d69e8a634dffb25 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 06/10] fix(ses): tuple-typed args restores Parameters overlap Tighten the args tuple in compartment.js so Parameters overlaps with the documented compartment- constructor surface, restoring the type-level invariant that held before the new @endo/random package's typecheck pass surfaced the gap. --- packages/ses/src/compartment.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ses/src/compartment.js b/packages/ses/src/compartment.js index ad88c8ff19..2447dc750b 100644 --- a/packages/ses/src/compartment.js +++ b/packages/ses/src/compartment.js @@ -336,7 +336,7 @@ export const makeCompartmentConstructor = ( ) => { /** * @this {Compartment} - * @param {...any} args + * @param {CompartmentOptionsArgs|LegacyCompartmentOptionsArgs} args */ function Compartment(...args) { if (enforceNew && new.target === undefined) { From 2a10b6ef94b6deb09e1bf61c3e111b25a46db1d8 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 07/10] docs: document the thunk-module policy in AGENTS.md Capture why a top-level .js thunk module exists (legacy exports-map portability and public-interface filtering) and when it can be removed. Surfaced by the @endo/random package introduction, whose multi-entry shape forced the question of whether each public sampler needed a thunk versus pointing exports at src/ directly. --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c23508a805..bd1cea15fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,3 +130,18 @@ Run `yarn clean` to reset. - Use conventional commits: `feat(pkg):`, `fix(pkg):`, `refactor(pkg):`, `chore:`, `test(pkg):` - Breaking changes: `feat(pkg)!:` or `fix(pkg)!:` - File conversions (`.js` to `.ts`) get their own `refactor:` commit + +## Thunk modules + +A "thunk module" is a top-level `.js` file in a package whose only purpose is to re-export from one or more deeper files (e.g. `./src/foo.js`). Thunk modules exist for two reasons: + +1. **`exports`-map portability.** The `package.json` `"exports"` property is not supported by every Node.js version we still target. A physical file at the path `consumers will import` is the fall-through resolution under the legacy directory-walk algorithm: `import '@endo/foo/bar.js'` resolves to `node_modules/@endo/foo/bar.js` when `exports` is unrecognized. The `"main"` property by contrast is honored by every Node.js version, so a single primary entry point can point directly at `./src/foo.js` without a thunk. + +2. **Public-interface filtering.** When a `src/` file exports both public and internal symbols (e.g. test-only primitives needed for known-answer cross-checks), a top-level thunk module that re-exports only the public subset gives the package a stable public surface. In-package tests can still reach internals via relative imports; external callers cannot. + +When neither reason applies — a package has only one `exports` entry, OR the `src/` file already exports exactly the public surface — the thunk module is superfluous and can be deleted in favor of pointing `package.json` `"main"` (and `"exports"`) at `./src/foo.js` directly. + +When auditing thunk modules: + +- If the thunk re-exports `*` (or every named export) from `./src/foo.js`, consider deleting it and pointing `main`/`exports` at `./src/foo.js` directly. +- If the thunk re-exports a strict subset, document the filtering intent in a comment at the top of the file so future maintainers understand why the indirection is load-bearing. From 3dee3aa7100a09e3b2c2e2e6ca75e34db91da9ea Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 08/10] docs(random,chacha12): changeset for @endo/random + @endo/chacha12 Announce @endo/random (major) and @endo/chacha12 (major) as new packages, and the @endo/hex and @endo/ocapn (patch) test-driver swaps from the in-tree xorshift copies. --- .changeset/endo-chacha12.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/endo-chacha12.md diff --git a/.changeset/endo-chacha12.md b/.changeset/endo-chacha12.md new file mode 100644 index 0000000000..48a4f06643 --- /dev/null +++ b/.changeset/endo-chacha12.md @@ -0,0 +1,30 @@ +--- +'@endo/random': major +'@endo/chacha12': major +'@endo/hex': patch +'@endo/ocapn': patch +--- + +Add `@endo/random`: a source-agnostic library of random sampling functions (`random`, `randomInt`, plus the underlying `randomUint8` / `randomUint16` / `randomUint24` / `randomUint32` / `randomUint53` readers). +Each function accepts a `RandomSource`, which is simply a function `(out: Uint8Array) => void` matching the shape of `crypto.getRandomValues` (minus the return value). +Names follow the TC39 [proposal-random-functions](https://tc39.es/proposal-random-functions/) translation `Random.method` -> `randomMethod`. +Each sampler is its own module so consumers can import only what they use: + +```js +import { random } from '@endo/random/random.js'; +import { randomInt } from '@endo/random/int.js'; +``` + +The package also ships `@endo/random/seeds.js`, exporting the canonical `bobsCoffee32` 32-byte seed shared across Endo deterministic fuzz suites. + +Add `@endo/chacha12`: a pure-JavaScript ChaCha12 keystream. +The factory `makeChaCha12(key)` returns a `ChaCha12Generator` record `{ next, getState, clone, fillRandomBytes }`. +The `fillRandomBytes` method has the shape `(out: Uint8Array) => void`, conforming to `@endo/random`'s `RandomSource` and to `crypto.getRandomValues`-style ergonomics; it can be passed directly to the samplers. +The remaining methods expose the keystream's internal state (snapshot via `getState`, independent copy via `clone`, signed-int32 pull via `next`) so the generator satisfies the [`pure-rand` v8 `RandomGenerator` interface](https://github.com/dubzzz/pure-rand/blob/v8.0.0/src/types/RandomGenerator.ts) structurally and can be used directly as a [`fast-check` v4 `randomType` parameter](https://fast-check.dev/docs/api/interfaces/Parameters/#randomtype). +A companion `makeChaCha12FromState(state)` reconstructs a generator from a snapshot for deterministic resumption. + +The keystream is cross-checked against three published ChaCha12 test vectors from [`draft-strombergson-chacha-test-vectors-01`](https://datatracker.ietf.org/doc/html/draft-strombergson-chacha-test-vectors-01) (TC1, TC4, TC8). + +ChaCha12 is the 12-round variant of Daniel J. Bernstein's ChaCha family. +The block function is identical to ChaCha20 modulo the round count (6 double-rounds vs 10), so the implementation, API, and harden discipline mirror the sibling 20-round implementation. +ChaCha12 trades cryptographic margin for throughput; for deterministic test fixtures, property-based testing, and fuzzing the extra speed is generally the right tradeoff. From f251f08899411fe7c3627d25b2b44045b4756697 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 09/10] chore: register chacha12, chacha12-fast-check-test, random in root tsconfig and typedoc Wire the three new packages into the workspace's composite tsconfig so yarn build resolves them, and exclude chacha12-fast-check-test from typedoc generation (test-package shape: no public docs surface). --- tsconfig.composite.json | 9 +++++++++ typedoc.json | 1 + 2 files changed, 10 insertions(+) diff --git a/tsconfig.composite.json b/tsconfig.composite.json index 4d1f878bca..98957b5815 100644 --- a/tsconfig.composite.json +++ b/tsconfig.composite.json @@ -20,6 +20,12 @@ { "path": "packages/captp/tsconfig.composite.json" }, + { + "path": "packages/chacha12/tsconfig.composite.json" + }, + { + "path": "packages/chacha12-fast-check-test/tsconfig.composite.json" + }, { "path": "packages/check-bundle/tsconfig.composite.json" }, @@ -116,6 +122,9 @@ { "path": "packages/promise-kit/tsconfig.composite.json" }, + { + "path": "packages/random/tsconfig.composite.json" + }, { "path": "packages/ses-ava/tsconfig.composite.json" }, diff --git a/typedoc.json b/typedoc.json index 381479695e..aee00a8b77 100644 --- a/typedoc.json +++ b/typedoc.json @@ -23,6 +23,7 @@ "**/tmp/**", "**/dist/**", "**/node_modules/**", + "packages/chacha12-fast-check-test", "packages/cjs-module-analyzer", "packages/cli", "packages/compartment-mapper", From 5cb2ae89afc9a618154c1ec5c24a307d95942316 Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Tue, 9 Jun 2026 07:36:52 -0700 Subject: [PATCH 10/10] chore: Update yarn.lock --- yarn.lock | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index d4bb426813..9fa27f7516 100644 --- a/yarn.lock +++ b/yarn.lock @@ -534,6 +534,43 @@ __metadata: languageName: unknown linkType: soft +"@endo/chacha12-fast-check-test@workspace:packages/chacha12-fast-check-test": + version: 0.0.0-use.local + resolution: "@endo/chacha12-fast-check-test@workspace:packages/chacha12-fast-check-test" + dependencies: + "@endo/chacha12": "workspace:^" + "@endo/eventual-send": "workspace:^" + "@endo/harden": "workspace:^" + "@endo/init": "workspace:^" + "@endo/ses-ava": "workspace:^" + ava: "catalog:dev" + c8: "catalog:dev" + eslint: "catalog:dev" + fast-check: "npm:^4.0.0" + prettier: "npm:^3.5.3" + ses: "workspace:^" + typescript: "catalog:dev" + languageName: unknown + linkType: soft + +"@endo/chacha12@workspace:^, @endo/chacha12@workspace:packages/chacha12": + version: 0.0.0-use.local + resolution: "@endo/chacha12@workspace:packages/chacha12" + dependencies: + "@endo/eventual-send": "workspace:^" + "@endo/harden": "workspace:^" + "@endo/init": "workspace:^" + "@endo/ses-ava": "workspace:^" + ava: "catalog:dev" + babel-eslint: "npm:^10.1.0" + c8: "catalog:dev" + eslint: "catalog:dev" + prettier: "npm:^3.5.3" + ses: "workspace:^" + typescript: "catalog:dev" + languageName: unknown + linkType: soft + "@endo/check-bundle@workspace:packages/check-bundle": version: 0.0.0-use.local resolution: "@endo/check-bundle@workspace:packages/check-bundle" @@ -869,9 +906,11 @@ __metadata: version: 0.0.0-use.local resolution: "@endo/hex@workspace:packages/hex" dependencies: + "@endo/chacha12": "workspace:^" "@endo/eventual-send": "workspace:^" "@endo/harden": "workspace:^" "@endo/init": "workspace:^" + "@endo/random": "workspace:^" "@endo/ses-ava": "workspace:^" ava: "catalog:dev" babel-eslint: "npm:^10.1.0" @@ -1071,6 +1110,7 @@ __metadata: resolution: "@endo/ocapn@workspace:packages/ocapn" dependencies: "@endo/bytes": "workspace:^" + "@endo/chacha12": "workspace:^" "@endo/eventual-send": "workspace:^" "@endo/harden": "workspace:^" "@endo/hex": "workspace:^" @@ -1080,6 +1120,7 @@ __metadata: "@endo/nat": "workspace:^" "@endo/pass-style": "workspace:^" "@endo/promise-kit": "workspace:^" + "@endo/random": "workspace:^" "@endo/ses-ava": "workspace:^" "@endo/stream": "workspace:^" "@endo/syrup-frame": "workspace:^" @@ -1188,6 +1229,25 @@ __metadata: languageName: unknown linkType: soft +"@endo/random@workspace:^, @endo/random@workspace:packages/random": + version: 0.0.0-use.local + resolution: "@endo/random@workspace:packages/random" + dependencies: + "@endo/chacha12": "workspace:^" + "@endo/eventual-send": "workspace:^" + "@endo/harden": "workspace:^" + "@endo/init": "workspace:^" + "@endo/ses-ava": "workspace:^" + ava: "catalog:dev" + babel-eslint: "npm:^10.1.0" + c8: "catalog:dev" + eslint: "catalog:dev" + prettier: "npm:^3.5.3" + ses: "workspace:^" + typescript: "catalog:dev" + languageName: unknown + linkType: soft + "@endo/ses-ava@workspace:^, @endo/ses-ava@workspace:packages/ses-ava": version: 0.0.0-use.local resolution: "@endo/ses-ava@workspace:packages/ses-ava" @@ -4679,7 +4739,7 @@ __metadata: languageName: node linkType: hard -"fast-check@npm:^3.0.0 || ^4.0.0": +"fast-check@npm:^3.0.0 || ^4.0.0, fast-check@npm:^4.0.0": version: 4.8.0 resolution: "fast-check@npm:4.8.0" dependencies: