Release Random Tools 0.2.0

This commit is contained in:
2026-09-02 12:28:20 +02:00
parent e4aa85a503
commit 81cd2f87f3
30 changed files with 1179 additions and 85 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added exact weighted sampling without replacement and versioned executable seeded recipes.
- Added a bounded, domain-separated WebCrypto commitreveal ceremony and verifier.
## 0.1.1 - 2026-09-01 ## 0.1.1 - 2026-09-01
- Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only. - Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only.
+6 -3
View File
@@ -4,16 +4,19 @@ Generate secure or reproducible random values locally in the browser.
Random Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded. Random Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope ## Current scope
- Unbiased local WebCrypto integers and strings plus normal-distribution samples - Unbiased local WebCrypto integers and strings plus normal-distribution samples
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata - Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
- Dice expressions, sampling without replacement, FisherYates shuffling and passphrases - Dice expressions, sampling without replacement, FisherYates shuffling and passphrases
- Exact weighted sampling without replacement from bounded quoted CSV input
- Versioned executable seeded recipes for integers, strings, equal-weight samples and weighted samples
- WebCrypto commitreveal ceremonies with domain-separated commitments, 256-bit private nonces, canonical participant ordering and fail-closed verification
- UUIDv4, UUIDv7 and random ULID generation - UUIDv4, UUIDv7 and random ULID generation
- Local draws for coin flips, shuffled card deals and unique integer sequences - Local draws for coin flips, shuffled card deals and unique integer sequences
- Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates - Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates
All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results are reproducible but not suitable for secrets. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md). All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results/recipes are reproducible but not suitable for secrets. Recipes can embed list values, so review them before sharing. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. A commitreveal result proves only that the supplied reveals match the supplied commitments; participants must publish every commitment before any reveal through a channel of their choice. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development ## Development
@@ -27,7 +30,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.1.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/rand-tools-0.2.0.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Random Tools 0.1.1 is available at: The corresponding source for Random Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.1 https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Random Tools 0.1.1 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+5 -1
View File
@@ -4,4 +4,8 @@ Random Tools is a static React/Vite application wrapped in the shared Toolbox sh
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial FisherYates mapping for selection without replacement; it does not allocate every date in a large range. `random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial FisherYates mapping for selection without replacement; it does not allocate every date in a large range.
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity uses the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell. `random/weighted.ts` parses bounded two-column quoted CSV and ranks each item by an exponential-race key, yielding an exact weighted sample without replacement. `random/recipes.ts` validates a 2 MiB, schema-versioned executable recipe and runs it only through the deterministic source; every algorithm then applies its normal count/list/alphabet bounds. Recipe output includes the generator identity and state after execution.
`random/ceremony.ts` creates 32-byte WebCrypto reveal nonces. A SHA-256 commitment is domain-separated and binds the normalized ceremony ID, participant name and canonical unpadded Base64url nonce. Finalization validates every commitment, rejects duplicate/missing/changed entries, sorts normalized participant names by a locale-independent code-unit order, and hashes the complete verified reveal set under a separate final-seed domain. It cannot enforce the social publication order.
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity and ceremony hashes use the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. Weighted input is capped at 100,000 items/4,000,000 UTF-16 units; recipes and ceremony documents at 2 MiB; ceremonies at 1,000 participants. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell.
+3 -1
View File
@@ -2,6 +2,8 @@
Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit. Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results and seeds remain in page memory unless copied or downloaded. A custom passphrase recipe contains the normalized list count and a versioned SHA-256 identity, not the list contents; the same normalized input is therefore still required for reproduction. There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results, seeds, recipe contents and ceremony nonces remain in page memory unless copied or downloaded. Executable sample recipes embed their input lists; a custom passphrase recipe instead contains the normalized list count and a versioned SHA-256 identity, not the list contents. The same normalized passphrase input is therefore still required for reproduction.
Commitreveal is a coordination primitive, not an audited drawing service. Keep each nonce private until every commitment is independently published, preserve that publication record, and reject missing participants. The verifier proves correspondence between the document's commitments/reveals and derives an order-independent seed; it cannot prove publication timing, participant identity or freedom from collusion. Use that final seed in the deterministic generator and preserve the exact executable recipe for reproducibility.
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling. Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling.
+22 -23
View File
@@ -1,22 +1,22 @@
{ {
"name": "rand-tools", "name": "rand-tools",
"version": "0.1.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rand-tools", "name": "rand-tools",
"version": "0.1.1", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"engines": { "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"node": ">=22" "license": "GPL-3.0-or-later"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,17 +67,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "rand-tools", "name": "rand-tools",
"version": "0.1.1", "version": "0.2.0",
"description": "Generate secure or reproducible random values locally in the browser.", "description": "Generate secure or reproducible random values locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added exact weighted sampling without replacement and versioned executable seeded recipes.
- Added a bounded, domain-separated WebCrypto commitreveal ceremony and verifier.
## 0.1.1 - 2026-09-01 ## 0.1.1 - 2026-09-01
- Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only. - Removed the external randomness-service client and its network permission; Random Tools is now entirely local-only.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
============================================================================== ==============================================================================
@add-ideas/toolbox-helpers@0.1.0 @add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
============================================================================== ==============================================================================
@add-ideas/toolbox-shell-react@0.2.3 @add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+6 -3
View File
@@ -4,16 +4,19 @@ Generate secure or reproducible random values locally in the browser.
Random Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded. Random Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope ## Current scope
- Unbiased local WebCrypto integers and strings plus normal-distribution samples - Unbiased local WebCrypto integers and strings plus normal-distribution samples
- Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata - Explicitly non-cryptographic, seeded deterministic generation with reproduction metadata
- Dice expressions, sampling without replacement, FisherYates shuffling and passphrases - Dice expressions, sampling without replacement, FisherYates shuffling and passphrases
- Exact weighted sampling without replacement from bounded quoted CSV input
- Versioned executable seeded recipes for integers, strings, equal-weight samples and weighted samples
- WebCrypto commitreveal ceremonies with domain-separated commitments, 256-bit private nonces, canonical participant ordering and fail-closed verification
- UUIDv4, UUIDv7 and random ULID generation - UUIDv4, UUIDv7 and random ULID generation
- Local draws for coin flips, shuffled card deals and unique integer sequences - Local draws for coin flips, shuffled card deals and unique integer sequences
- Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates - Random calendar dates with weekday and uniqueness controls, exact decimal fractions, and equal-area spherical coordinates
All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results are reproducible but not suitable for secrets. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md). All functionality runs locally. Secure generation never falls back to the seeded source, and deterministic results/recipes are reproducible but not suitable for secrets. Recipes can embed list values, so review them before sharing. Recipes for custom passphrases identify the normalized list by count and SHA-256 but do not embed it, so reproduction requires the same custom input. A commitreveal result proves only that the supplied reveals match the supplied commitments; participants must publish every commitment before any reveal through a channel of their choice. The browser CSPRNG is not physical entropy, and no operation is presented as certified for regulated drawings or gambling. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development ## Development
@@ -27,7 +30,7 @@ npm run test:browser
## Release ## Release
`npm run release:artifact` creates a deterministic `release/rand-tools-0.1.1.zip` and checksum sidecar. `npm run release:artifact` creates a deterministic `release/rand-tools-0.2.0.zip` and checksum sidecar.
## Licence ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # Corresponding source
The corresponding source for Random Tools 0.1.1 is available at: The corresponding source for Random Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.1.1 https://git.add-ideas.de/lotobo/rand-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Random Tools 0.1.1 directly depends on these runtime packages:
| Package | Pinned version | Declared licence | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+5 -1
View File
@@ -4,4 +4,8 @@ Random Tools is a static React/Vite application wrapped in the shared Toolbox sh
`random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial FisherYates mapping for selection without replacement; it does not allocate every date in a large range. `random/generators.ts` builds bounded numbers, strings, identifiers, dice, samples, shuffles, passphrases and normal samples on that interface. `random/draws.ts` adds local binary coin flips, standard playing-card deals, shuffled inclusive integer sequences, Gregorian calendar-date sampling, exact decimal-digit fractions and equal-area points on a spherical surface model. Date sampling can filter weekdays and use a partial FisherYates mapping for selection without replacement; it does not allocate every date in a large range.
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity uses the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell. `random/weighted.ts` parses bounded two-column quoted CSV and ranks each item by an exponential-race key, yielding an exact weighted sample without replacement. `random/recipes.ts` validates a 2 MiB, schema-versioned executable recipe and runs it only through the deterministic source; every algorithm then applies its normal count/list/alphabet bounds. Recipe output includes the generator identity and state after execution.
`random/ceremony.ts` creates 32-byte WebCrypto reveal nonces. A SHA-256 commitment is domain-separated and binds the normalized ceremony ID, participant name and canonical unpadded Base64url nonce. Finalization validates every commitment, rejects duplicate/missing/changed entries, sorts normalized participant names by a locale-independent code-unit order, and hashes the complete verified reveal set under a separate final-seed domain. It cannot enforce the social publication order.
Each successful result includes source identity, parameters and deterministic state where applicable. Most operations are synchronous and bounded; custom word-list identity and ceremony hashes use the asynchronous browser Web Crypto digest API without a worker. Custom lists are trimmed, emptied lines are dropped and uniqueness is checked once before both selection and metadata are derived. Weighted input is capped at 100,000 items/4,000,000 UTF-16 units; recipes and ceremony documents at 2 MiB; ceremonies at 1,000 participants. The application contains no third-party network client; its CSP limits connections to its own origin for loading the local application shell.
+3 -1
View File
@@ -2,6 +2,8 @@
Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit. Local Web Crypto is the default. It stays in the browser, has no network or seeded fallback, and is the only mode intended for secrets. Seeded xoshiro128** output is reproducible and explicitly non-cryptographic; anyone with the seed/state/recipe can reproduce or predict it. The entropy estimate for a passphrase is a simple uniform-choice model, not a password-strength audit.
There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results and seeds remain in page memory unless copied or downloaded. A custom passphrase recipe contains the normalized list count and a versioned SHA-256 identity, not the list contents; the same normalized input is therefore still required for reproduction. There is no third-party request path, telemetry, analytics, account, geolocation lookup or persistence. Coordinates are generated as mathematical samples and are not derived from the device location. Results, seeds, recipe contents and ceremony nonces remain in page memory unless copied or downloaded. Executable sample recipes embed their input lists; a custom passphrase recipe instead contains the normalized list count and a versioned SHA-256 identity, not the list contents. The same normalized passphrase input is therefore still required for reproduction.
Commitreveal is a coordination primitive, not an audited drawing service. Keep each nonce private until every commitment is independently published, preserve that publication record, and reject missing participants. The verifier proves correspondence between the document's commitments/reveals and derives an order-independent seed; it cannot prove publication timing, participant identity or freedom from collusion. Use that final seed in the deterministic generator and preserve the exact executable recipe for reproducibility.
Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling. Counts, ranges, alphabets, list sizes and generated byte counts have explicit limits before large allocations. Web Crypto is a browser-provided CSPRNG, not a physical randomness source. The app is not certified for regulated drawings or gambling.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "rand-tools-shell-"; const CACHE_PREFIX = "rand-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.1"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+14 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.rand-tools", "id": "de.add-ideas.rand-tools",
"name": "Random Tools", "name": "Random Tools",
"version": "0.1.1", "version": "0.2.0",
"description": "Generate secure or reproducible random values locally in the browser.", "description": "Generate secure or reproducible random values locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -31,6 +31,19 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "text/plain", "extensions": [".txt"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "text/plain", "extensions": [".txt"] }
]
},
"capabilities": { "required": ["secure-random"], "optional": [] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": false,
+308 -10
View File
@@ -24,6 +24,18 @@ import {
randomDates, randomDates,
} from "../random/draws"; } from "../random/draws";
import { randomSource, type SourceMode } from "../random/source"; import { randomSource, type SourceMode } from "../random/source";
import {
createCeremonyReveal,
finalizeCeremony,
parseCeremonyDocument,
type CeremonyResult,
type CeremonyReveal,
} from "../random/ceremony";
import { parseSeededRecipe, runSeededRecipe } from "../random/recipes";
import {
parseWeightedItems,
weightedSampleWithoutReplacement,
} from "../random/weighted";
import { APP_VERSION } from "../version"; import { APP_VERSION } from "../version";
type Tab = type Tab =
@@ -33,7 +45,9 @@ type Tab =
| "dice" | "dice"
| "lists" | "lists"
| "passphrases" | "passphrases"
| "draws"; | "draws"
| "recipes"
| "ceremony";
type DrawKind = type DrawKind =
"coins" | "cards" | "sequence" | "dates" | "decimals" | "coordinates"; "coins" | "cards" | "sequence" | "dates" | "decimals" | "coordinates";
const WEEKDAYS = [ const WEEKDAYS = [
@@ -175,6 +189,10 @@ export function Workbench() {
const [list, setList] = useState( const [list, setList] = useState(
"amber\nbirch\ncedar\ndelta\nember\nforest\ngranite\nharbor", "amber\nbirch\ncedar\ndelta\nember\nforest\ngranite\nharbor",
); );
const [listMode, setListMode] = useState<"equal" | "weighted">("equal");
const [weightedList, setWeightedList] = useState(
'Amber,1\nBirch,2\n"Cedar, western",4\nDelta,8',
);
const [sampleCount, setSampleCount] = useState(3); const [sampleCount, setSampleCount] = useState(3);
const [shuffle, setShuffle] = useState(false); const [shuffle, setShuffle] = useState(false);
const [wordCount, setWordCount] = useState(6); const [wordCount, setWordCount] = useState(6);
@@ -190,6 +208,34 @@ export function Workbench() {
const [uniqueDates, setUniqueDates] = useState(false); const [uniqueDates, setUniqueDates] = useState(false);
const [decimalPlaces, setDecimalPlaces] = useState(8); const [decimalPlaces, setDecimalPlaces] = useState(8);
const [coordinatePlaces, setCoordinatePlaces] = useState(6); const [coordinatePlaces, setCoordinatePlaces] = useState(6);
const [recipeSource, setRecipeSource] = useState(
JSON.stringify(
{
schemaVersion: 1,
algorithm: "integers-v1",
seed: "reproducible-example",
parameters: { count: 6, minimum: 1, maximumInclusive: 49 },
},
null,
2,
),
);
const [ceremonyId, setCeremonyId] = useState("example-draw-2026-09-01");
const [participant, setParticipant] = useState("Participant A");
const [ceremonyReveal, setCeremonyReveal] = useState<CeremonyReveal>();
const [ceremonyDocument, setCeremonyDocument] = useState(
JSON.stringify(
{
schemaVersion: 1,
ceremonyId: "example-draw-2026-09-01",
participants: [],
},
null,
2,
),
);
const [ceremonyResult, setCeremonyResult] = useState<CeremonyResult>();
const [ceremonyError, setCeremonyError] = useState("");
const activeOperation = useRef(0); const activeOperation = useRef(0);
type LocalResult = { type LocalResult = {
@@ -299,6 +345,30 @@ export function Workbench() {
}); });
const generateList = () => const generateList = () =>
local((source) => { local((source) => {
if (listMode === "weighted") {
if (shuffle)
throw new Error(
"Weighted mode produces a sample; switch to equal weights to shuffle every item.",
);
const items = parseWeightedItems(weightedList);
const selected = weightedSampleWithoutReplacement(
source,
items,
sampleCount,
);
return {
title: "Weighted sample without replacement",
text: selected.map((item) => item.value).join("\n"),
parameters: {
algorithm:
"exponential-race weighted sample without replacement-v1",
inputCount: items.length,
sampleCount,
items,
},
note: "Weights affect relative selection chances at each draw. Every input row can be selected at most once; duplicate labels on distinct rows remain distinct entries.",
};
}
if (list.length > 4_000_000) if (list.length > 4_000_000)
throw new Error("List input exceeds 4,000,000 UTF-16 units."); throw new Error("List input exceeds 4,000,000 UTF-16 units.");
const values = list const values = list
@@ -322,6 +392,79 @@ export function Workbench() {
}, },
}; };
}); });
const executeRecipe = () => {
const operationId = ++activeOperation.current;
try {
const recipe = parseSeededRecipe(recipeSource);
const result = runSeededRecipe(recipe);
if (operationId !== activeOperation.current) return;
setOutput({
title: `Replayed ${recipe.algorithm}`,
text: result.output.join("\n"),
recipe: {
executableRecipe: recipe,
source: result.identity,
sourceClass: "deterministic-non-cryptographic",
stateAfter: result.stateAfter,
},
note: "Executable recipes are deterministic and reproducible, not suitable for secrets. Review embedded lists before sharing a recipe.",
});
setError("");
} catch (reason) {
if (operationId === activeOperation.current)
setError(reason instanceof Error ? reason.message : "Recipe failed.");
}
};
const generateCommitment = async () => {
const operationId = ++activeOperation.current;
setCeremonyError("");
try {
const reveal = await createCeremonyReveal(ceremonyId, participant);
if (operationId === activeOperation.current) setCeremonyReveal(reveal);
} catch (reason) {
if (operationId === activeOperation.current)
setCeremonyError(
reason instanceof Error
? reason.message
: "Commitment generation failed.",
);
}
};
const useGeneratedReveal = () => {
if (!ceremonyReveal) return;
setCeremonyDocument(
JSON.stringify(
{
schemaVersion: 1,
ceremonyId: ceremonyReveal.ceremonyId,
participants: [ceremonyReveal],
},
null,
2,
),
);
};
const verifyCeremony = async () => {
const operationId = ++activeOperation.current;
setCeremonyError("");
try {
const result = await finalizeCeremony(
parseCeremonyDocument(ceremonyDocument),
);
if (operationId === activeOperation.current) setCeremonyResult(result);
} catch (reason) {
if (operationId === activeOperation.current)
setCeremonyError(
reason instanceof Error
? reason.message
: "Ceremony verification failed.",
);
}
};
const generatePassphrase = async () => { const generatePassphrase = async () => {
const operationId = ++activeOperation.current; const operationId = ++activeOperation.current;
try { try {
@@ -474,6 +617,8 @@ export function Workbench() {
["lists", "Lists"], ["lists", "Lists"],
["passphrases", "Passphrases"], ["passphrases", "Passphrases"],
["draws", "Draws"], ["draws", "Draws"],
["recipes", "Recipes"],
["ceremony", "Commitreveal"],
] as const; ] as const;
return ( return (
<main className="workbench"> <main className="workbench">
@@ -495,16 +640,11 @@ export function Workbench() {
seed={seed} seed={seed}
setSeed={setSeed} setSeed={setSeed}
/> />
<nav <nav className="panel workspace-tabs" aria-label="Random workspaces">
className="panel workspace-tabs"
role="tablist"
aria-label="Random workspaces"
>
{tabs.map(([value, label]) => ( {tabs.map(([value, label]) => (
<button <button
type="button" type="button"
role="tab" aria-pressed={tab === value}
aria-selected={tab === value}
onClick={() => setTab(value)} onClick={() => setTab(value)}
key={value} key={value}
> >
@@ -695,6 +835,21 @@ export function Workbench() {
)} )}
{tab === "lists" && ( {tab === "lists" && (
<> <>
<label className="field">
<span>Sampling model</span>
<select
value={listMode}
onChange={(event) => {
const next = event.target.value as typeof listMode;
setListMode(next);
if (next === "weighted") setShuffle(false);
}}
>
<option value="equal">Equal-weight lines</option>
<option value="weighted">Weighted CSV rows</option>
</select>
</label>
{listMode === "equal" ? (
<label className="field"> <label className="field">
<span>One item per line</span> <span>One item per line</span>
<textarea <textarea
@@ -702,7 +857,18 @@ export function Workbench() {
onChange={(event) => setList(event.target.value)} onChange={(event) => setList(event.target.value)}
/> />
</label> </label>
) : (
<label className="field">
<span>CSV rows item,positive weight</span>
<textarea
value={weightedList}
onChange={(event) => setWeightedList(event.target.value)}
spellCheck={false}
/>
</label>
)}
<div className="form-grid"> <div className="form-grid">
{listMode === "equal" && (
<label className="check"> <label className="check">
<input <input
type="checkbox" type="checkbox"
@@ -711,9 +877,10 @@ export function Workbench() {
/>{" "} />{" "}
Shuffle every item Shuffle every item
</label> </label>
)}
{!shuffle && ( {!shuffle && (
<label className="field"> <label className="field">
<span>Unique sample count</span> <span>Sample count (without replacement)</span>
<input <input
type="number" type="number"
min="1" min="1"
@@ -726,7 +893,11 @@ export function Workbench() {
)} )}
</div> </div>
<button className="primary" type="button" onClick={generateList}> <button className="primary" type="button" onClick={generateList}>
{shuffle ? "Shuffle" : "Sample"} {shuffle
? "Shuffle"
: listMode === "weighted"
? "Draw weighted sample"
: "Sample"}
</button> </button>
</> </>
)} )}
@@ -928,6 +1099,133 @@ export function Workbench() {
</button> </button>
</> </>
)} )}
{tab === "recipes" && (
<>
<p className="muted">
Run a versioned, deterministic recipe. Supported algorithms are
integers-v1, string-v1, sample-v1 and weighted-sample-v1.
Recipes never switch to the secure source and may embed input
lists.
</p>
<label className="field">
<span>Executable recipe JSON</span>
<textarea
value={recipeSource}
onChange={(event) => setRecipeSource(event.target.value)}
spellCheck={false}
/>
</label>
<button className="primary" type="button" onClick={executeRecipe}>
Validate and replay recipe
</button>
</>
)}
{tab === "ceremony" && (
<div className="ceremony-stack">
<p className="notice">
Publish every commitment before anyone reveals a nonce. The tool
verifies all reveals and refuses to derive a seed if one is
missing, duplicated, or changed; it cannot prove that your group
followed the publication order.
</p>
<div className="form-grid">
<label className="field">
<span>Ceremony ID</span>
<input
value={ceremonyId}
onChange={(event) => setCeremonyId(event.target.value)}
/>
</label>
<label className="field">
<span>Participant name</span>
<input
value={participant}
onChange={(event) => setParticipant(event.target.value)}
/>
</label>
</div>
<button
className="primary"
type="button"
onClick={() => void generateCommitment()}
>
Generate commitment and private reveal
</button>
{ceremonyReveal && (
<div className="ceremony-output">
<section>
<h3>Publish now</h3>
<pre aria-label="Public commitment">
{JSON.stringify(
{
ceremonyId: ceremonyReveal.ceremonyId,
participant: ceremonyReveal.participant,
commitment: ceremonyReveal.commitment,
},
null,
2,
)}
</pre>
</section>
<section>
<h3>Keep private until reveal</h3>
<pre aria-label="Private reveal">
{JSON.stringify(ceremonyReveal, null, 2)}
</pre>
</section>
<button type="button" onClick={useGeneratedReveal}>
Use this reveal in verifier
</button>
</div>
)}
<label className="field">
<span>
Final ceremony JSON with every commitment and reveal
</span>
<textarea
value={ceremonyDocument}
onChange={(event) => setCeremonyDocument(event.target.value)}
spellCheck={false}
/>
</label>
<button
className="primary"
type="button"
onClick={() => void verifyCeremony()}
>
Verify reveals and derive seed
</button>
{ceremonyResult && (
<section
className={ceremonyResult.valid ? "success" : "error"}
aria-live="polite"
>
<strong>
{ceremonyResult.valid
? `${ceremonyResult.verified} reveals verified`
: "Ceremony rejected"}
</strong>
{ceremonyResult.seed && (
<p className="seed-value">
Final Base64url seed: {ceremonyResult.seed}
</p>
)}
{ceremonyResult.errors.length > 0 && (
<ul>
{ceremonyResult.errors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
</section>
)}
{ceremonyError && (
<p className="error" role="alert">
{ceremonyError}
</p>
)}
</div>
)}
{error && ( {error && (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}
+227
View File
@@ -0,0 +1,227 @@
import {
base64UrlToBytes,
bytesToBase64Url,
secureRandomBytes,
} from "@add-ideas/toolbox-helpers";
const encoder = new TextEncoder();
const MAX_PARTICIPANTS = 1_000;
const MAX_DOCUMENT = 2 * 1024 * 1024;
export interface CeremonyCommitment {
ceremonyId: string;
participant: string;
commitment: string;
}
export interface CeremonyReveal extends CeremonyCommitment {
nonce: string;
}
export interface CeremonyDocument {
schemaVersion: 1;
ceremonyId: string;
participants: CeremonyReveal[];
}
export interface CeremonyResult {
valid: boolean;
seed?: string;
verified: number;
errors: string[];
canonicalParticipants: string[];
}
function text(value: string, name: string): string {
const normalized = value.normalize("NFC").trim();
if (
!normalized ||
normalized.length > 200 ||
[...normalized].some((character) => {
const code = character.codePointAt(0)!;
return code <= 31 || code === 127;
})
)
throw new Error(`${name} must contain 1200 printable characters.`);
return normalized;
}
async function hash(value: string): Promise<string> {
return bytesToBase64Url(
new Uint8Array(
await crypto.subtle.digest("SHA-256", encoder.encode(value)),
),
);
}
function canonicalCommitInput(
ceremonyId: string,
participant: string,
nonce: string,
): string {
return JSON.stringify([
"add-ideas-rand-tools-commit-v1",
ceremonyId,
participant,
nonce,
]);
}
function canonicalDigest(value: string, name: string): string {
const bytes = base64UrlToBytes(value, { maxOutputBytes: 32 });
if (bytes.length !== 32 || bytesToBase64Url(bytes) !== value)
throw new Error(
`${name} must be canonical unpadded Base64url for exactly 32 bytes.`,
);
return value;
}
export async function commitmentForReveal(
ceremonyIdInput: string,
participantInput: string,
nonce: string,
): Promise<CeremonyCommitment> {
const ceremonyId = text(ceremonyIdInput, "Ceremony ID");
const participant = text(participantInput, "Participant");
canonicalDigest(nonce, "Reveal nonce");
return {
ceremonyId,
participant,
commitment: await hash(
canonicalCommitInput(ceremonyId, participant, nonce),
),
};
}
export async function createCeremonyReveal(
ceremonyId: string,
participant: string,
): Promise<CeremonyReveal> {
const nonce = bytesToBase64Url(secureRandomBytes(32, crypto, 32));
return {
...(await commitmentForReveal(ceremonyId, participant, nonce)),
nonce,
};
}
export function parseCeremonyDocument(source: string): CeremonyDocument {
if (source.length > MAX_DOCUMENT)
throw new Error("Ceremony document exceeds the 2 MiB limit.");
let value: unknown;
try {
value = JSON.parse(source);
} catch {
throw new Error("Ceremony document is not valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Ceremony document must be an object.");
const candidate = value as Partial<CeremonyDocument>;
if (candidate.schemaVersion !== 1 || !Array.isArray(candidate.participants))
throw new Error(
"Expected ceremony schemaVersion 1 and a participants array.",
);
if (
candidate.participants.length < 1 ||
candidate.participants.length > MAX_PARTICIPANTS
)
throw new Error(
`Ceremony must contain 1${MAX_PARTICIPANTS} participants.`,
);
const ceremonyId = text(candidate.ceremonyId ?? "", "Ceremony ID");
const participants = candidate.participants.map((item, index) => {
if (!item || typeof item !== "object")
throw new Error(`Participant ${index + 1} is not an object.`);
const entry = item as Partial<CeremonyReveal>;
if (
typeof entry.participant !== "string" ||
typeof entry.commitment !== "string" ||
typeof entry.nonce !== "string"
)
throw new Error(
`Participant ${index + 1} has incomplete commitment/reveal fields.`,
);
const entryCeremonyId =
entry.ceremonyId === undefined
? ceremonyId
: typeof entry.ceremonyId === "string"
? text(entry.ceremonyId, `Participant ${index + 1} ceremony ID`)
: (() => {
throw new Error(
`Participant ${index + 1} ceremony ID must be a string.`,
);
})();
return {
ceremonyId: entryCeremonyId,
participant: text(entry.participant, `Participant ${index + 1}`),
commitment: entry.commitment,
nonce: entry.nonce,
};
});
return { schemaVersion: 1, ceremonyId, participants };
}
export async function finalizeCeremony(
document: CeremonyDocument,
): Promise<CeremonyResult> {
if (
document.participants.length < 1 ||
document.participants.length > MAX_PARTICIPANTS
)
throw new Error(
`Ceremony must contain 1${MAX_PARTICIPANTS} participants.`,
);
const ceremonyId = text(document.ceremonyId, "Ceremony ID");
const errors: string[] = [];
const seen = new Set<string>();
const verified: CeremonyReveal[] = [];
for (const [index, entry] of document.participants.entries()) {
try {
if (entry.ceremonyId !== ceremonyId)
throw new Error("ceremony ID differs from the document");
canonicalDigest(entry.commitment, "Commitment");
const expected = await commitmentForReveal(
ceremonyId,
entry.participant,
entry.nonce,
);
if (seen.has(expected.participant))
throw new Error("participant name is duplicated");
seen.add(expected.participant);
if (expected.commitment !== entry.commitment)
throw new Error("reveal does not match the published commitment");
verified.push({ ...entry, participant: expected.participant });
} catch (error) {
errors.push(
`Participant ${index + 1}: ${error instanceof Error ? error.message : "verification failed"}.`,
);
}
}
const canonical = verified.sort((left, right) =>
left.participant < right.participant
? -1
: left.participant > right.participant
? 1
: 0,
);
if (errors.length > 0)
return {
valid: false,
verified: verified.length,
errors,
canonicalParticipants: canonical.map((entry) => entry.participant),
};
const seed = await hash(
JSON.stringify([
"add-ideas-rand-tools-final-seed-v1",
ceremonyId,
canonical.map((entry) => [entry.participant, entry.nonce]),
]),
);
return {
valid: true,
seed,
verified: canonical.length,
errors: [],
canonicalParticipants: canonical.map((entry) => entry.participant),
};
}
+126
View File
@@ -0,0 +1,126 @@
import { randomIntegers, randomString, sampleValues } from "./generators";
import { randomSource } from "./source";
import {
weightedSampleWithoutReplacement,
type WeightedItem,
} from "./weighted";
const MAX_RECIPE = 2 * 1024 * 1024;
export type SeededRecipe =
| {
schemaVersion: 1;
algorithm: "integers-v1";
seed: string;
parameters: { count: number; minimum: number; maximumInclusive: number };
}
| {
schemaVersion: 1;
algorithm: "string-v1";
seed: string;
parameters: { length: number; alphabet: string };
}
| {
schemaVersion: 1;
algorithm: "sample-v1";
seed: string;
parameters: { count: number; values: string[] };
}
| {
schemaVersion: 1;
algorithm: "weighted-sample-v1";
seed: string;
parameters: { count: number; items: WeightedItem[] };
};
export interface RecipeRun {
output: string[];
identity: string;
stateAfter: readonly number[];
}
export function parseSeededRecipe(source: string): SeededRecipe {
if (source.length > MAX_RECIPE)
throw new Error("Recipe exceeds the 2 MiB limit.");
let value: unknown;
try {
value = JSON.parse(source);
} catch {
throw new Error("Recipe is not valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Recipe must be an object.");
const candidate = value as Partial<SeededRecipe> & { parameters?: unknown };
if (
candidate.schemaVersion !== 1 ||
typeof candidate.seed !== "string" ||
!candidate.seed
)
throw new Error("Recipe requires schemaVersion 1 and a non-empty seed.");
if (candidate.seed.length > 10_000)
throw new Error("Recipe seed exceeds 10,000 UTF-16 units.");
if (
!candidate.parameters ||
typeof candidate.parameters !== "object" ||
Array.isArray(candidate.parameters)
)
throw new Error("Recipe parameters must be an object.");
if (
!["integers-v1", "string-v1", "sample-v1", "weighted-sample-v1"].includes(
String(candidate.algorithm),
)
)
throw new Error("Recipe algorithm is unsupported.");
return candidate as SeededRecipe;
}
export function runSeededRecipe(recipe: SeededRecipe): RecipeRun {
const source = randomSource("deterministic", recipe.seed);
let output: string[];
switch (recipe.algorithm) {
case "integers-v1":
output = randomIntegers(
source,
recipe.parameters.count,
recipe.parameters.minimum,
recipe.parameters.maximumInclusive,
).map(String);
break;
case "string-v1":
output = [
randomString(
source,
recipe.parameters.length,
recipe.parameters.alphabet,
),
];
break;
case "sample-v1": {
const { values, count } = recipe.parameters;
if (
!Array.isArray(values) ||
values.length > 100_000 ||
values.some(
(value) => typeof value !== "string" || value.length > 100_000,
)
)
throw new Error("Recipe sample values are invalid or exceed limits.");
output = sampleValues(source, values, count);
break;
}
case "weighted-sample-v1": {
const { items, count } = recipe.parameters;
if (!Array.isArray(items))
throw new Error("Recipe weighted items must be an array.");
output = weightedSampleWithoutReplacement(source, items, count).map(
(item) => item.value,
);
break;
}
}
return {
output,
identity: source.identity,
stateAfter: source.state?.() ?? [],
};
}
+99
View File
@@ -0,0 +1,99 @@
import { parseCsv } from "@add-ideas/toolbox-helpers";
import type { RandomSource } from "./source";
const MAX_ITEMS = 100_000;
const MAX_INPUT = 4_000_000;
export interface WeightedItem {
value: string;
weight: number;
}
export interface WeightedSelection extends WeightedItem {
inputIndex: number;
}
export function parseWeightedItems(source: string): WeightedItem[] {
if (source.length > MAX_INPUT)
throw new Error("Weighted input exceeds 4,000,000 UTF-16 units.");
const rows = parseCsv(source, {
delimiter: ",",
maxRows: MAX_ITEMS + 1,
maxColumns: 2,
maxFieldChars: 100_000,
});
const items: WeightedItem[] = [];
for (const [index, row] of rows.entries()) {
if (row.length === 1 && row[0]?.trim() === "") continue;
if (row.length !== 2)
throw new Error(`CSV row ${index + 1}: expected item and weight.`);
const value = row[0] ?? "";
const weightText = row[1]?.trim() ?? "";
if (!value.trim())
throw new Error(`CSV row ${index + 1}: item must not be empty.`);
if (value.length > 100_000)
throw new Error(
`CSV row ${index + 1}: item exceeds 100,000 UTF-16 units.`,
);
if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/u.test(weightText))
throw new Error(
`CSV row ${index + 1}: weight must be a positive decimal number.`,
);
const weight = Number(weightText);
if (!Number.isFinite(weight) || weight < 1e-12 || weight > 1e12)
throw new Error(
`CSV row ${index + 1}: weight must be between 1e-12 and 1e12.`,
);
items.push({ value, weight });
}
if (items.length === 0 || items.length > MAX_ITEMS)
throw new Error(
`Supply between 1 and ${MAX_ITEMS.toLocaleString()} weighted items.`,
);
return items;
}
export function weightedSampleWithoutReplacement(
source: RandomSource,
items: readonly WeightedItem[],
count: number,
): WeightedSelection[] {
if (items.length === 0 || items.length > MAX_ITEMS)
throw new Error(
`Supply between 1 and ${MAX_ITEMS.toLocaleString()} weighted items.`,
);
if (!Number.isSafeInteger(count) || count < 1 || count > items.length)
throw new Error(
"Sample count must be between 1 and the number of weighted items.",
);
const ranked = items.map((item, inputIndex) => {
if (
!item ||
typeof item.value !== "string" ||
typeof item.weight !== "number" ||
!item.value.trim() ||
item.value.length > 100_000 ||
!Number.isFinite(item.weight) ||
item.weight < 1e-12 ||
item.weight > 1e12
)
throw new Error(`Weighted item ${inputIndex + 1} is invalid.`);
// Exponential-race sampling: the smallest -ln(U)/weight keys form an
// exact weighted sample without replacement. Avoid ln(0) explicitly.
const uniform = Math.max(Number.MIN_VALUE, source.float());
return {
...item,
inputIndex,
rank: -Math.log(uniform) / item.weight,
};
});
ranked.sort(
(left, right) =>
left.rank - right.rank || left.inputIndex - right.inputIndex,
);
return ranked.slice(0, count).map((item) => ({
value: item.value,
weight: item.weight,
inputIndex: item.inputIndex,
}));
}
+26 -1
View File
@@ -171,7 +171,7 @@ textarea {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
} }
.workspace-tabs button[aria-selected="true"] { .workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent); border-color: var(--toolbox-accent);
background: var(--toolbox-accent); background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast); color: var(--toolbox-accent-contrast);
@@ -254,6 +254,31 @@ textarea {
gap: 0.55rem; gap: 0.55rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.ceremony-stack,
.ceremony-output {
display: grid;
gap: 0.8rem;
}
.ceremony-output {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
background: var(--toolbox-surface-soft);
}
.ceremony-output section {
min-width: 0;
}
.ceremony-output pre {
max-height: 18rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.seed-value {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.primary { .primary {
border-color: var(--toolbox-accent); border-color: var(--toolbox-accent);
background: var(--toolbox-accent); background: var(--toolbox-accent);
+35 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.rand-tools", "id": "de.add-ideas.rand-tools",
"name": "Random Tools", "name": "Random Tools",
"version": "0.1.1", "version": "0.2.0",
"description": "Generate secure or reproducible random values locally in the browser.", "description": "Generate secure or reproducible random values locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -31,6 +31,40 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "text/plain",
"extensions": [".txt"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "text/plain",
"extensions": [".txt"]
}
]
},
"capabilities": {
"required": ["secure-random"],
"optional": []
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": false,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.1"; export const APP_VERSION = "0.2.0";
+45 -3
View File
@@ -57,7 +57,7 @@ test("runs the local draw catalogue with recipe metadata", async ({ page }) => {
await page await page
.getByRole("textbox", { name: "Seed", exact: true }) .getByRole("textbox", { name: "Seed", exact: true })
.fill("draw-catalogue"); .fill("draw-catalogue");
await page.getByRole("tab", { name: "Draws" }).click(); await page.getByRole("button", { name: "Draws" }).click();
await page.getByLabel("Result count").fill("7"); await page.getByLabel("Result count").fill("7");
await page.getByRole("button", { name: "Generate draw" }).click(); await page.getByRole("button", { name: "Generate draw" }).click();
@@ -115,7 +115,7 @@ test("identifies normalized custom passphrase inputs without announcing output",
await page await page
.getByRole("textbox", { name: "Seed", exact: true }) .getByRole("textbox", { name: "Seed", exact: true })
.fill("word-list-identity"); .fill("word-list-identity");
await page.getByRole("tab", { name: "Passphrases" }).click(); await page.getByRole("button", { name: "Passphrases" }).click();
await page await page
.getByLabel(/Optional custom word list/u) .getByLabel(/Optional custom word list/u)
.fill(" alpha \n\nbeta\n gamma "); .fill(" alpha \n\nbeta\n gamma ");
@@ -139,6 +139,48 @@ test("identifies normalized custom passphrase inputs without announcing output",
expect(external).toEqual([]); expect(external).toEqual([]);
}); });
test("runs weighted draws and a local commitreveal ceremony", async ({
page,
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/rand/");
await page.getByLabel("Random source").selectOption("deterministic");
await page
.getByRole("textbox", { name: "Seed", exact: true })
.fill("weighted");
await page.getByRole("button", { name: "Lists" }).click();
await page.getByLabel("Sampling model").selectOption("weighted");
await page
.getByRole("textbox", { name: /CSV rows/u })
.fill('"Alpha, Inc",10\nBeta,2\nGamma,1');
await page.getByLabel(/Sample count/u).fill("2");
await page.getByRole("button", { name: "Draw weighted sample" }).click();
const weighted = (await page.locator(".result > pre").textContent())
?.trim()
.split("\n");
expect(weighted).toHaveLength(2);
expect(
weighted?.every((value) => ["Alpha, Inc", "Beta", "Gamma"].includes(value)),
).toBe(true);
expect(new Set(weighted).size).toBe(2);
await page.getByRole("button", { name: "Commitreveal" }).click();
await page.getByRole("button", { name: /Generate commitment/u }).click();
await expect(page.getByLabel("Public commitment")).toContainText(
/"commitment": "[A-Za-z0-9_-]{43}"/u,
);
await expect(page.getByLabel("Private reveal")).toContainText(
/"nonce": "[A-Za-z0-9_-]{43}"/u,
);
await page
.getByRole("button", { name: "Use this reveal in verifier" })
.click();
await page.getByRole("button", { name: /Verify reveals/u }).click();
await expect(page.getByText("1 reveals verified")).toBeVisible();
await expect(page.getByText(/Final Base64url seed:/u)).toBeVisible();
expect(external).toEqual([]);
});
test("serves the release identity and hardened headers", async ({ test("serves the release identity and hardened headers", async ({
request, request,
}) => { }) => {
@@ -157,7 +199,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/rand/toolbox-app.json"); const manifest = await request.get("/deep/nested/rand/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.rand-tools", id: "de.add-ideas.rand-tools",
version: "0.1.1", version: "0.2.0",
entry: "./", entry: "./",
}); });
}); });
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/rand/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe("Random Tools", () => {
await screen.findByRole("heading", { name: "Random Tools" }), await screen.findByRole("heading", { name: "Random Tools" }),
).toBeVisible(); ).toBeVisible();
expect(await screen.findByText("No network requests")).toBeVisible(); expect(await screen.findByText("No network requests")).toBeVisible();
expect(screen.getByRole("tab", { name: "Draws" })).toBeVisible(); expect(screen.getByRole("button", { name: "Draws" })).toBeVisible();
expect(screen.queryByText(/RANDOM\.ORG/iu)).not.toBeInTheDocument(); expect(screen.queryByText(/RANDOM\.ORG/iu)).not.toBeInTheDocument();
}); });
}); });
+128
View File
@@ -20,6 +20,17 @@ import {
randomDates, randomDates,
} from "../../src/random/draws"; } from "../../src/random/draws";
import { randomSource } from "../../src/random/source"; import { randomSource } from "../../src/random/source";
import {
commitmentForReveal,
createCeremonyReveal,
finalizeCeremony,
parseCeremonyDocument,
} from "../../src/random/ceremony";
import { parseSeededRecipe, runSeededRecipe } from "../../src/random/recipes";
import {
parseWeightedItems,
weightedSampleWithoutReplacement,
} from "../../src/random/weighted";
describe("random generators", () => { describe("random generators", () => {
it("repeats deterministic recipes", () => it("repeats deterministic recipes", () =>
@@ -208,3 +219,120 @@ describe("local draws", () => {
expect(() => randomCoordinates(source, 1, 11)).toThrow(/010/u); expect(() => randomCoordinates(source, 1, 11)).toThrow(/010/u);
}); });
}); });
describe("weighted draws and recipes", () => {
it("parses quoted CSV and samples entries without replacement", () => {
const items = parseWeightedItems('"Alpha, Inc.",1\nBeta,10\nGamma,2');
const first = weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
items,
3,
);
const second = weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
items,
3,
);
expect(first).toEqual(second);
expect(new Set(first.map((item) => item.inputIndex)).size).toBe(3);
expect(items[0]?.value).toBe("Alpha, Inc.");
});
it("rejects invalid weights before drawing", () => {
expect(() => parseWeightedItems("Alpha,0")).toThrow(/between/u);
expect(() =>
weightedSampleWithoutReplacement(
randomSource("deterministic", "weighted"),
[{ value: "Alpha", weight: Number.POSITIVE_INFINITY }],
1,
),
).toThrow(/invalid/u);
});
it("validates and exactly replays a versioned seeded recipe", () => {
const source = JSON.stringify({
schemaVersion: 1,
algorithm: "weighted-sample-v1",
seed: "recipe-seed",
parameters: {
count: 2,
items: [
{ value: "one", weight: 1 },
{ value: "two", weight: 4 },
{ value: "three", weight: 2 },
],
},
});
const recipe = parseSeededRecipe(source);
expect(runSeededRecipe(recipe)).toEqual(runSeededRecipe(recipe));
expect(runSeededRecipe(recipe).output).toHaveLength(2);
});
});
describe("commitreveal ceremonies", () => {
it("verifies commitments and derives an order-independent final seed", async () => {
const alice = await createCeremonyReveal("draw-1", "Alice");
const bob = await createCeremonyReveal("draw-1", "Bob");
const first = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-1",
participants: [alice, bob],
});
const second = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-1",
participants: [bob, alice],
});
expect(first.valid).toBe(true);
expect(first.seed).toMatch(/^[A-Za-z0-9_-]{43}$/u);
expect(second.seed).toBe(first.seed);
});
it("rejects a reveal changed after commitment", async () => {
const entry = await createCeremonyReveal("draw-2", "Alice");
const other = await createCeremonyReveal("draw-2", "Other");
const result = await finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-2",
participants: [{ ...entry, nonce: other.nonce }],
});
expect(result).toMatchObject({ valid: false, verified: 0 });
expect(result.errors.join(" ")).toMatch(/does not match/u);
});
it("parses a bounded document and reproduces its commitment", async () => {
const entry = await createCeremonyReveal("draw-3", "Alice");
const parsed = parseCeremonyDocument(
JSON.stringify({
schemaVersion: 1,
ceremonyId: "draw-3",
participants: [entry],
}),
);
await expect(
commitmentForReveal("draw-3", "Alice", parsed.participants[0]!.nonce),
).resolves.toMatchObject({ commitment: entry.commitment });
});
it("retains per-reveal ceremony IDs and rejects malformed commitments", async () => {
const entry = await createCeremonyReveal("draw-4", "Alice");
const parsed = parseCeremonyDocument(
JSON.stringify({
schemaVersion: 1,
ceremonyId: "draw-4",
participants: [{ ...entry, ceremonyId: "another-draw" }],
}),
);
await expect(finalizeCeremony(parsed)).resolves.toMatchObject({
valid: false,
verified: 0,
});
await expect(
finalizeCeremony({
schemaVersion: 1,
ceremonyId: "draw-4",
participants: [{ ...entry, commitment: "not-base64url" }],
}),
).resolves.toMatchObject({ valid: false, verified: 0 });
});
});