diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5208298 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +* @paulkakell +/assets/crypto*.js @paulkakell +/assets/passphrase.js @paulkakell +/.github/workflows/ @paulkakell +/SECURITY.md @paulkakell +/docs/FORMAT.md @paulkakell +/docs/THREAT_MODEL.md @paulkakell diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c8c4f89 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: America/Denver + open-pull-requests-limit: 5 + versioning-strategy: increase-if-necessary + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:30" + timezone: America/Denver + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b2713a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: Security validation + +on: + push: + branches: [dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-validation-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.16.0 + + - name: Install locked development tools + run: npm ci --ignore-scripts + + - name: Audit development dependency + run: npm audit --audit-level=high + + - name: Run complete validation suite + run: npm run validate + + - name: Upload validated static artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: blindcrypt-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..363c7a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "17 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + packages: read + actions: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + languages: javascript-typescript + queries: security-extended + + - name: Analyze source + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + category: /language:javascript-typescript diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..77f60a0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,60 @@ +name: Deploy validated Pages artifact + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + permissions: + contents: read + pages: write + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.16.0 + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Install locked development tools + run: npm ci --ignore-scripts + + - name: Audit development dependency + run: npm audit --audit-level=high + + - name: Validate and build + run: npm run validate + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + + deploy: + needs: build + permissions: + pages: write + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e3789d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.log +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..10b52b6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +audit=true +engine-strict=true +fund=false +ignore-scripts=true +package-lock=true +save-exact=true +update-notifier=false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..03f76e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +BlindCrypt versions use `xx.xx.xx` as `..`. + +## [01.01.01] - 2026-08-13 + +Status: development branch candidate. Release tag reserved: `v01.01.01` after merge and successful protected validation. + +References: `GHAS-PR-1`, CodeQL alert 1, PR #1. + +Baseline: `b57c01dd515011273832064f0645842655196be7`. + +### Security fix + +- Replaced the validation HTTP server's `stat()` followed by `readFile()` sequence, which CodeQL identified as a potential filesystem check/use race. +- Replaced request-derived filesystem resolution with an exact route allowlist. Request path data is now used only as a map key and never becomes a filesystem path. +- Added explicit rejection for unlisted paths, traversal-shaped requests, and non-GET methods. + +### Tests and controls + +- Added regression tests that prohibit reintroduction of `stat()` checks, dynamic path decoding, and request-derived file paths in the smoke server. +- Made the public-header tamper fixture choose a guaranteed-different writer value so release version changes cannot turn the security mutation into a no-op. +- Retained the runtime smoke checks for every expected build asset and added negative requests for traversal and unlisted paths. +- Updated the application version, changelog, SBOM, security policy, release notes, validation record, release checklist, README, and commit notes. + +### Classification and compatibility + +- Change type: security bug fix. +- Breaking: no. +- Encryption format: unchanged at v3. +- Reader compatibility: unchanged for v1, v2, and v3. +- Runtime dependencies: unchanged at zero. +- Database, backend, environment, and configuration migration: not applicable. + +## [01.01.00] - 2026-08-13 + +Status: superseded before release by `01.01.01`. + +Reference: `SEC-AUDIT-2026-08-13`. + +Baseline: `4ed8c157c6015340b363848c12527d9499fb8d69`. + +### Security fixes + +- Added authenticated format v3. The exact public header frame, record type, record index, and plaintext record length are bound to every AES-GCM record through additional authenticated data. +- Added a fixed-size encrypted metadata record for filename, media type, and writer version. +- Added exact container-length verification, canonical public-header parsing, record-geometry checks, and rejection of truncation or trailing data. +- Added strict upper and lower bounds for KDF iterations, public-header length, salt and IV lengths, plaintext size, record count, metadata size, and passphrase length. +- Added a 64 MiB plaintext ceiling and slice-based Blob processing to reduce memory amplification. +- Added safe filename and MIME normalization. Legacy output uses a neutral filename and media type. +- Replaced misleading custom-passphrase entropy estimates. Generated word-list phrases retain transparent word-count estimates; custom passphrases receive no entropy claim. +- Removed the four-word generator option. The minimum generated phrase is six words. Repetitive custom values are rejected. +- Added NFC normalization for v3 while preserving exact passphrase behavior for legacy v1 and v2. +- Added a restrictive Content Security Policy, a no-referrer policy, local-only executable resources, and checks that prohibit network APIs, dynamic HTML sinks, persistent storage, and console logging. + +### Additive changes + +- Added read compatibility for v1, v2, and v3 through a single bounded parser. +- Added unit, integration, regression, tamper, normalization, and performance tests. +- Added strict JavaScript type checking, custom linting, local SAST, configuration validation, reproducible static builds, an HTTP artifact smoke test, SHA-256 manifests, an SPDX SBOM, dependency auditing, and CodeQL. +- Classified the bundled 2,048-word list as separately validated static data so security lint does not mistake dictionary words such as `fetch` for executable network APIs. +- Added pinned GitHub Actions workflows for validation, scanning, artifact retention, and Pages deployment. +- Added version, format, architecture, API, threat-model, validation, release, rollback, repository-settings, commit-note, and security-policy documentation. + +### Removed + +- Removed the unused placeholder `assets/wordlist_2048.js` file. +- Removed new-file format v2 output. Version 2 remains readable. +- Removed unverified custom entropy labels and the insecure four-word generation option. + +### Compatibility + +- Additive reader compatibility: version `01.01.00` reads v1, v2, and v3. +- Breaking producer change: files created by `01.01.00` use v3 and cannot be opened by the unversioned baseline. +- No backend API, database schema, environment-variable, or server configuration migration exists. diff --git a/COMMIT_NOTES.md b/COMMIT_NOTES.md new file mode 100644 index 0000000..8896467 --- /dev/null +++ b/COMMIT_NOTES.md @@ -0,0 +1,21 @@ +# Commit notes for 01.01.01 + +```text +security: fix smoke-server filesystem race for 01.01.01 + +Release: 01.01.01 +Tag after protected merge: v01.01.01 +Refs: GHAS-PR-1, CodeQL alert 1, PR #1 +Baseline: b57c01dd515011273832064f0645842655196be7 + +- replace stat-then-read validation with a fixed route allowlist +- prevent request paths from becoming filesystem paths +- reject traversal-shaped, unlisted, and non-GET requests +- add regression coverage for the CodeQL finding +- make authenticated-header tamper coverage deterministic across version increments +- update version, changelog, SBOM, security policy, release notes, validation, and rollback evidence + +Change type: non-breaking security bug fix +Compatibility: format v3 writer and v1/v2/v3 reader behavior unchanged +Rollback: revert the 01.01.01 commits to the validated 01.01.00 candidate; retain format v3 support +``` diff --git a/README.md b/README.md index 2c59ad1..3ce0cf8 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,114 @@ # BlindCrypt -BlindCrypt is a static web app for client-side file encryption and decryption. Your passphrase and plaintext stay in your browser. +BlindCrypt is a static browser application for authenticated client-side file encryption. Version `01.01.01` writes format v3 containers and reads format v1, v2, and v3 files. -## Features +The application has no runtime dependencies, backend, account system, telemetry, analytics, or network requests. The hosting server delivers static files. Encryption and decryption use the browser WebCrypto implementation. -- Encrypt a file with a passphrase and download a `.blindcrypt` file -- Decrypt a `.blindcrypt` file with the passphrase and download the original -- Passphrase generator with adjustable security levels -- Versioned file format for future compatibility +## Primary use cases -## Security model +- Encrypt a document before sending it through email, cloud storage, chat, or another untrusted transport. +- Decrypt a received `.blindcrypt` file without uploading its contents to a service. +- Generate a uniformly selected multiword passphrase for out-of-band exchange. +- Open older BlindCrypt v1 or v2 files while receiving an explicit warning about their legacy integrity limits. -- Encryption and decryption occur locally via WebCrypto -- Server only hosts static files -- If the endpoint is compromised (malware, hostile browser extensions), no web app can protect the data +BlindCrypt does not protect data on a compromised device, inside a hostile browser or extension, or after plaintext is downloaded. -## Algorithms +## Use the application -- Cipher: AES-256-GCM (authenticated encryption) -- KDF: PBKDF2 with SHA-256 -- Randomness: `crypto.getRandomValues` +1. Serve the repository through HTTPS or a local web server. Opening the page through `file://` is not recommended. +2. Select **Encrypt** and choose a file no larger than 64 MiB. +3. Select a security level. **Strong** is the default. +4. Generate a passphrase or enter a non-repetitive custom passphrase of at least 16 characters with sufficient character variety. Generated phrases require at least six bundled words. +5. Store the passphrase separately, confirm it, then select **Encrypt and download**. +6. Send the `.blindcrypt` file and passphrase through separate channels. -This starter uses PBKDF2 to remain dependency-free for GitHub Pages. For stronger GPU-resistant derivation, replace PBKDF2 with Argon2id via WASM. +For decryption, select **Decrypt**, choose the encrypted file, enter the passphrase, and select **Decrypt and download**. Format v3 restores the authenticated filename. Legacy output is downloaded as `legacy-decrypted.bin` because legacy metadata is not authenticated. -## Passphrase word list +## Security-level options -BlindCrypt uses the 2048 word BIP39 English word list (bundled in `assets/wordlist.js`). +| Option | PBKDF2-SHA-256 iterations | Generated words | Intended use | +|---|---:|---:|---| +| Standard | 600,000 | 6 | Routine files when device speed is constrained | +| Strong | 900,000 | 8 | Default balance for ordinary sensitive files | +| High | 1,200,000 | 10 | Higher-value files on capable devices | +| Critical | 2,400,000 | 16 | Maximum configured passphrase and KDF cost | -## File format +The word generator selects from the bundled 2,048-word BIP39 English list using `crypto.getRandomValues`. Six words provide approximately 66 bits when each word is independently generated. BlindCrypt does not assign entropy estimates to custom passphrases. -``` -[4 bytes big-endian header length][header JSON UTF-8][ciphertext bytes] -``` +## Format v3 security properties + +- AES-256-GCM encrypts a fixed-size metadata record and each 512 KiB data record. +- PBKDF2-HMAC-SHA-256 derives a nonextractable key from the NFC-normalized passphrase and a random 128-bit salt. +- Each record uses a unique 96-bit IV composed of a random 64-bit prefix and a 32-bit record counter. +- The exact binary header frame, record type, record index, and plaintext record length are AES-GCM additional authenticated data. +- Filename and media type are encrypted inside a fixed-size metadata block. +- Header size, salt, IV, KDF cost, file size, record count, record geometry, metadata size, and final container length are validated before decryption proceeds. +- Truncation, appended bytes, record substitution, reordered records, public-header changes, metadata changes, and ciphertext changes cause rejection. + +See [docs/FORMAT.md](docs/FORMAT.md) for the byte-level specification and [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for assumptions and exclusions. + +## Legacy compatibility -The header contains version, KDF parameters, salt, IV, and original filename/type. +BlindCrypt reads v1 and v2 files so existing data remains accessible. Legacy passphrases are used exactly as entered; NFC normalization applies only to v3. -## Run locally +Legacy limitations cannot be repaired after encryption: -Use a local web server (recommended): +- v1 and v2 metadata is public and unauthenticated. +- v2 authenticates individual records, but the original format does not authenticate the complete file structure. +- Legacy filenames and MIME types are treated as untrusted. Downloads use a neutral filename and `application/octet-stream`. +- Strict bounds and exact-length checks reject malformed legacy files, excessive KDF settings, and trailing data, but they cannot add missing cryptographic commitments to previously created files. + +## Browser and resource limits + +The current browser-only implementation accepts plaintext files up to 64 MiB. Encryption and decryption process 512 KiB slices, then assemble a downloadable `Blob`. The ceiling limits memory amplification and malicious local-file resource consumption. A future large-file mode should use a reviewed writable-stream design rather than increasing this limit. + +## Local development + +Requirements: Node.js 22 or newer and Python 3 or another static HTTP server. ```bash +npm ci --ignore-scripts +npm audit --audit-level=high +npm run validate python -m http.server 8080 ``` -Then visit `http://localhost:8080`. +Open `http://localhost:8080`. + +Validation commands: + +```bash +npm run lint # syntax, HTML policy, word-list, and unsafe-API checks +npm run typecheck # strict TypeScript checking over production JavaScript +npm test # unit, integration, and regression tests +npm run security # local SAST and dependency allowlist checks +npm run config # workflow, version, default, and policy checks +npm run build # clean static artifact plus SHA256SUMS +npm run smoke # allowlisted local HTTP retrieval of the built artifact +npm run perf # 1 MiB authenticated round-trip performance smoke test +``` + +`npm run validate` executes all commands in release order. The smoke server uses a fixed route allowlist. Request paths never become filesystem paths, preventing path traversal and filesystem check/use races in the validation utility. + +## CI and deployment + +- `.github/workflows/ci.yml` validates pushes to `dev` and pull requests into `main` or `dev`. +- `.github/workflows/codeql.yml` runs CodeQL with extended security queries. +- `.github/workflows/pages.yml` builds and deploys the validated `dist/` artifact after changes reach `main`. +- All referenced GitHub Actions are pinned to full commit SHAs. +- Production Pages settings must use **GitHub Actions** as the deployment source. Required repository settings are listed in [docs/REPOSITORY_SETTINGS.md](docs/REPOSITORY_SETTINGS.md). + +## Versioning and releases + +BlindCrypt uses `xx.xx.xx` as `..`. The repository version is stored in `VERSION` and exposed through `APP_VERSION`. + +Development commits carry the next version but are not tagged. After the validated commit reaches `main`, create the matching immutable tag, such as `v01.01.01`, and attach the `dist/` artifact, `SHA256SUMS`, SPDX SBOM, release notes, and validation evidence. Do not tag a commit that did not pass the full workflow. + +See [CHANGELOG.md](CHANGELOG.md), [docs/RELEASE_01.01.01.md](docs/RELEASE_01.01.01.md), [COMMIT_NOTES.md](COMMIT_NOTES.md), [docs/VALIDATION_01.01.01.md](docs/VALIDATION_01.01.01.md), [docs/RELEASE_CHECKLIST.md](docs/RELEASE_CHECKLIST.md), and [docs/ROLLBACK.md](docs/ROLLBACK.md). -## Host on GitHub Pages +## Security reports -1. Push this repo to GitHub -2. Settings -> Pages -3. Source: Deploy from a branch -4. Branch: `main` and folder `/root` -5. Save +Do not open a public issue for an undisclosed vulnerability. Follow [SECURITY.md](SECURITY.md). ## License diff --git a/SBOM.spdx.json b/SBOM.spdx.json new file mode 100644 index 0000000..289bd26 --- /dev/null +++ b/SBOM.spdx.json @@ -0,0 +1,63 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "BlindCrypt-01.01.01", + "documentNamespace": "https://github.com/paulkakell/BlindCrypt/spdx/01.01.01/2026-08-13", + "creationInfo": { + "created": "2026-08-14T03:30:00Z", + "creators": [ + "Tool: BlindCrypt release validation" + ] + }, + "packages": [ + { + "name": "BlindCrypt", + "SPDXID": "SPDXRef-Package-BlindCrypt", + "versionInfo": "01.01.01", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "Copyright (c) 2026", + "primaryPackagePurpose": "APPLICATION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:github/paulkakell/BlindCrypt@01.01.01" + } + ] + }, + { + "name": "TypeScript", + "SPDXID": "SPDXRef-Package-TypeScript", + "versionInfo": "5.8.3", + "downloadLocation": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "filesAnalyzed": false, + "licenseConcluded": "Apache-2.0", + "licenseDeclared": "Apache-2.0", + "copyrightText": "NOASSERTION", + "primaryPackagePurpose": "BUILD_TOOL", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:npm/typescript@5.8.3" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-BlindCrypt" + }, + { + "spdxElementId": "SPDXRef-Package-TypeScript", + "relationshipType": "BUILD_TOOL_OF", + "relatedSpdxElement": "SPDXRef-Package-BlindCrypt" + } + ] +} diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3ab8161 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security policy + +## Supported versions + +| Version | Status | +|---|---| +| 01.01.01 development candidate | Security fixes accepted on `dev` | +| 01.01.00 development candidate | Superseded by 01.01.01 before release | +| Unversioned baseline | Unsupported after 01.01.01 is released | + +## Reporting a vulnerability + +Do not disclose an unpatched vulnerability in a public issue or discussion. Use GitHub private vulnerability reporting when it is enabled for this repository. If that feature is unavailable, contact the repository owner through a private channel listed on the owner's GitHub profile. + +Include: + +- affected commit and application version +- browser and operating system +- concise reproduction steps +- expected and observed behavior +- whether confidentiality, integrity, availability, or supply-chain controls are affected +- proof-of-concept files with non-sensitive test data only + +Do not send real passphrases, plaintext, private keys, personal data, or production files. + +## Security response + +The maintainer will reproduce the report, assign severity, prepare a private fix, add regression coverage, run the full release checklist, and publish an advisory when disclosure is appropriate. Release tags must match the corrected `VERSION` value. + +## Cryptographic scope + +BlindCrypt relies on browser WebCrypto for PBKDF2-HMAC-SHA-256 and AES-256-GCM. It does not implement primitive algorithms. Changes to format framing, key derivation, IV construction, authentication inputs, limits, or compatibility behavior require focused security review and new known-answer or tamper tests. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..f40474d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +01.01.01 diff --git a/assets/app.css b/assets/app.css index 12cf598..ca24aa7 100644 --- a/assets/app.css +++ b/assets/app.css @@ -1,195 +1,466 @@ -:root{ - --bg:#0b0f14; - --card:#121824; - --muted:#96a2b4; - --text:#e7edf6; - --line:#243048; - --btn:#1e2a40; - --btn2:#152033; - --accent:#4ea3ff; - --warn:#ffcc66; - --bad:#ff6b6b; - --good:#6bff95; +:root { + color-scheme: dark; + --bg: #0b0f14; + --card: #121824; + --card-soft: #0f1622; + --muted: #a8b3c4; + --text: #eef3fa; + --line: #2a3852; + --button: #1d2a40; + --button-secondary: #151f31; + --accent: #6bb3ff; + --accent-deep: #377dff; + --warning: #ffd27a; + --danger: #ff8585; + --success: #8df0aa; --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; -} - -*{box-sizing:border-box} -body{ - margin:0; - font-family:var(--sans); - background:radial-gradient(1200px 700px at 20% -10%, #122341 0%, var(--bg) 60%); - color:var(--text); -} - -.wrap{max-width:1100px;margin:0 auto;padding:18px} -.brand{display:flex;gap:14px;align-items:center} -.logo{ - width:44px;height:44px;border-radius:12px; - background:linear-gradient(135deg,#2b5cff,#4ea3ff); - display:flex;align-items:center;justify-content:center; - font-weight:800;color:#06101f; -} -h1{margin:0;font-size:28px;letter-spacing:0.2px} -.sub{margin:4px 0 0;color:var(--muted);max-width:760px} - -.tabs{ - display:flex;gap:10px;flex-wrap:wrap; - border-bottom:1px solid var(--line); - padding-bottom:12px;margin-bottom:16px; -} -.tab{ - border:1px solid var(--line); - background:transparent; - color:var(--text); - padding:10px 12px; - border-radius:12px; - cursor:pointer; -} -.tab.active{ - border-color:#2a3b5c; - background:rgba(78,163,255,0.10); -} - -.panel{display:none} -.panel.show{display:block} - -.grid{ - display:grid; - gap:14px; - grid-template-columns:repeat(3, minmax(0, 1fr)); -} -@media (max-width: 980px){ - .grid{grid-template-columns:1fr} -} - -.card{ - background:rgba(18,24,36,0.85); - border:1px solid rgba(36,48,72,0.75); - border-radius:16px; - padding:14px; - box-shadow: 0 10px 30px rgba(0,0,0,0.25); -} - -.label{display:block;margin-bottom:8px;color:var(--muted);font-size:13px} -input[type="file"]{width:100%} - -input, select, button{ - font-family:inherit; - font-size:14px; -} -input[type="password"], input[type="text"]{ - width:100%; - padding:10px 10px; - border-radius:12px; - border:1px solid var(--line); - background:#0d1420; - color:var(--text); -} -select{ - width:100%; - padding:10px 10px; - border-radius:12px; - border:1px solid var(--line); - background:#0d1420; - color:var(--text); -} - -.row{display:flex;gap:10px;align-items:center;margin-top:10px} -.row > *{flex:1} -.row button{flex:0 0 auto} - -.btn{ - border:1px solid var(--line); - background:var(--btn); - color:var(--text); - padding:10px 12px; - border-radius:12px; - cursor:pointer; -} -.btn.secondary{background:var(--btn2)} -.btn.primary{ - background:linear-gradient(135deg,#2b5cff,#4ea3ff); - border-color:transparent; - color:#06101f; - font-weight:700; -} - -.actions{display:flex;gap:12px;align-items:center;margin-top:14px} -.status{color:var(--muted);min-height:20px} -.hint{color:var(--muted);font-size:12.5px;margin-top:8px;line-height:1.35} -.hint.warn{color:var(--warn)} -.muted{color:var(--muted)} -.dot{margin:0 8px;color:var(--muted)} -.footer{display:flex;align-items:center;gap:0;color:var(--muted);padding-top:10px} -.mono{font-family:var(--mono)} - -.kdfBox{ - margin-top:10px; - padding:10px; - border:1px dashed rgba(36,48,72,0.85); - border-radius:12px; - background:rgba(13,20,32,0.45); -} -.kdfRow{ - display:grid; - grid-template-columns:1fr 1fr 1fr; - gap:10px; -} -@media (max-width: 980px){ - .kdfRow{grid-template-columns:1fr} -} -.kdfKey{color:var(--muted);font-size:12px} -.kdfVal{margin-top:2px;font-family:var(--mono);font-size:12.5px} - -.meta{margin-top:10px} -.metaRow{ - display:flex;justify-content:space-between;gap:10px; - padding:8px 0;border-bottom:1px solid rgba(36,48,72,0.45) -} -.metaRow:last-child{border-bottom:0} - -.bullets{margin:0;padding-left:18px} -.bullets li{margin:8px 0} - - -/* Progress + strength UI */ -.actionCol{display:flex;flex-direction:column;gap:10px} -.pwrap{min-width:260px} -.ptrack{ - width:100%; - height:10px; - border-radius:999px; - border:1px solid rgba(36,48,72,0.85); - background:rgba(13,20,32,0.60); - overflow:hidden; -} -.pbar{ - height:100%; - width:0%; - border-radius:999px; - background:linear-gradient(135deg,#2b5cff,#4ea3ff); - transition:width 120ms linear; -} -.ptext{margin-top:6px;color:var(--muted);font-size:12px;min-height:16px} - -.strength{margin-top:10px} -.strengthTop{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px} -.strengthLabel{color:var(--muted);font-size:12.5px} -.strengthTrack{ - width:100%; - height:10px; - border-radius:999px; - border:1px solid rgba(36,48,72,0.85); - background:rgba(13,20,32,0.60); - overflow:hidden; -} -.strengthFill{ - height:100%; - width:0%; - border-radius:999px; - background:linear-gradient(135deg,#2b5cff,#4ea3ff); - transition:width 120ms linear; -} -.footer a.muted{color:var(--muted);text-decoration:none} -.footer a.muted:hover{color:var(--text);text-decoration:underline} + --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--sans); + background: radial-gradient(1200px 760px at 18% -12%, #152a4d 0%, var(--bg) 62%); + color: var(--text); + line-height: 1.5; +} + +button, +input, +select { + font: inherit; +} + +button, +a, +input, +select { + outline-offset: 3px; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--accent); +} + +.wrap { + width: min(1120px, 100%); + margin: 0 auto; + padding: 20px; +} + +.siteHeader { + padding-top: 28px; + padding-bottom: 14px; +} + +.brand { + display: flex; + gap: 15px; + align-items: center; +} + +.logo { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 13px; + background: linear-gradient(135deg, var(--accent-deep), var(--accent)); + color: #06101f; + font-weight: 800; + letter-spacing: 0.04em; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 3px; + font-size: clamp(1.7rem, 4vw, 2.15rem); +} + +h2 { + margin-bottom: 8px; + font-size: 1.45rem; +} + +h3 { + margin-bottom: 10px; + font-size: 1.08rem; +} + +.sub, +.sectionIntro, +.hint, +.footer { + color: var(--muted); +} + +.sub { + margin-bottom: 0; + max-width: 820px; +} + +.sectionIntro { + max-width: 900px; + margin-bottom: 18px; +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; + padding-bottom: 13px; + border-bottom: 1px solid var(--line); +} + +.tab, +.btn { + border: 1px solid var(--line); + border-radius: 11px; + background: var(--button); + color: var(--text); + cursor: pointer; + transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease; +} + +.tab { + padding: 9px 14px; + background: transparent; +} + +.tab.active { + border-color: #3e5984; + background: rgba(107, 179, 255, 0.12); +} + +.tab:hover, +.btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.btn:active:not(:disabled) { + transform: translateY(1px); +} + +.btn { + padding: 10px 14px; +} + +.btn.secondary { + background: var(--button-secondary); +} + +.btn.primary { + border-color: transparent; + background: linear-gradient(135deg, var(--accent-deep), var(--accent)); + color: #06101f; + font-weight: 750; +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.panel { + min-height: 420px; +} + +.grid, +.aboutGrid { + display: grid; + gap: 15px; +} + +.grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.aboutGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card { + min-width: 0; + padding: 16px; + border: 1px solid rgba(42, 56, 82, 0.86); + border-radius: 15px; + background: rgba(18, 24, 36, 0.9); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.24); +} + +.label { + display: block; + margin-bottom: 9px; + color: var(--muted); + font-size: 0.86rem; + font-weight: 650; +} + +input[type="file"] { + width: 100%; +} + +input[type="password"], +input[type="text"], +select { + width: 100%; + min-width: 0; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 10px; + background: #0d1420; + color: var(--text); +} + +.row { + display: flex; + gap: 10px; + align-items: center; + margin-top: 10px; +} + +.row > input { + flex: 1 1 auto; +} + +.buttonRow { + flex-wrap: wrap; +} + +.kdfGrid, +.meta { + margin: 13px 0 0; +} + +.kdfGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + border: 1px dashed var(--line); + border-radius: 11px; + background: rgba(13, 20, 32, 0.58); +} + +.kdfGrid div, +.meta div { + min-width: 0; +} + +.kdfGrid dt, +.meta dt { + color: var(--muted); + font-size: 0.78rem; +} + +.kdfGrid dd, +.meta dd { + margin: 2px 0 0; +} + +.kdfGrid dd { + font-family: var(--mono); + font-size: 0.82rem; +} + +.meta div { + display: grid; + grid-template-columns: minmax(90px, 0.55fr) minmax(0, 1.45fr); + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid rgba(42, 56, 82, 0.55); +} + +.meta div:last-child { + border-bottom: 0; +} + +.meta dd { + overflow-wrap: anywhere; + text-align: right; +} + +.strength { + margin-top: 13px; +} + +.strengthTop { + display: flex; + gap: 12px; + align-items: baseline; + justify-content: space-between; + margin-bottom: 7px; + color: var(--muted); + font-size: 0.84rem; +} + +progress { + width: 100%; + height: 12px; + border: 0; + border-radius: 999px; + overflow: hidden; + background: #0d1420; +} + +progress::-webkit-progress-bar { + border-radius: 999px; + background: #0d1420; +} + +progress::-webkit-progress-value { + border-radius: 999px; + background: linear-gradient(90deg, var(--accent-deep), var(--accent)); +} + +progress::-moz-progress-bar { + border-radius: 999px; + background: linear-gradient(90deg, var(--accent-deep), var(--accent)); +} + +.hint { + margin: 8px 0 0; + font-size: 0.8rem; + line-height: 1.42; +} + +.warn { + color: var(--warning); +} + +.actions { + display: grid; + grid-template-columns: auto minmax(220px, 340px) minmax(220px, 1fr); + gap: 14px; + align-items: center; + margin-top: 17px; +} + +.progressGroup { + display: grid; + gap: 5px; +} + +.progressGroup .mono { + min-height: 1.1rem; + color: var(--muted); + font-size: 0.78rem; +} + +.status { + min-height: 1.5rem; + color: var(--muted); +} + +.status[data-kind="good"] { + color: var(--success); +} + +.status[data-kind="bad"] { + color: var(--danger); +} + +.status[data-kind="warn"] { + color: var(--warning); +} + +.bullets { + margin: 0; + padding-left: 20px; +} + +.bullets li { + margin: 8px 0; +} + +.footer { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding-top: 12px; + padding-bottom: 28px; + font-size: 0.84rem; +} + +.footer a { + color: var(--muted); +} + +.footer a:hover { + color: var(--text); +} + +.mono { + font-family: var(--mono); +} + +@media (max-width: 960px) { + .grid, + .aboutGrid { + grid-template-columns: 1fr; + } + + .actions { + grid-template-columns: 1fr; + align-items: stretch; + } +} + +@media (max-width: 560px) { + .wrap { + padding-left: 14px; + padding-right: 14px; + } + + .brand { + align-items: flex-start; + } + + .logo { + flex: 0 0 auto; + width: 42px; + height: 42px; + } + + .row { + align-items: stretch; + flex-direction: column; + } + + .row .btn { + width: 100%; + } + + .kdfGrid { + grid-template-columns: 1fr; + } + + .meta div { + grid-template-columns: 1fr; + gap: 2px; + } + + .meta dd { + text-align: left; + } +} diff --git a/assets/app.js b/assets/app.js index 3ad3a41..b7e5988 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,507 +1,353 @@ -/* BlindCrypt - Client-side encrypt/decrypt using WebCrypto. - Format v1: [4 bytes big-endian headerLength][header JSON utf8][ciphertext bytes] - Format v2: [4 bytes big-endian headerLength][header JSON utf8][ciphertext chunks] -*/ - -const $ = (id) => document.getElementById(id); - -const LEVELS = { - standard: { iterations: 310000, words: 4 }, - strong: { iterations: 600000, words: 6 }, - high: { iterations: 1200000, words: 8 }, - critical: { iterations: 2400000, words: 16 }, -}; - -const CHUNK_SIZE = 512 * 1024; // 512 KiB - -function setStatus(el, msg, kind = "info") { - el.textContent = msg; - el.dataset.kind = kind; +// @ts-check + +import { + APP_VERSION, + BlindCryptError, + LEVELS, + LIMITS, + decryptBlobAny, + encryptBlobV3, + sanitizeFilename, +} from "./crypto.js"; +import { + assessPassphrase, + buildWordSet, + generatePassphrase, + validateNewPassphrase, +} from "./passphrase.js"; + +/** @param {string} id */ +function element(id) { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing required element: ${id}`); + return found; } -function setProgress(pct, msg = "") { - const bar = $("encProgBar"); - const txt = $("encProgText"); - if (!bar || !txt) return; - const p = Math.max(0, Math.min(100, Number(pct) || 0)); - bar.style.width = `${p.toFixed(1)}%`; - txt.textContent = msg || (p > 0 ? `${p.toFixed(1)}%` : ""); +/** @param {string} id */ +function input(id) { + return /** @type {HTMLInputElement} */ (element(id)); } -function b64uEncode(bytes) { - const bin = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); - return btoa(bin).replaceAll("+","-").replaceAll("/","_").replaceAll("=",""); +/** @param {string} id */ +function select(id) { + return /** @type {HTMLSelectElement} */ (element(id)); } -function b64uDecode(str) { - const s = str.replaceAll("-","+").replaceAll("_","/"); - const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); - const bin = atob(s + pad); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; +/** @param {string} id */ +function button(id) { + return /** @type {HTMLButtonElement} */ (element(id)); } -function utf8Encode(s) { - return new TextEncoder().encode(s); -} -function utf8Decode(b) { - return new TextDecoder().decode(b); +/** @param {string} id */ +function progress(id) { + return /** @type {HTMLProgressElement} */ (element(id)); } -function u32be(n) { - const b = new Uint8Array(4); - b[0] = (n >>> 24) & 0xff; - b[1] = (n >>> 16) & 0xff; - b[2] = (n >>> 8) & 0xff; - b[3] = n & 0xff; - return b; -} -function readU32be(b, off) { - return ((b[off] << 24) | (b[off+1] << 16) | (b[off+2] << 8) | (b[off+3])) >>> 0; +const bundledWords = /** @type {unknown} */ (Reflect.get(globalThis, "WORDS")); +if (!Array.isArray(bundledWords)) throw new Error("Bundled word list is unavailable"); +const words = /** @type {string[]} */ (bundledWords); +const wordSet = buildWordSet(words); + +/** + * @param {HTMLElement} target + * @param {string} message + * @param {"info" | "good" | "bad" | "warn"} [kind] + */ +function setStatus(target, message, kind = "info") { + target.textContent = message; + target.dataset.kind = kind; } -function concatBytes(...parts) { - const total = parts.reduce((n,p) => n + p.length, 0); - const out = new Uint8Array(total); - let o = 0; - for (const p of parts) { out.set(p, o); o += p.length; } - return out; +/** + * @param {HTMLProgressElement} bar + * @param {HTMLElement} text + * @param {number} percent + * @param {string} message + */ +function setProgress(bar, text, percent, message) { + const bounded = Math.max(0, Math.min(100, Number(percent) || 0)); + bar.value = bounded; + text.textContent = message || (bounded > 0 ? `${bounded.toFixed(1)}%` : ""); } -function downloadBytes(bytes, filename) { - const blob = new Blob([bytes], { type: "application/octet-stream" }); +/** @param {Blob} blob @param {string} filename */ +function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 2000); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = sanitizeFilename(filename); + anchor.rel = "noopener noreferrer"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 30_000); } -function sampleWords(count) { - if (!Array.isArray(window.WORDS) || window.WORDS.length < 2048) { - throw new Error("Word list is missing or incomplete"); +/** @param {unknown} error */ +function encryptionErrorMessage(error) { + if (error instanceof BlindCryptError) { + if (["FILE_TOO_LARGE", "INVALID_INPUT", "INVALID_KDF", "INVALID_PASSPHRASE"].includes(error.code)) { + return error.message; + } } - const r = new Uint32Array(count); - crypto.getRandomValues(r); - const out = []; - for (let i = 0; i < count; i++) out.push(window.WORDS[r[i] % window.WORDS.length]); - return out.join(" "); + return "Encryption failed. No output was created."; } -async function deriveKeyPBKDF2(passphrase, saltBytes, iterations) { - const baseKey = await crypto.subtle.importKey( - "raw", - utf8Encode(passphrase), - "PBKDF2", - false, - ["deriveKey"] - ); - - return crypto.subtle.deriveKey( - { name: "PBKDF2", salt: saltBytes, iterations, hash: "SHA-256" }, - baseKey, - { name: "AES-GCM", length: 256 }, - false, - ["encrypt", "decrypt"] - ); +/** @param {unknown} error */ +function decryptionErrorMessage(error) { + if (error instanceof BlindCryptError && error.code === "FILE_TOO_LARGE") return error.message; + return "Decryption failed. The passphrase is wrong, the file is invalid, or the file was modified."; } -function makeChunkIV(ivBaseBytes, counter) { - // AES-GCM expects 12-byte IV. - // Use first 8 bytes as random prefix, last 4 bytes as big-endian chunk counter. - const iv = new Uint8Array(12); - iv.set(ivBaseBytes.slice(0, 8), 0); - iv[8] = (counter >>> 24) & 0xff; - iv[9] = (counter >>> 16) & 0xff; - iv[10] = (counter >>> 8) & 0xff; - iv[11] = counter & 0xff; - return iv; +function updateLevelLabel() { + const levelKey = select("encLevel").value; + const level = LEVELS[/** @type {keyof typeof LEVELS} */ (levelKey)] || LEVELS.strong; + element("encIterLabel").textContent = level.iterations.toLocaleString("en-US"); + element("encWordLabel").textContent = String(level.words); } -async function encryptFileV2Chunked(file, passphrase, levelKey, onProgress) { - const level = LEVELS[levelKey] || LEVELS.critical; - - const salt = new Uint8Array(16); - crypto.getRandomValues(salt); - - const ivBase = new Uint8Array(12); - crypto.getRandomValues(ivBase); - - onProgress?.(1, "Deriving key..."); - const key = await deriveKeyPBKDF2(passphrase, salt, level.iterations); - - const total = file.size; - const chunkSize = CHUNK_SIZE; - const chunks = Math.max(1, Math.ceil(total / chunkSize)); - const last = total - (chunks - 1) * chunkSize; - - const cipherParts = []; - let processed = 0; - - for (let i = 0; i < chunks; i++) { - const start = i * chunkSize; - const end = Math.min(total, start + chunkSize); - const ab = await file.slice(start, end).arrayBuffer(); - const plain = new Uint8Array(ab); - - const iv = makeChunkIV(ivBase, i); - - const cipherBuf = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, - key, - plain - ); - const cipher = new Uint8Array(cipherBuf); - cipherParts.push(cipher); - - processed += plain.length; - const pct = 5 + (processed / total) * 95; - onProgress?.(pct, `${pct.toFixed(1)}%`); - // yield to UI for smoother progress on slower devices - await new Promise(r => setTimeout(r, 0)); - } - - const header = { - v: 2, - mode: "chunked-aesgcm", - kdf: "PBKDF2", - hash: "SHA-256", - iter: level.iterations, - alg: "AES-256-GCM", - salt: b64uEncode(salt), - iv: b64uEncode(ivBase), - size: total, - chunk: chunkSize, - chunks, - last, - name: file.name || "file", - type: file.type || "application/octet-stream" - }; - - const headerBytes = utf8Encode(JSON.stringify(header)); - - // stitch output: header + cipher parts - const totalCipher = cipherParts.reduce((n, p) => n + p.length, 0); - const out = new Uint8Array(4 + headerBytes.length + totalCipher); - out.set(u32be(headerBytes.length), 0); - out.set(headerBytes, 4); - - let o = 4 + headerBytes.length; - for (const p of cipherParts) { - out.set(p, o); - o += p.length; +function updatePassphraseAssessment() { + const assessment = assessPassphrase(input("encPass").value, wordSet); + const bar = progress("passStrengthBar"); + const text = element("passStrengthText"); + const hint = element("passStrengthHint"); + + text.textContent = assessment.label; + hint.textContent = assessment.text; + if (assessment.kind === "word-list") { + bar.hidden = false; + bar.value = assessment.progress; + } else { + bar.hidden = true; + bar.value = 0; } - - return { bytes: out, header }; } -async function decryptFileAny(fileBytes, passphrase, onProgress) { - if (fileBytes.length < 5) throw new Error("File too small"); - - const headerLen = readU32be(fileBytes, 0); - const headerStart = 4; - const headerEnd = headerStart + headerLen; - - if (headerEnd > fileBytes.length) throw new Error("Invalid header length"); - - const headerJson = utf8Decode(fileBytes.slice(headerStart, headerEnd)); - let header; - try { header = JSON.parse(headerJson); } - catch { throw new Error("Invalid header JSON"); } - - if (!header || (header.v !== 1 && header.v !== 2)) throw new Error("Unsupported format version"); - - const salt = b64uDecode(header.salt); - const iter = Number(header.iter); - if (!Number.isFinite(iter) || iter < 10000) throw new Error("Invalid KDF settings"); - - onProgress?.(1, "Deriving key..."); - const key = await deriveKeyPBKDF2(passphrase, salt, iter); - - if (header.v === 1) { - const iv = b64uDecode(header.iv); - const cipher = fileBytes.slice(headerEnd); - - const plainBuf = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - cipher - ); - - onProgress?.(100, "100.0%"); - return { plain: new Uint8Array(plainBuf), header }; +/** @param {string} name */ +function setTab(name) { + for (const tab of document.querySelectorAll("[role='tab']")) { + const active = tab instanceof HTMLElement && tab.dataset.tab === name; + tab.setAttribute("aria-selected", active ? "true" : "false"); + tab.setAttribute("tabindex", active ? "0" : "-1"); + tab.classList.toggle("active", active); } - - // v2 - const ivBase = b64uDecode(header.iv); - const totalPlain = Number(header.size); - const chunkSize = Number(header.chunk); - const chunks = Number(header.chunks); - const last = Number(header.last); - - if (![totalPlain, chunkSize, chunks, last].every(Number.isFinite)) throw new Error("Invalid header settings"); - if (chunks < 1 || chunkSize < 1024 || last < 0) throw new Error("Invalid chunk settings"); - - const cipherAll = fileBytes.slice(headerEnd); - const plainParts = []; - let off = 0; - let processed = 0; - - for (let i = 0; i < chunks; i++) { - const plainLen = (i === chunks - 1) ? last : chunkSize; - const cipherLen = plainLen + 16; // AES-GCM tag - const cipher = cipherAll.slice(off, off + cipherLen); - if (cipher.length !== cipherLen) throw new Error("Truncated ciphertext"); - off += cipherLen; - - const iv = makeChunkIV(ivBase, i); - const plainBuf = await crypto.subtle.decrypt( - { name: "AES-GCM", iv }, - key, - cipher - ); - - const p = new Uint8Array(plainBuf); - plainParts.push(p); - - processed += p.length; - const pct = 5 + (processed / totalPlain) * 95; - onProgress?.(pct, `${pct.toFixed(1)}%`); - await new Promise(r => setTimeout(r, 0)); + for (const panel of document.querySelectorAll("[role='tabpanel']")) { + if (panel instanceof HTMLElement) panel.hidden = panel.id !== `panel-${name}`; } - - onProgress?.(100, "100.0%"); - - const out = new Uint8Array(totalPlain); - let o = 0; - for (const p of plainParts) { out.set(p, o); o += p.length; } - - return { plain: out, header }; } -function setTab(name) { - const tabs = document.querySelectorAll(".tab"); - const panels = document.querySelectorAll(".panel"); - - tabs.forEach(t => { - const active = t.dataset.tab === name; - t.classList.toggle("active", active); - t.setAttribute("aria-selected", active ? "true" : "false"); +function bindTabs() { + const tabs = [...document.querySelectorAll("[role='tab']")]; + tabs.forEach((tab, index) => { + tab.addEventListener("click", () => { + if (tab instanceof HTMLElement && tab.dataset.tab) setTab(tab.dataset.tab); + }); + tab.addEventListener("keydown", (event) => { + if (!(event instanceof KeyboardEvent) || !["ArrowLeft", "ArrowRight"].includes(event.key)) return; + event.preventDefault(); + const delta = event.key === "ArrowRight" ? 1 : -1; + const next = tabs[(index + delta + tabs.length) % tabs.length]; + if (next instanceof HTMLElement && next.dataset.tab) { + setTab(next.dataset.tab); + next.focus(); + } + }); }); - - panels.forEach(p => p.classList.toggle("show", p.id === name)); -} - -function updateIterLabel() { - const levelKey = $("encLevel").value; - const level = LEVELS[levelKey] || LEVELS.critical; - $("encIterLabel").textContent = String(level.iterations); -} - -function wordSet() { - if (!Array.isArray(window.WORDS)) return null; - if (!wordSet.cache) wordSet.cache = new Set(window.WORDS.map(w => w.toLowerCase())); - return wordSet.cache; } -function estimatePassphrase(pass) { - const trimmed = (pass || "").trim(); - if (!trimmed) return { score: 0, text: "-", pct: 0 }; - - const parts = trimmed.split(/\s+/).filter(Boolean); - const wset = wordSet(); - - let bits = 0; - let mode = "chars"; - - if (wset && parts.length >= 2 && parts.every(w => wset.has(w.toLowerCase()))) { - mode = "words"; - bits = 11 * parts.length; - } else { - const s = trimmed; - const hasLower = /[a-z]/.test(s); - const hasUpper = /[A-Z]/.test(s); - const hasDigit = /[0-9]/.test(s); - const hasSymbol = /[^A-Za-z0-9\s]/.test(s); - - let pool = 0; - if (hasLower) pool += 26; - if (hasUpper) pool += 26; - if (hasDigit) pool += 10; - if (hasSymbol) pool += 33; - if (pool === 0) pool = 26; - - bits = s.replace(/\s+/g, "").length * Math.log2(pool); - } - - const target = 11 * 16; // 16 BIP39 words - const pct = Math.max(0, Math.min(100, (bits / target) * 100)); - - let label = "Weak"; - if (pct >= 85) label = "Critical"; - else if (pct >= 60) label = "High"; - else if (pct >= 35) label = "Strong"; - - const info = mode === "words" - ? `${label} (${parts.length} words, ~${bits.toFixed(0)} bits)` - : `${label} (~${bits.toFixed(0)} bits)`; - - return { score: bits, text: info, pct }; -} - -function updateStrengthUI() { - const pass = $("encPass")?.value || ""; - const { pct, text } = estimatePassphrase(pass); - - const fill = $("passStrengthFill"); - const txt = $("passStrengthText"); - const track = document.querySelector("#passStrength .strengthTrack"); - - if (fill) fill.style.width = `${pct.toFixed(1)}%`; - if (txt) txt.textContent = text; - if (track) track.setAttribute("aria-valuenow", String(Math.round(pct))); -} - -function bindUI() { - document.querySelectorAll(".tab").forEach(btn => { - btn.addEventListener("click", () => setTab(btn.dataset.tab)); +function bindPasswordVisibility() { + button("encShow").addEventListener("click", () => { + const field = input("encPass"); + field.type = field.type === "password" ? "text" : "password"; + button("encShow").textContent = field.type === "password" ? "Show" : "Hide"; }); - - $("encLevel").addEventListener("change", updateIterLabel); - updateIterLabel(); - - $("encShow").addEventListener("click", () => { - const i = $("encPass"); - i.type = (i.type === "password") ? "text" : "password"; - $("encShow").textContent = (i.type === "password") ? "Show" : "Hide"; - }); - - $("decShow").addEventListener("click", () => { - const i = $("decPass"); - i.type = (i.type === "password") ? "text" : "password"; - $("decShow").textContent = (i.type === "password") ? "Show" : "Hide"; + button("decShow").addEventListener("click", () => { + const field = input("decPass"); + field.type = field.type === "password" ? "text" : "password"; + button("decShow").textContent = field.type === "password" ? "Show" : "Hide"; }); +} - $("encPass").addEventListener("input", updateStrengthUI); - updateStrengthUI(); +function bindPassphraseControls() { + select("encLevel").addEventListener("change", updateLevelLabel); + input("encPass").addEventListener("input", updatePassphraseAssessment); - $("genPass").addEventListener("click", () => { - const levelKey = $("encLevel").value; - const level = LEVELS[levelKey] || LEVELS.critical; + button("genPass").addEventListener("click", () => { + const levelKey = /** @type {keyof typeof LEVELS} */ (select("encLevel").value); + const level = LEVELS[levelKey] || LEVELS.strong; try { - const pass = sampleWords(level.words); - $("encPass").value = pass; - $("encConfirm").value = ""; - updateStrengthUI(); - setStatus($("encStatus"), "Passphrase generated. Copy it and store it safely.", "info"); - } catch (e) { - setStatus($("encStatus"), `Passphrase generation failed: ${e?.message || String(e)}`, "bad"); + const generated = generatePassphrase(words, level.words); + input("encPass").value = generated; + input("encConfirm").value = ""; + updatePassphraseAssessment(); + setStatus( + element("encStatus"), + "Passphrase generated. Store it separately before encrypting; lost passphrases cannot be recovered.", + "warn", + ); + } catch { + setStatus(element("encStatus"), "Secure passphrase generation is unavailable.", "bad"); } }); - $("copyPass").addEventListener("click", async () => { - const pass = $("encPass").value; - if (!pass) { setStatus($("encStatus"), "Nothing to copy.", "bad"); return; } + button("copyPass").addEventListener("click", async () => { + const passphrase = input("encPass").value; + if (!passphrase) { + setStatus(element("encStatus"), "There is no passphrase to copy.", "bad"); + return; + } try { - await navigator.clipboard.writeText(pass); - setStatus($("encStatus"), "Copied passphrase to clipboard.", "good"); + await navigator.clipboard.writeText(passphrase); + setStatus( + element("encStatus"), + "Passphrase copied. Clipboard contents may be visible to other applications; clear it after use.", + "warn", + ); } catch { - setStatus($("encStatus"), "Clipboard blocked by browser. Select and copy manually.", "bad"); + setStatus(element("encStatus"), "Clipboard access was blocked. Select and copy the passphrase manually.", "bad"); } }); +} - $("doEncrypt").addEventListener("click", async () => { - const st = $("encStatus"); - const btn = $("doEncrypt"); - setStatus(st, "", "info"); - setProgress(0, ""); - - const f = $("encFile").files?.[0]; - if (!f) { setStatus(st, "Choose a file first.", "bad"); return; } - - const pass = $("encPass").value; - const conf = $("encConfirm").value; - if (!pass) { setStatus(st, "Enter a passphrase or generate one.", "bad"); return; } - if (pass !== conf) { setStatus(st, "Passphrase confirmation does not match.", "bad"); return; } +function bindEncryption() { + button("doEncrypt").addEventListener("click", async () => { + const action = button("doEncrypt"); + const status = element("encStatus"); + const progressBar = progress("encProgress"); + const progressText = element("encProgressText"); + setProgress(progressBar, progressText, 0, ""); + + const file = input("encFile").files?.[0]; + if (!file) { + setStatus(status, "Choose a file first.", "bad"); + return; + } + if (file.size > LIMITS.maxPlaintextSize) { + setStatus(status, "The selected file exceeds the 64 MiB browser safety limit.", "bad"); + return; + } - const levelKey = $("encLevel").value; + let passphrase; + try { + passphrase = validateNewPassphrase(input("encPass").value, wordSet); + } catch (error) { + setStatus(status, error instanceof Error ? error.message : "Passphrase is invalid.", "bad"); + return; + } + const confirmation = input("encConfirm").value.normalize("NFC"); + if (passphrase !== confirmation) { + setStatus(status, "Passphrase confirmation does not match.", "bad"); + return; + } + const levelKey = /** @type {keyof typeof LEVELS} */ (select("encLevel").value); try { - btn.disabled = true; - setStatus(st, "Encrypting locally...", "info"); - const { bytes } = await encryptFileV2Chunked( - f, - pass, + action.disabled = true; + setStatus(status, "Encrypting locally. The file and passphrase are not transmitted.", "info"); + const result = await encryptBlobV3(file, passphrase, { + name: file.name, + type: file.type, levelKey, - (pct, msg) => setProgress(pct, msg) + onProgress: (percent, message) => setProgress(progressBar, progressText, percent, message), + }); + downloadBlob(result.blob, `${result.metadata.name}.blindcrypt`); + input("encConfirm").value = ""; + setProgress(progressBar, progressText, 100, "100.0%"); + setStatus( + status, + "Authenticated format v3 file created. Share the passphrase through a separate channel.", + "good", ); - - const safeName = (f.name && f.name.trim().length) ? f.name.trim() : "file"; - downloadBytes(bytes, `${safeName}.blindcrypt`); - - setProgress(100, "100.0%"); - setStatus(st, "Encrypted file downloaded. Share it and share the passphrase out of band.", "good"); - } catch (e) { - setProgress(0, ""); - setStatus(st, `Encryption failed: ${e?.message || String(e)}`, "bad"); + } catch (error) { + setProgress(progressBar, progressText, 0, ""); + setStatus(status, encryptionErrorMessage(error), "bad"); } finally { - btn.disabled = false; + action.disabled = false; } }); +} - $("doDecrypt").addEventListener("click", async () => { - const st = $("decStatus"); - const btn = $("doDecrypt"); - setStatus(st, "", "info"); - - $("metaName").textContent = "-"; - $("metaType").textContent = "-"; - $("metaIter").textContent = "-"; - - const f = $("decFile").files?.[0]; - if (!f) { setStatus(st, "Choose an encrypted file first.", "bad"); return; } +function resetDecryptionMetadata() { + element("metaFormat").textContent = "-"; + element("metaName").textContent = "-"; + element("metaType").textContent = "-"; + element("metaIntegrity").textContent = "-"; +} - const pass = $("decPass").value; - if (!pass) { setStatus(st, "Enter the passphrase.", "bad"); return; } +function bindDecryption() { + button("doDecrypt").addEventListener("click", async () => { + const action = button("doDecrypt"); + const status = element("decStatus"); + const progressBar = progress("decProgress"); + const progressText = element("decProgressText"); + resetDecryptionMetadata(); + setProgress(progressBar, progressText, 0, ""); + + const file = input("decFile").files?.[0]; + if (!file) { + setStatus(status, "Choose an encrypted file first.", "bad"); + return; + } + if (file.size > LIMITS.maxContainerSize) { + setStatus(status, "The encrypted file exceeds the supported browser safety limit.", "bad"); + return; + } + const passphrase = input("decPass").value; + if (!passphrase) { + setStatus(status, "Enter the passphrase.", "bad"); + return; + } try { - btn.disabled = true; - setStatus(st, "Decrypting locally...", "info"); - - const bytes = new Uint8Array(await f.arrayBuffer()); - const { plain, header } = await decryptFileAny( - bytes, - pass, - () => {} + action.disabled = true; + setStatus(status, "Decrypting locally. No file data is transmitted.", "info"); + const result = await decryptBlobAny( + file, + passphrase, + (percent, message) => setProgress(progressBar, progressText, percent, message), ); - - $("metaName").textContent = header.name || "file"; - $("metaType").textContent = header.type || "application/octet-stream"; - $("metaIter").textContent = String(header.iter || "-"); - - const outName = header.name || "decrypted.bin"; - const blob = new Blob([plain], { type: header.type || "application/octet-stream" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = outName; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 2000); - - setStatus(st, "Decryption complete. Download started.", "good"); - } catch (e) { - setStatus(st, "Decryption failed. Wrong passphrase or file was modified.", "bad"); + element("metaFormat").textContent = `v${result.formatVersion}`; + element("metaName").textContent = result.metadata.name; + element("metaType").textContent = result.metadata.type; + element("metaIntegrity").textContent = result.authenticatedMetadata + ? "Header, metadata, and every record authenticated" + : "Legacy limitations apply"; + + const outputName = result.authenticatedMetadata ? result.metadata.name : "legacy-decrypted.bin"; + downloadBlob(result.blob, outputName); + input("decPass").value = ""; + input("decPass").type = "password"; + button("decShow").textContent = "Show"; + setProgress(progressBar, progressText, 100, "100.0%"); + setStatus( + status, + result.legacyWarning || "Decryption complete. Authenticated output download started.", + result.legacyWarning ? "warn" : "good", + ); + } catch (error) { + setProgress(progressBar, progressText, 0, ""); + setStatus(status, decryptionErrorMessage(error), "bad"); } finally { - btn.disabled = false; + action.disabled = false; } }); } -bindUI(); +function initialize() { + document.documentElement.dataset.version = APP_VERSION; + for (const versionElement of document.querySelectorAll("[data-app-version]")) { + versionElement.textContent = APP_VERSION; + } + element("maxFileSize").textContent = `${LIMITS.maxPlaintextSize / (1024 * 1024)} MiB`; + bindTabs(); + bindPasswordVisibility(); + bindPassphraseControls(); + bindEncryption(); + bindDecryption(); + updateLevelLabel(); + updatePassphraseAssessment(); + resetDecryptionMetadata(); + setTab("encrypt"); +} + +initialize(); diff --git a/assets/crypto-core.js b/assets/crypto-core.js new file mode 100644 index 0000000..8a28712 --- /dev/null +++ b/assets/crypto-core.js @@ -0,0 +1,357 @@ +// @ts-check + +/** BlindCrypt application version. */ +export const APP_VERSION = "01.01.01"; + +/** New files are written with format v3. */ +export const FORMAT_VERSION = 3; + +/** 512 KiB plaintext records. */ +export const CHUNK_SIZE = 512 * 1024; + +/** Fixed encrypted metadata plaintext length. */ +export const METADATA_BLOCK_SIZE = 1024; + +/** Browser-only safety ceiling. */ +export const MAX_PLAINTEXT_SIZE = 64 * 1024 * 1024; + +/** Maximum accepted public header length. */ +export const MAX_HEADER_SIZE = 4096; + +/** Maximum passphrase size after UTF-8 encoding. */ +export const MAX_PASSPHRASE_BYTES = 1024; + +export const GCM_TAG_BYTES = 16; +export const MIN_V3_ITERATIONS = 600_000; +export const MAX_KDF_ITERATIONS = 2_400_000; +export const MIN_LEGACY_ITERATIONS = 10_000; +export const MAX_METADATA_JSON_BYTES = METADATA_BLOCK_SIZE - 4; +export const MAX_CHUNKS = Math.ceil(MAX_PLAINTEXT_SIZE / CHUNK_SIZE); +export const MAX_CONTAINER_SIZE = + 8 + + MAX_HEADER_SIZE + + METADATA_BLOCK_SIZE + + GCM_TAG_BYTES + + MAX_PLAINTEXT_SIZE + + MAX_CHUNKS * GCM_TAG_BYTES; + +export const MAGIC = Uint8Array.of(0x42, 0x43, 0x30, 0x33); // BC03 +const RECORD_DOMAIN = new TextEncoder().encode("BlindCrypt-v3\0"); +export const textEncoder = new TextEncoder(); +const strictDecoder = new TextDecoder("utf-8", { fatal: true }); + +export const LEVELS = Object.freeze({ + standard: Object.freeze({ iterations: 600_000, words: 6 }), + strong: Object.freeze({ iterations: 900_000, words: 8 }), + high: Object.freeze({ iterations: 1_200_000, words: 10 }), + critical: Object.freeze({ iterations: 2_400_000, words: 16 }), +}); + +export class BlindCryptError extends Error { + /** + * @param {string} code + * @param {string} message + */ + constructor(code, message) { + super(message); + this.name = "BlindCryptError"; + this.code = code; + } +} + +/** @param {unknown} value @returns {value is Record} */ +export function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** + * @param {unknown} value + * @param {string[]} expected + */ +export function hasExactKeys(value, expected) { + if (!isPlainObject(value)) return false; + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +/** @param {unknown} value @param {string} label */ +export function assertSafeInteger(value, label) { + if (!Number.isSafeInteger(value)) { + throw new BlindCryptError("INVALID_FORMAT", `${label} must be a safe integer`); + } + return /** @type {number} */ (value); +} + +export function requireWebCrypto() { + if (!globalThis.crypto?.subtle || typeof globalThis.crypto.getRandomValues !== "function") { + throw new BlindCryptError("CRYPTO_UNAVAILABLE", "WebCrypto is unavailable"); + } +} + +/** @param {number} length */ +export function randomBytes(length) { + requireWebCrypto(); + const out = new Uint8Array(length); + globalThis.crypto.getRandomValues(out); + return out; +} + +/** @param {number} value */ +export function u32be(value) { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new BlindCryptError("INVALID_FORMAT", "Unsigned 32-bit value out of range"); + } + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value, false); + return out; +} + +/** @param {Uint8Array} bytes @param {number} offset */ +export function readU32be(bytes, offset) { + if (!Number.isInteger(offset) || offset < 0 || offset + 4 > bytes.length) { + throw new BlindCryptError("INVALID_FORMAT", "Unable to read unsigned 32-bit value"); + } + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, false); +} + +/** @param {...Uint8Array} parts */ +export function concatBytes(...parts) { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} + +/** @param {Uint8Array} bytes */ +export function base64urlEncode(bytes) { + let binary = ""; + const stride = 0x8000; + for (let offset = 0; offset < bytes.length; offset += stride) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + stride)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +/** @param {unknown} value @param {number} expectedLength @param {string} label */ +export function base64urlDecodeStrict(value, expectedLength, label) { + if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/.test(value) || value.includes("=")) { + throw new BlindCryptError("INVALID_FORMAT", `${label} is not canonical base64url`); + } + const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)); + let binary; + try { + binary = atob(normalized + padding); + } catch { + throw new BlindCryptError("INVALID_FORMAT", `${label} is invalid base64url`); + } + const out = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + out[index] = binary.charCodeAt(index); + } + if (out.length !== expectedLength || base64urlEncode(out) !== value) { + throw new BlindCryptError("INVALID_FORMAT", `${label} has an invalid length or encoding`); + } + return out; +} + +/** @param {Blob} blob @param {number} start @param {number} end */ +export async function readBlobSlice(blob, start, end) { + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > blob.size) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid file slice"); + } + return new Uint8Array(await blob.slice(start, end).arrayBuffer()); +} + +/** @param {Uint8Array} bytes @param {string} label */ +export function decodeUtf8Strict(bytes, label) { + try { + return strictDecoder.decode(bytes); + } catch { + throw new BlindCryptError("INVALID_FORMAT", `${label} is not valid UTF-8`); + } +} + +/** @param {Uint8Array} bytes @param {string} label */ +export function parseCanonicalJson(bytes, label) { + const text = decodeUtf8Strict(bytes, label); + let value; + try { + value = JSON.parse(text); + } catch { + throw new BlindCryptError("INVALID_FORMAT", `${label} is not valid JSON`); + } + if (!isPlainObject(value) || JSON.stringify(value) !== text) { + throw new BlindCryptError("INVALID_FORMAT", `${label} is not canonical JSON`); + } + return /** @type {Record} */ (value); +} + +/** @param {string} value */ +export function sanitizeFilename(value) { + let name = String(value || "file").normalize("NFC"); + name = name + .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "") + .replace(/[<>:"/\\|?*]/gu, "_") + .replace(/\.{2,}/gu, "_") + .replace(/_+/gu, "_") + .replace(/\s+/gu, " ") + .trim() + .replace(/^[. ]+/u, "") + .replace(/[. ]+$/u, ""); + + if (!name || name === "." || name === "..") name = "file"; + + const reserved = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu; + if (reserved.test(name)) name = `_${name}`; + + const codePoints = [...name]; + if (codePoints.length > 180) name = codePoints.slice(0, 180).join(""); + return name || "file"; +} + +/** @param {string} value */ +export function sanitizeMimeType(value) { + const type = String(value || "").trim().toLowerCase(); + if ( + type.length <= 129 && + /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/u.test(type) + ) { + return type; + } + return "application/octet-stream"; +} + +/** @param {string} passphrase @param {boolean} normalize */ +export function encodePassphrase(passphrase, normalize) { + if (typeof passphrase !== "string") { + throw new BlindCryptError("INVALID_PASSPHRASE", "Passphrase must be text"); + } + const value = normalize ? passphrase.normalize("NFC") : passphrase; + const bytes = textEncoder.encode(value); + if (bytes.length < 1 || bytes.length > MAX_PASSPHRASE_BYTES) { + throw new BlindCryptError("INVALID_PASSPHRASE", "Passphrase length is outside the supported range"); + } + return bytes; +} + +/** + * @param {string} passphrase + * @param {Uint8Array} salt + * @param {number} iterations + * @param {boolean} normalize + */ +export async function deriveKey(passphrase, salt, iterations, normalize) { + requireWebCrypto(); + const passphraseBytes = encodePassphrase(passphrase, normalize); + try { + const baseKey = await globalThis.crypto.subtle.importKey( + "raw", + passphraseBytes, + "PBKDF2", + false, + ["deriveKey"], + ); + return await globalThis.crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + baseKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + } finally { + passphraseBytes.fill(0); + } +} + +/** @param {Uint8Array} ivPrefix @param {number} counter */ +export function makeRecordIv(ivPrefix, counter) { + if (ivPrefix.length !== 8 || !Number.isInteger(counter) || counter < 0 || counter > 0xffff_ffff) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid record IV parameters"); + } + return concatBytes(ivPrefix, u32be(counter)); +} + +/** + * @param {Uint8Array} headerFrame + * @param {number} recordType + * @param {number} index + * @param {number} plainLength + */ +export function makeRecordAad(headerFrame, recordType, index, plainLength) { + if (recordType !== 0 && recordType !== 1) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid record type"); + } + return concatBytes( + RECORD_DOMAIN, + headerFrame, + Uint8Array.of(recordType), + u32be(index), + u32be(plainLength), + ); +} + +/** @param {string} name @param {string} type */ +export function createMetadataBlock(name, type) { + const metadata = { + name: sanitizeFilename(name), + type: sanitizeMimeType(type), + writer: APP_VERSION, + }; + const json = textEncoder.encode(JSON.stringify(metadata)); + if (json.length > MAX_METADATA_JSON_BYTES) { + throw new BlindCryptError("INVALID_METADATA", "Encrypted metadata is too large"); + } + const block = randomBytes(METADATA_BLOCK_SIZE); + block.set(u32be(json.length), 0); + block.set(json, 4); + return { block, metadata }; +} + +/** @param {Uint8Array} block */ +export function parseMetadataBlock(block) { + if (block.length !== METADATA_BLOCK_SIZE) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid encrypted metadata length"); + } + const jsonLength = readU32be(block, 0); + if (jsonLength < 2 || jsonLength > MAX_METADATA_JSON_BYTES) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid encrypted metadata JSON length"); + } + const metadata = parseCanonicalJson(block.subarray(4, 4 + jsonLength), "Encrypted metadata"); + if (!hasExactKeys(metadata, ["name", "type", "writer"])) { + throw new BlindCryptError("INVALID_FORMAT", "Encrypted metadata fields are invalid"); + } + const name = metadata.name; + const type = metadata.type; + const writer = metadata.writer; + if ( + typeof name !== "string" || + typeof type !== "string" || + typeof writer !== "string" || + !/^\d{2}\.\d{2}\.\d{2}$/u.test(writer) || + sanitizeFilename(name) !== name || + sanitizeMimeType(type) !== type + ) { + throw new BlindCryptError("INVALID_FORMAT", "Encrypted metadata values are invalid"); + } + return { name, type, writer }; +} + +export const LIMITS = Object.freeze({ + maxPlaintextSize: MAX_PLAINTEXT_SIZE, + maxContainerSize: MAX_CONTAINER_SIZE, + maxHeaderSize: MAX_HEADER_SIZE, + maxPassphraseBytes: MAX_PASSPHRASE_BYTES, + minV3Iterations: MIN_V3_ITERATIONS, + maxKdfIterations: MAX_KDF_ITERATIONS, +}); diff --git a/assets/crypto-legacy.js b/assets/crypto-legacy.js new file mode 100644 index 0000000..0b9c51a --- /dev/null +++ b/assets/crypto-legacy.js @@ -0,0 +1,185 @@ +// @ts-check + +import { + CHUNK_SIZE, + MAX_PLAINTEXT_SIZE, + MAX_HEADER_SIZE, + GCM_TAG_BYTES, + MIN_LEGACY_ITERATIONS, + MAX_KDF_ITERATIONS, + MAX_CONTAINER_SIZE, + BlindCryptError, + isPlainObject, + assertSafeInteger, + base64urlDecodeStrict, + readBlobSlice, + readU32be, + decodeUtf8Strict, + deriveKey, + sanitizeFilename, + sanitizeMimeType, + u32be, +} from "./crypto-core.js"; + +/** @param {Record} header */ +function validateLegacyCommon(header) { + if (header.v !== 1 && header.v !== 2) { + throw new BlindCryptError("UNSUPPORTED_VERSION", "Unsupported BlindCrypt format version"); + } + const iterations = assertSafeInteger(header.iter, "KDF iterations"); + if (iterations < MIN_LEGACY_ITERATIONS || iterations > MAX_KDF_ITERATIONS) { + throw new BlindCryptError("INVALID_KDF", "Legacy KDF iteration count is outside the supported range"); + } + if (header.kdf !== undefined && header.kdf !== "PBKDF2") { + throw new BlindCryptError("INVALID_FORMAT", "Unsupported legacy KDF"); + } + if (header.hash !== undefined && header.hash !== "SHA-256") { + throw new BlindCryptError("INVALID_FORMAT", "Unsupported legacy hash"); + } + if (header.alg !== undefined && header.alg !== "AES-256-GCM") { + throw new BlindCryptError("INVALID_FORMAT", "Unsupported legacy cipher"); + } + const salt = base64urlDecodeStrict(header.salt, 16, "Legacy salt"); + const iv = base64urlDecodeStrict(header.iv, 12, "Legacy IV"); + return { iterations, salt, iv }; +} + +/** @param {Record} header */ +function legacyMetadata(header) { + return { + name: sanitizeFilename(typeof header.name === "string" ? header.name : "legacy-decrypted.bin"), + type: sanitizeMimeType(typeof header.type === "string" ? header.type : "application/octet-stream"), + writer: "legacy", + }; +} + +/** + * @param {Blob} source + * @param {string} passphrase + * @param {(percent: number, message: string) => void} [onProgress] + */ +export async function decryptLegacy(source, passphrase, onProgress) { + if (source.size < 5 || source.size > MAX_CONTAINER_SIZE) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy file size is invalid"); + } + const prefix = await readBlobSlice(source, 0, 4); + const headerLength = readU32be(prefix, 0); + if (headerLength < 2 || headerLength > MAX_HEADER_SIZE || 4 + headerLength > source.size) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy header length is invalid"); + } + const headerBytes = await readBlobSlice(source, 4, 4 + headerLength); + let header; + try { + header = JSON.parse(decodeUtf8Strict(headerBytes, "Legacy header")); + } catch (error) { + if (error instanceof BlindCryptError) throw error; + throw new BlindCryptError("INVALID_FORMAT", "Legacy header is not valid JSON"); + } + if (!isPlainObject(header)) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy header is invalid"); + } + const legacy = validateLegacyCommon(/** @type {Record} */ (header)); + const headerEnd = 4 + headerLength; + + if (header.v === 1) { + const cipherLength = source.size - headerEnd; + if (cipherLength < GCM_TAG_BYTES || cipherLength > MAX_PLAINTEXT_SIZE + GCM_TAG_BYTES) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy v1 ciphertext length is invalid"); + } + onProgress?.(1, "Deriving legacy key"); + const key = await deriveKey(passphrase, legacy.salt, legacy.iterations, false); + const cipher = await readBlobSlice(source, headerEnd, source.size); + try { + const plain = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv: legacy.iv, tagLength: 128 }, + key, + cipher, + ), + ); + if (plain.length > MAX_PLAINTEXT_SIZE) { + throw new BlindCryptError("FILE_TOO_LARGE", "Legacy plaintext exceeds the safety limit"); + } + onProgress?.(100, "100.0%"); + return { + blob: new Blob([plain], { type: "application/octet-stream" }), + metadata: legacyMetadata(/** @type {Record} */ (header)), + formatVersion: 1, + authenticatedMetadata: false, + legacyWarning: + "Legacy v1 metadata and whole-file structure are not authenticated. The download uses a neutral filename and MIME type.", + publicHeader: header, + }; + } catch (error) { + if (error instanceof BlindCryptError) throw error; + throw new BlindCryptError("AUTHENTICATION_FAILED", "Passphrase is wrong or the legacy file was modified"); + } + } + + const size = assertSafeInteger(header.size, "Legacy plaintext size"); + const chunkSize = assertSafeInteger(header.chunk, "Legacy chunk size"); + const chunks = assertSafeInteger(header.chunks, "Legacy chunk count"); + const last = assertSafeInteger(header.last, "Legacy final chunk size"); + if (size < 0 || size > MAX_PLAINTEXT_SIZE || chunkSize !== CHUNK_SIZE) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy v2 geometry is outside supported bounds"); + } + const expectedChunks = Math.max(1, Math.ceil(size / chunkSize)); + const expectedLast = size - (expectedChunks - 1) * chunkSize; + if (chunks !== expectedChunks || last !== expectedLast || last < 0 || last > chunkSize) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy v2 record geometry is inconsistent"); + } + const expectedCipherLength = size + chunks * GCM_TAG_BYTES; + if (source.size - headerEnd !== expectedCipherLength) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy v2 has trailing, missing, or inconsistent data"); + } + + onProgress?.(1, "Deriving legacy key"); + const key = await deriveKey(passphrase, legacy.salt, legacy.iterations, false); + /** @type {BlobPart[]} */ + const plainParts = []; + let offset = headerEnd; + + for (let index = 0; index < chunks; index += 1) { + const plainLength = index === chunks - 1 ? last : chunkSize; + const cipherLength = plainLength + GCM_TAG_BYTES; + const cipher = await readBlobSlice(source, offset, offset + cipherLength); + offset += cipherLength; + const iv = new Uint8Array(12); + iv.set(legacy.iv.subarray(0, 8), 0); + iv.set(u32be(index), 8); + try { + const plain = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv, tagLength: 128 }, + key, + cipher, + ), + ); + if (plain.length !== plainLength) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy decrypted record length is invalid"); + } + plainParts.push(plain); + } catch (error) { + if (error instanceof BlindCryptError) throw error; + throw new BlindCryptError("AUTHENTICATION_FAILED", "Passphrase is wrong or the legacy file was modified"); + } + const percent = 5 + ((index + 1) / chunks) * 95; + onProgress?.(percent, `${percent.toFixed(1)}%`); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + if (offset !== source.size) { + throw new BlindCryptError("INVALID_FORMAT", "Legacy v2 has trailing data"); + } + onProgress?.(100, "100.0%"); + return { + blob: new Blob(plainParts, { type: "application/octet-stream" }), + metadata: legacyMetadata(/** @type {Record} */ (header)), + formatVersion: 2, + authenticatedMetadata: false, + legacyWarning: + "Legacy v2 authenticates records separately but not its metadata or whole-file completeness. The download uses a neutral filename and MIME type.", + publicHeader: header, + }; +} + diff --git a/assets/crypto-v3.js b/assets/crypto-v3.js new file mode 100644 index 0000000..11aaa02 --- /dev/null +++ b/assets/crypto-v3.js @@ -0,0 +1,324 @@ +// @ts-check + +import { + APP_VERSION, + FORMAT_VERSION, + CHUNK_SIZE, + METADATA_BLOCK_SIZE, + MAX_PLAINTEXT_SIZE, + MAX_HEADER_SIZE, + GCM_TAG_BYTES, + MIN_V3_ITERATIONS, + MAX_KDF_ITERATIONS, + MAX_CHUNKS, + MAX_CONTAINER_SIZE, + MAGIC, + LEVELS, + BlindCryptError, + hasExactKeys, + assertSafeInteger, + requireWebCrypto, + randomBytes, + u32be, + readU32be, + concatBytes, + base64urlEncode, + base64urlDecodeStrict, + readBlobSlice, + parseCanonicalJson, + textEncoder, + deriveKey, + makeRecordIv, + makeRecordAad, + createMetadataBlock, + parseMetadataBlock, +} from "./crypto-core.js"; + +/** + * @typedef {object} EncryptOptions + * @property {string} name + * @property {string} type + * @property {keyof typeof LEVELS} levelKey + * @property {(percent: number, message: string) => void} [onProgress] + */ + +/** + * Encrypt a Blob into the authenticated v3 container format. + * @param {Blob} source + * @param {string} passphrase + * @param {EncryptOptions} options + */ +export async function encryptBlobV3(source, passphrase, options) { + requireWebCrypto(); + if (!(source instanceof Blob)) { + throw new BlindCryptError("INVALID_INPUT", "Source must be a Blob"); + } + if (!Number.isSafeInteger(source.size) || source.size < 0 || source.size > MAX_PLAINTEXT_SIZE) { + throw new BlindCryptError("FILE_TOO_LARGE", "File exceeds the 64 MiB browser safety limit"); + } + const level = LEVELS[options.levelKey]; + if (!level) { + throw new BlindCryptError("INVALID_KDF", "Unknown security level"); + } + + const chunks = source.size === 0 ? 0 : Math.ceil(source.size / CHUNK_SIZE); + const last = source.size === 0 ? 0 : source.size - (chunks - 1) * CHUNK_SIZE; + if (chunks > MAX_CHUNKS) { + throw new BlindCryptError("FILE_TOO_LARGE", "File has too many records"); + } + + const salt = randomBytes(16); + const ivPrefix = randomBytes(8); + const header = { + v: FORMAT_VERSION, + mode: "chunked-aesgcm-aad", + kdf: "PBKDF2", + hash: "SHA-256", + iter: level.iterations, + alg: "AES-256-GCM", + salt: base64urlEncode(salt), + iv: base64urlEncode(ivPrefix), + size: source.size, + chunk: CHUNK_SIZE, + chunks, + last, + meta: METADATA_BLOCK_SIZE, + norm: "NFC", + writer: APP_VERSION, + }; + const headerBytes = textEncoder.encode(JSON.stringify(header)); + if (headerBytes.length > MAX_HEADER_SIZE) { + throw new BlindCryptError("INVALID_FORMAT", "Generated header is too large"); + } + const headerFrame = concatBytes(MAGIC, u32be(headerBytes.length), headerBytes); + + options.onProgress?.(1, "Deriving key"); + const key = await deriveKey(passphrase, salt, level.iterations, true); + const { block: metadataBlock, metadata } = createMetadataBlock(options.name, options.type); + /** @type {BlobPart[]} */ + const outputParts = [headerFrame]; + + try { + const metadataCipher = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: makeRecordIv(ivPrefix, 0), + additionalData: makeRecordAad(headerFrame, 0, 0, METADATA_BLOCK_SIZE), + tagLength: 128, + }, + key, + metadataBlock, + ), + ); + outputParts.push(metadataCipher); + options.onProgress?.(7, "Encrypting file"); + + for (let index = 0; index < chunks; index += 1) { + const start = index * CHUNK_SIZE; + const end = Math.min(source.size, start + CHUNK_SIZE); + const plain = new Uint8Array(await source.slice(start, end).arrayBuffer()); + try { + const cipher = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: makeRecordIv(ivPrefix, index + 1), + additionalData: makeRecordAad(headerFrame, 1, index, plain.length), + tagLength: 128, + }, + key, + plain, + ), + ); + outputParts.push(cipher); + } finally { + plain.fill(0); + } + const percent = 7 + ((index + 1) / Math.max(1, chunks)) * 93; + options.onProgress?.(percent, `${percent.toFixed(1)}%`); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } finally { + metadataBlock.fill(0); + } + + options.onProgress?.(100, "100.0%"); + return { + blob: new Blob(outputParts, { type: "application/octet-stream" }), + header, + metadata, + }; +} + +/** @param {Record} header */ +function validateV3Header(header) { + const keys = [ + "v", + "mode", + "kdf", + "hash", + "iter", + "alg", + "salt", + "iv", + "size", + "chunk", + "chunks", + "last", + "meta", + "norm", + "writer", + ]; + if (!hasExactKeys(header, keys)) { + throw new BlindCryptError("INVALID_FORMAT", "Public header fields are invalid"); + } + if ( + header.v !== FORMAT_VERSION || + header.mode !== "chunked-aesgcm-aad" || + header.kdf !== "PBKDF2" || + header.hash !== "SHA-256" || + header.alg !== "AES-256-GCM" || + header.chunk !== CHUNK_SIZE || + header.meta !== METADATA_BLOCK_SIZE || + header.norm !== "NFC" || + typeof header.writer !== "string" || + !/^\d{2}\.\d{2}\.\d{2}$/u.test(header.writer) + ) { + throw new BlindCryptError("INVALID_FORMAT", "Public header constants are invalid"); + } + + const iterations = assertSafeInteger(header.iter, "KDF iterations"); + const size = assertSafeInteger(header.size, "Plaintext size"); + const chunks = assertSafeInteger(header.chunks, "Record count"); + const last = assertSafeInteger(header.last, "Final record size"); + + if (iterations < MIN_V3_ITERATIONS || iterations > MAX_KDF_ITERATIONS) { + throw new BlindCryptError("INVALID_KDF", "KDF iteration count is outside the supported range"); + } + if (size < 0 || size > MAX_PLAINTEXT_SIZE) { + throw new BlindCryptError("FILE_TOO_LARGE", "Declared plaintext size exceeds the safety limit"); + } + const expectedChunks = size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE); + const expectedLast = size === 0 ? 0 : size - (expectedChunks - 1) * CHUNK_SIZE; + if (chunks !== expectedChunks || last !== expectedLast || chunks > MAX_CHUNKS) { + throw new BlindCryptError("INVALID_FORMAT", "Record geometry is inconsistent"); + } + + const salt = base64urlDecodeStrict(header.salt, 16, "Salt"); + const ivPrefix = base64urlDecodeStrict(header.iv, 8, "IV prefix"); + return { iterations, size, chunks, last, salt, ivPrefix }; +} + +/** + * @param {Blob} source + * @param {string} passphrase + * @param {(percent: number, message: string) => void} [onProgress] + */ +export async function decryptV3(source, passphrase, onProgress) { + if (source.size < 8 + METADATA_BLOCK_SIZE + GCM_TAG_BYTES) { + throw new BlindCryptError("INVALID_FORMAT", "File is too small for format v3"); + } + const prefix = await readBlobSlice(source, 0, 8); + if (!MAGIC.every((value, index) => prefix[index] === value)) { + throw new BlindCryptError("INVALID_FORMAT", "Invalid format v3 magic"); + } + const headerLength = readU32be(prefix, 4); + if (headerLength < 2 || headerLength > MAX_HEADER_SIZE || 8 + headerLength > source.size) { + throw new BlindCryptError("INVALID_FORMAT", "Public header length is invalid"); + } + const headerBytes = await readBlobSlice(source, 8, 8 + headerLength); + const header = parseCanonicalJson(headerBytes, "Public header"); + const geometry = validateV3Header(header); + const headerFrame = concatBytes(prefix, headerBytes); + + const expectedSize = + headerFrame.length + + METADATA_BLOCK_SIZE + + GCM_TAG_BYTES + + geometry.size + + geometry.chunks * GCM_TAG_BYTES; + if (!Number.isSafeInteger(expectedSize) || source.size !== expectedSize || source.size > MAX_CONTAINER_SIZE) { + throw new BlindCryptError("INVALID_FORMAT", "Container length does not match its authenticated geometry"); + } + + onProgress?.(1, "Deriving key"); + const key = await deriveKey(passphrase, geometry.salt, geometry.iterations, true); + let offset = headerFrame.length; + const metadataCipherLength = METADATA_BLOCK_SIZE + GCM_TAG_BYTES; + const metadataCipher = await readBlobSlice(source, offset, offset + metadataCipherLength); + offset += metadataCipherLength; + + let metadataPlain; + try { + metadataPlain = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: makeRecordIv(geometry.ivPrefix, 0), + additionalData: makeRecordAad(headerFrame, 0, 0, METADATA_BLOCK_SIZE), + tagLength: 128, + }, + key, + metadataCipher, + ), + ); + } catch { + throw new BlindCryptError("AUTHENTICATION_FAILED", "Passphrase is wrong or the file was modified"); + } + + let metadata; + try { + metadata = parseMetadataBlock(metadataPlain); + } finally { + metadataPlain.fill(0); + } + /** @type {BlobPart[]} */ + const plainParts = []; + onProgress?.(7, "Decrypting file"); + + for (let index = 0; index < geometry.chunks; index += 1) { + const plainLength = index === geometry.chunks - 1 ? geometry.last : CHUNK_SIZE; + const cipherLength = plainLength + GCM_TAG_BYTES; + const cipher = await readBlobSlice(source, offset, offset + cipherLength); + offset += cipherLength; + try { + const plain = new Uint8Array( + await globalThis.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: makeRecordIv(geometry.ivPrefix, index + 1), + additionalData: makeRecordAad(headerFrame, 1, index, plainLength), + tagLength: 128, + }, + key, + cipher, + ), + ); + if (plain.length !== plainLength) { + throw new BlindCryptError("INVALID_FORMAT", "Decrypted record length is invalid"); + } + plainParts.push(plain); + } catch (error) { + if (error instanceof BlindCryptError) throw error; + throw new BlindCryptError("AUTHENTICATION_FAILED", "Passphrase is wrong or the file was modified"); + } + const percent = 7 + ((index + 1) / Math.max(1, geometry.chunks)) * 93; + onProgress?.(percent, `${percent.toFixed(1)}%`); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + if (offset !== source.size) { + throw new BlindCryptError("INVALID_FORMAT", "Container has trailing or missing data"); + } + onProgress?.(100, "100.0%"); + return { + blob: new Blob(plainParts, { type: "application/octet-stream" }), + metadata, + formatVersion: FORMAT_VERSION, + authenticatedMetadata: true, + legacyWarning: null, + publicHeader: header, + }; +} + diff --git a/assets/crypto.js b/assets/crypto.js new file mode 100644 index 0000000..aa620aa --- /dev/null +++ b/assets/crypto.js @@ -0,0 +1,35 @@ +// @ts-check + +import { + BlindCryptError, + MAGIC, + MAX_CONTAINER_SIZE, + requireWebCrypto, + readBlobSlice, +} from "./crypto-core.js"; +import { decryptLegacy } from "./crypto-legacy.js"; +import { decryptV3 } from "./crypto-v3.js"; + +export * from "./crypto-core.js"; +export { encryptBlobV3 } from "./crypto-v3.js"; + +/** + * Decrypt v3 or read-compatible legacy v1/v2 containers. + * @param {Blob} source + * @param {string} passphrase + * @param {(percent: number, message: string) => void} [onProgress] + */ +export async function decryptBlobAny(source, passphrase, onProgress) { + requireWebCrypto(); + if (!(source instanceof Blob)) { + throw new BlindCryptError("INVALID_INPUT", "Source must be a Blob"); + } + if (!Number.isSafeInteger(source.size) || source.size < 5 || source.size > MAX_CONTAINER_SIZE) { + throw new BlindCryptError("FILE_TOO_LARGE", "Encrypted file is outside the supported safety limit"); + } + const firstFour = await readBlobSlice(source, 0, 4); + const isV3 = MAGIC.every((value, index) => firstFour[index] === value); + return isV3 + ? decryptV3(source, passphrase, onProgress) + : decryptLegacy(source, passphrase, onProgress); +} diff --git a/assets/passphrase.js b/assets/passphrase.js new file mode 100644 index 0000000..5b5b0e3 --- /dev/null +++ b/assets/passphrase.js @@ -0,0 +1,179 @@ +// @ts-check + +import { MAX_PASSPHRASE_BYTES } from "./crypto.js"; + +export const MIN_GENERATED_WORDS = 6; +export const MAX_GENERATED_WORDS = 16; +export const MIN_CUSTOM_CODEPOINTS = 16; +export const MIN_CUSTOM_UNIQUE_CODEPOINTS = 6; + +/** @param {unknown} words */ +export function buildWordSet(words) { + if (!Array.isArray(words) || words.length !== 2048) { + throw new Error("The bundled passphrase word list must contain exactly 2048 words"); + } + const normalized = words.map((word) => { + if (typeof word !== "string" || !/^[a-z]+$/u.test(word)) { + throw new Error("The bundled passphrase word list contains an invalid word"); + } + return word; + }); + const unique = new Set(normalized); + if (unique.size !== 2048) { + throw new Error("The bundled passphrase word list contains duplicates"); + } + return unique; +} + +/** + * @param {string[]} words + * @param {number} count + */ +export function generatePassphrase(words, count) { + const wordSet = buildWordSet(words); + if ( + wordSet.size !== 2048 || + !Number.isInteger(count) || + count < MIN_GENERATED_WORDS || + count > MAX_GENERATED_WORDS + ) { + throw new Error(`Generated passphrases must contain ${MIN_GENERATED_WORDS}-${MAX_GENERATED_WORDS} words`); + } + if (!globalThis.crypto || typeof globalThis.crypto.getRandomValues !== "function") { + throw new Error("Secure randomness is unavailable"); + } + + const random = new Uint32Array(count); + globalThis.crypto.getRandomValues(random); + const selected = []; + for (let index = 0; index < count; index += 1) { + // 2048 divides 2^32, so this mapping has no modulo bias. + selected.push(words[random[index] & 2047]); + } + return selected.join(" "); +} + +/** @param {string[]} codePoints */ +function isRepeatedSequence(codePoints) { + const length = codePoints.length; + for (let period = 1; period <= Math.floor(length / 2); period += 1) { + if (length % period !== 0) continue; + let repeated = true; + for (let index = period; index < length; index += 1) { + if (codePoints[index] !== codePoints[index % period]) { + repeated = false; + break; + } + } + if (repeated) return true; + } + return false; +} + +/** + * @typedef {object} PassphraseAssessment + * @property {"empty" | "word-list" | "custom"} kind + * @property {boolean} accepted + * @property {number | null} bits + * @property {number} progress + * @property {string} label + * @property {string} text + * @property {string} normalized + */ + +/** + * @param {string} passphrase + * @param {Set} wordSet + * @returns {PassphraseAssessment} + */ +export function assessPassphrase(passphrase, wordSet) { + const normalized = String(passphrase || "").normalize("NFC"); + if (!normalized) { + return { + kind: "empty", + accepted: false, + bits: null, + progress: 0, + label: "Empty", + text: "Enter a passphrase or generate one.", + normalized, + }; + } + + const trimmed = normalized.trim(); + const parts = trimmed.split(/\s+/u).filter(Boolean); + const isWordListPhrase = + trimmed === normalized && + parts.length >= 2 && + parts.every((word) => wordSet.has(word.toLowerCase())); + + if (isWordListPhrase) { + const bits = 11 * parts.length; + const accepted = parts.length >= MIN_GENERATED_WORDS; + let label = "Below minimum"; + if (parts.length >= 16) label = "Critical"; + else if (parts.length >= 10) label = "High"; + else if (parts.length >= 8) label = "Strong"; + else if (parts.length >= 6) label = "Standard"; + + return { + kind: "word-list", + accepted, + bits, + progress: Math.min(100, (parts.length / MAX_GENERATED_WORDS) * 100), + label, + text: accepted + ? `${label}: ${parts.length} independently selected words, approximately ${bits} bits.` + : `${parts.length} word-list words. At least ${MIN_GENERATED_WORDS} are required.`, + normalized, + }; + } + + const codePointValues = [...normalized]; + const codePoints = codePointValues.length; + const noOuterWhitespace = normalized === normalized.trim(); + const sufficientlyVaried = new Set(codePointValues).size >= MIN_CUSTOM_UNIQUE_CODEPOINTS; + const repeatedSequence = isRepeatedSequence(codePointValues); + const accepted = + codePoints >= MIN_CUSTOM_CODEPOINTS && + noOuterWhitespace && + sufficientlyVaried && + !repeatedSequence; + + let text; + if (!noOuterWhitespace) { + text = "Leading or trailing whitespace is not allowed in new passphrases."; + } else if (codePoints < MIN_CUSTOM_CODEPOINTS) { + text = `Custom passphrases require at least ${MIN_CUSTOM_CODEPOINTS} characters. Generated word phrases are preferred.`; + } else if (!sufficientlyVaried || repeatedSequence) { + text = "Custom passphrase is too repetitive. Use a generated word phrase or a less predictable custom value."; + } else { + text = `Custom passphrase accepted at ${codePoints} characters. Strength is not estimated; generated word phrases are preferred.`; + } + + return { + kind: "custom", + accepted, + bits: null, + progress: 0, + label: "Custom", + text, + normalized, + }; +} + +/** + * @param {string} passphrase + * @param {Set} wordSet + */ +export function validateNewPassphrase(passphrase, wordSet) { + const assessment = assessPassphrase(passphrase, wordSet); + if (!assessment.accepted) { + throw new Error(assessment.text); + } + const encodedLength = new TextEncoder().encode(assessment.normalized).length; + if (encodedLength > MAX_PASSPHRASE_BYTES) { + throw new Error("Passphrase is too long"); + } + return assessment.normalized; +} diff --git a/assets/wordlist_2048.js b/assets/wordlist_2048.js deleted file mode 100644 index 4b7781b..0000000 --- a/assets/wordlist_2048.js +++ /dev/null @@ -1 +0,0 @@ -export const DICEWARE_2048 = ["word0001", "word0002", "word0003", "word0004", "word0005", "word0006", "word0007", "word0008", "word0009", "word0010", "word0011", "word0012", "word0013", "word0014", "word0015", "word0016", "word0017", "word0018", "word0019", "word0020", "word0021", "word0022", "word0023", "word0024", "word0025", "word0026", "word0027", "word0028", "word0029", "word0030", "word0031", "word0032", "word0033", "word0034", "word0035", "word0036", "word0037", "word0038", "word0039", "word0040", "word0041", "word0042", "word0043", "word0044", "word0045", "word0046", "word0047", "word0048", "word0049", "word0050", "word0051", "word0052", "word0053", "word0054", "word0055", "word0056", "word0057", "word0058", "word0059", "word0060", "word0061", "word0062", "word0063", "word0064", "word0065", "word0066", "word0067", "word0068", "word0069", "word0070", "word0071", "word0072", "word0073", "word0074", "word0075", "word0076", "word0077", "word0078", "word0079", "word0080", "word0081", "word0082", "word0083", "word0084", "word0085", "word0086", "word0087", "word0088", "word0089", "word0090", "word0091", "word0092", "word0093", "word0094", "word0095", "word0096", "word0097", "word0098", "word0099", "word0100", "word0101", "word0102", "word0103", "word0104", "word0105", "word0106", "word0107", "word0108", "word0109", "word0110", "word0111", "word0112", "word0113", "word0114", "word0115", "word0116", "word0117", "word0118", "word0119", "word0120", "word0121", "word0122", "word0123", "word0124", "word0125", "word0126", "word0127", "word0128", "word0129", "word0130", "word0131", "word0132", "word0133", "word0134", "word0135", "word0136", "word0137", "word0138", "word0139", "word0140", "word0141", "word0142", "word0143", "word0144", "word0145", "word0146", "word0147", "word0148", "word0149", "word0150", "word0151", "word0152", "word0153", "word0154", "word0155", "word0156", "word0157", "word0158", "word0159", "word0160", "word0161", "word0162", "word0163", "word0164", "word0165", "word0166", "word0167", "word0168", "word0169", "word0170", "word0171", "word0172", "word0173", "word0174", "word0175", "word0176", "word0177", "word0178", "word0179", "word0180", "word0181", "word0182", "word0183", "word0184", "word0185", "word0186", "word0187", "word0188", "word0189", "word0190", "word0191", "word0192", "word0193", "word0194", "word0195", "word0196", "word0197", "word0198", "word0199", "word0200", "word0201", "word0202", "word0203", "word0204", "word0205", "word0206", "word0207", "word0208", "word0209", "word0210", "word0211", "word0212", "word0213", "word0214", "word0215", "word0216", "word0217", "word0218", "word0219", "word0220", "word0221", "word0222", "word0223", "word0224", "word0225", "word0226", "word0227", "word0228", "word0229", "word0230", "word0231", "word0232", "word0233", "word0234", "word0235", "word0236", "word0237", "word0238", "word0239", "word0240", "word0241", "word0242", "word0243", "word0244", "word0245", "word0246", "word0247", "word0248", "word0249", "word0250", "word0251", "word0252", "word0253", "word0254", "word0255", "word0256", "word0257", "word0258", "word0259", "word0260", "word0261", "word0262", "word0263", "word0264", "word0265", "word0266", "word0267", "word0268", "word0269", "word0270", "word0271", "word0272", "word0273", "word0274", "word0275", "word0276", "word0277", "word0278", "word0279", "word0280", "word0281", "word0282", "word0283", "word0284", "word0285", "word0286", "word0287", "word0288", "word0289", "word0290", "word0291", "word0292", "word0293", "word0294", "word0295", "word0296", "word0297", "word0298", "word0299", "word0300", "word0301", "word0302", "word0303", "word0304", "word0305", "word0306", "word0307", "word0308", "word0309", "word0310", "word0311", "word0312", "word0313", "word0314", "word0315", "word0316", "word0317", "word0318", "word0319", "word0320", "word0321", "word0322", "word0323", "word0324", "word0325", "word0326", "word0327", "word0328", "word0329", "word0330", "word0331", "word0332", "word0333", "word0334", "word0335", "word0336", "word0337", "word0338", "word0339", "word0340", "word0341", "word0342", "word0343", "word0344", "word0345", "word0346", "word0347", "word0348", "word0349", "word0350", "word0351", "word0352", "word0353", "word0354", "word0355", "word0356", "word0357", "word0358", "word0359", "word0360", "word0361", "word0362", "word0363", "word0364", "word0365", "word0366", "word0367", "word0368", "word0369", "word0370", "word0371", "word0372", "word0373", "word0374", "word0375", "word0376", "word0377", "word0378", "word0379", "word0380", "word0381", "word0382", "word0383", "word0384", "word0385", "word0386", "word0387", "word0388", "word0389", "word0390", "word0391", "word0392", "word0393", "word0394", "word0395", "word0396", "word0397", "word0398", "word0399", "word0400", "word0401", "word0402", "word0403", "word0404", "word0405", "word0406", "word0407", "word0408", "word0409", "word0410", "word0411", "word0412", "word0413", "word0414", "word0415", "word0416", "word0417", "word0418", "word0419", "word0420", "word0421", "word0422", "word0423", "word0424", "word0425", "word0426", "word0427", "word0428", "word0429", "word0430", "word0431", "word0432", "word0433", "word0434", "word0435", "word0436", "word0437", "word0438", "word0439", "word0440", "word0441", "word0442", "word0443", "word0444", "word0445", "word0446", "word0447", "word0448", "word0449", "word0450", "word0451", "word0452", "word0453", "word0454", "word0455", "word0456", "word0457", "word0458", "word0459", "word0460", "word0461", "word0462", "word0463", "word0464", "word0465", "word0466", "word0467", "word0468", "word0469", "word0470", "word0471", "word0472", "word0473", "word0474", "word0475", "word0476", "word0477", "word0478", "word0479", "word0480", "word0481", "word0482", "word0483", "word0484", "word0485", "word0486", "word0487", "word0488", "word0489", "word0490", "word0491", "word0492", "word0493", "word0494", "word0495", "word0496", "word0497", "word0498", "word0499", "word0500", "word0501", "word0502", "word0503", "word0504", "word0505", "word0506", "word0507", "word0508", "word0509", "word0510", "word0511", "word0512", "word0513", "word0514", "word0515", "word0516", "word0517", "word0518", "word0519", "word0520", "word0521", "word0522", "word0523", "word0524", "word0525", "word0526", "word0527", "word0528", "word0529", "word0530", "word0531", "word0532", "word0533", "word0534", "word0535", "word0536", "word0537", "word0538", "word0539", "word0540", "word0541", "word0542", "word0543", "word0544", "word0545", "word0546", "word0547", "word0548", "word0549", "word0550", "word0551", "word0552", "word0553", "word0554", "word0555", "word0556", "word0557", "word0558", "word0559", "word0560", "word0561", "word0562", "word0563", "word0564", "word0565", "word0566", "word0567", "word0568", "word0569", "word0570", "word0571", "word0572", "word0573", "word0574", "word0575", "word0576", "word0577", "word0578", "word0579", "word0580", "word0581", "word0582", "word0583", "word0584", "word0585", "word0586", "word0587", "word0588", "word0589", "word0590", "word0591", "word0592", "word0593", "word0594", "word0595", "word0596", "word0597", "word0598", "word0599", "word0600", "word0601", "word0602", "word0603", "word0604", "word0605", "word0606", "word0607", "word0608", "word0609", "word0610", "word0611", "word0612", "word0613", "word0614", "word0615", "word0616", "word0617", "word0618", "word0619", "word0620", "word0621", "word0622", "word0623", "word0624", "word0625", "word0626", "word0627", "word0628", "word0629", "word0630", "word0631", "word0632", "word0633", "word0634", "word0635", "word0636", "word0637", "word0638", "word0639", "word0640", "word0641", "word0642", "word0643", "word0644", "word0645", "word0646", "word0647", "word0648", "word0649", "word0650", "word0651", "word0652", "word0653", "word0654", "word0655", "word0656", "word0657", "word0658", "word0659", "word0660", "word0661", "word0662", "word0663", "word0664", "word0665", "word0666", "word0667", "word0668", "word0669", "word0670", "word0671", "word0672", "word0673", "word0674", "word0675", "word0676", "word0677", "word0678", "word0679", "word0680", "word0681", "word0682", "word0683", "word0684", "word0685", "word0686", "word0687", "word0688", "word0689", "word0690", "word0691", "word0692", "word0693", "word0694", "word0695", "word0696", "word0697", "word0698", "word0699", "word0700", "word0701", "word0702", "word0703", "word0704", "word0705", "word0706", "word0707", "word0708", "word0709", "word0710", "word0711", "word0712", "word0713", "word0714", "word0715", "word0716", "word0717", "word0718", "word0719", "word0720", "word0721", "word0722", "word0723", "word0724", "word0725", "word0726", "word0727", "word0728", "word0729", "word0730", "word0731", "word0732", "word0733", "word0734", "word0735", "word0736", "word0737", "word0738", "word0739", "word0740", "word0741", "word0742", "word0743", "word0744", "word0745", "word0746", "word0747", "word0748", "word0749", "word0750", "word0751", "word0752", "word0753", "word0754", "word0755", "word0756", "word0757", "word0758", "word0759", "word0760", "word0761", "word0762", "word0763", "word0764", "word0765", "word0766", "word0767", "word0768", "word0769", "word0770", "word0771", "word0772", "word0773", "word0774", "word0775", "word0776", "word0777", "word0778", "word0779", "word0780", "word0781", "word0782", "word0783", "word0784", "word0785", "word0786", "word0787", "word0788", "word0789", "word0790", "word0791", "word0792", "word0793", "word0794", "word0795", "word0796", "word0797", "word0798", "word0799", "word0800", "word0801", "word0802", "word0803", "word0804", "word0805", "word0806", "word0807", "word0808", "word0809", "word0810", "word0811", "word0812", "word0813", "word0814", "word0815", "word0816", "word0817", "word0818", "word0819", "word0820", "word0821", "word0822", "word0823", "word0824", "word0825", "word0826", "word0827", "word0828", "word0829", "word0830", "word0831", "word0832", "word0833", "word0834", "word0835", "word0836", "word0837", "word0838", "word0839", "word0840", "word0841", "word0842", "word0843", "word0844", "word0845", "word0846", "word0847", "word0848", "word0849", "word0850", "word0851", "word0852", "word0853", "word0854", "word0855", "word0856", "word0857", "word0858", "word0859", "word0860", "word0861", "word0862", "word0863", "word0864", "word0865", "word0866", "word0867", "word0868", "word0869", "word0870", "word0871", "word0872", "word0873", "word0874", "word0875", "word0876", "word0877", "word0878", "word0879", "word0880", "word0881", "word0882", "word0883", "word0884", "word0885", "word0886", "word0887", "word0888", "word0889", "word0890", "word0891", "word0892", "word0893", "word0894", "word0895", "word0896", "word0897", "word0898", "word0899", "word0900", "word0901", "word0902", "word0903", "word0904", "word0905", "word0906", "word0907", "word0908", "word0909", "word0910", "word0911", "word0912", "word0913", "word0914", "word0915", "word0916", "word0917", "word0918", "word0919", "word0920", "word0921", "word0922", "word0923", "word0924", "word0925", "word0926", "word0927", "word0928", "word0929", "word0930", "word0931", "word0932", "word0933", "word0934", "word0935", "word0936", "word0937", "word0938", "word0939", "word0940", "word0941", "word0942", "word0943", "word0944", "word0945", "word0946", "word0947", "word0948", "word0949", "word0950", "word0951", "word0952", "word0953", "word0954", "word0955", "word0956", "word0957", "word0958", "word0959", "word0960", "word0961", "word0962", "word0963", "word0964", "word0965", "word0966", "word0967", "word0968", "word0969", "word0970", "word0971", "word0972", "word0973", "word0974", "word0975", "word0976", "word0977", "word0978", "word0979", "word0980", "word0981", "word0982", "word0983", "word0984", "word0985", "word0986", "word0987", "word0988", "word0989", "word0990", "word0991", "word0992", "word0993", "word0994", "word0995", "word0996", "word0997", "word0998", "word0999", "word1000", "word1001", "word1002", "word1003", "word1004", "word1005", "word1006", "word1007", "word1008", "word1009", "word1010", "word1011", "word1012", "word1013", "word1014", "word1015", "word1016", "word1017", "word1018", "word1019", "word1020", "word1021", "word1022", "word1023", "word1024", "word1025", "word1026", "word1027", "word1028", "word1029", "word1030", "word1031", "word1032", "word1033", "word1034", "word1035", "word1036", "word1037", "word1038", "word1039", "word1040", "word1041", "word1042", "word1043", "word1044", "word1045", "word1046", "word1047", "word1048", "word1049", "word1050", "word1051", "word1052", "word1053", "word1054", "word1055", "word1056", "word1057", "word1058", "word1059", "word1060", "word1061", "word1062", "word1063", "word1064", "word1065", "word1066", "word1067", "word1068", "word1069", "word1070", "word1071", "word1072", "word1073", "word1074", "word1075", "word1076", "word1077", "word1078", "word1079", "word1080", "word1081", "word1082", "word1083", "word1084", "word1085", "word1086", "word1087", "word1088", "word1089", "word1090", "word1091", "word1092", "word1093", "word1094", "word1095", "word1096", "word1097", "word1098", "word1099", "word1100", "word1101", "word1102", "word1103", "word1104", "word1105", "word1106", "word1107", "word1108", "word1109", "word1110", "word1111", "word1112", "word1113", "word1114", "word1115", "word1116", "word1117", "word1118", "word1119", "word1120", "word1121", "word1122", "word1123", "word1124", "word1125", "word1126", "word1127", "word1128", "word1129", "word1130", "word1131", "word1132", "word1133", "word1134", "word1135", "word1136", "word1137", "word1138", "word1139", "word1140", "word1141", "word1142", "word1143", "word1144", "word1145", "word1146", "word1147", "word1148", "word1149", "word1150", "word1151", "word1152", "word1153", "word1154", "word1155", "word1156", "word1157", "word1158", "word1159", "word1160", "word1161", "word1162", "word1163", "word1164", "word1165", "word1166", "word1167", "word1168", "word1169", "word1170", "word1171", "word1172", "word1173", "word1174", "word1175", "word1176", "word1177", "word1178", "word1179", "word1180", "word1181", "word1182", "word1183", "word1184", "word1185", "word1186", "word1187", "word1188", "word1189", "word1190", "word1191", "word1192", "word1193", "word1194", "word1195", "word1196", "word1197", "word1198", "word1199", "word1200", "word1201", "word1202", "word1203", "word1204", "word1205", "word1206", "word1207", "word1208", "word1209", "word1210", "word1211", "word1212", "word1213", "word1214", "word1215", "word1216", "word1217", "word1218", "word1219", "word1220", "word1221", "word1222", "word1223", "word1224", "word1225", "word1226", "word1227", "word1228", "word1229", "word1230", "word1231", "word1232", "word1233", "word1234", "word1235", "word1236", "word1237", "word1238", "word1239", "word1240", "word1241", "word1242", "word1243", "word1244", "word1245", "word1246", "word1247", "word1248", "word1249", "word1250", "word1251", "word1252", "word1253", "word1254", "word1255", "word1256", "word1257", "word1258", "word1259", "word1260", "word1261", "word1262", "word1263", "word1264", "word1265", "word1266", "word1267", "word1268", "word1269", "word1270", "word1271", "word1272", "word1273", "word1274", "word1275", "word1276", "word1277", "word1278", "word1279", "word1280", "word1281", "word1282", "word1283", "word1284", "word1285", "word1286", "word1287", "word1288", "word1289", "word1290", "word1291", "word1292", "word1293", "word1294", "word1295", "word1296", "word1297", "word1298", "word1299", "word1300", "word1301", "word1302", "word1303", "word1304", "word1305", "word1306", "word1307", "word1308", "word1309", "word1310", "word1311", "word1312", "word1313", "word1314", "word1315", "word1316", "word1317", "word1318", "word1319", "word1320", "word1321", "word1322", "word1323", "word1324", "word1325", "word1326", "word1327", "word1328", "word1329", "word1330", "word1331", "word1332", "word1333", "word1334", "word1335", "word1336", "word1337", "word1338", "word1339", "word1340", "word1341", "word1342", "word1343", "word1344", "word1345", "word1346", "word1347", "word1348", "word1349", "word1350", "word1351", "word1352", "word1353", "word1354", "word1355", "word1356", "word1357", "word1358", "word1359", "word1360", "word1361", "word1362", "word1363", "word1364", "word1365", "word1366", "word1367", "word1368", "word1369", "word1370", "word1371", "word1372", "word1373", "word1374", "word1375", "word1376", "word1377", "word1378", "word1379", "word1380", "word1381", "word1382", "word1383", "word1384", "word1385", "word1386", "word1387", "word1388", "word1389", "word1390", "word1391", "word1392", "word1393", "word1394", "word1395", "word1396", "word1397", "word1398", "word1399", "word1400", "word1401", "word1402", "word1403", "word1404", "word1405", "word1406", "word1407", "word1408", "word1409", "word1410", "word1411", "word1412", "word1413", "word1414", "word1415", "word1416", "word1417", "word1418", "word1419", "word1420", "word1421", "word1422", "word1423", "word1424", "word1425", "word1426", "word1427", "word1428", "word1429", "word1430", "word1431", "word1432", "word1433", "word1434", "word1435", "word1436", "word1437", "word1438", "word1439", "word1440", "word1441", "word1442", "word1443", "word1444", "word1445", "word1446", "word1447", "word1448", "word1449", "word1450", "word1451", "word1452", "word1453", "word1454", "word1455", "word1456", "word1457", "word1458", "word1459", "word1460", "word1461", "word1462", "word1463", "word1464", "word1465", "word1466", "word1467", "word1468", "word1469", "word1470", "word1471", "word1472", "word1473", "word1474", "word1475", "word1476", "word1477", "word1478", "word1479", "word1480", "word1481", "word1482", "word1483", "word1484", "word1485", "word1486", "word1487", "word1488", "word1489", "word1490", "word1491", "word1492", "word1493", "word1494", "word1495", "word1496", "word1497", "word1498", "word1499", "word1500", "word1501", "word1502", "word1503", "word1504", "word1505", "word1506", "word1507", "word1508", "word1509", "word1510", "word1511", "word1512", "word1513", "word1514", "word1515", "word1516", "word1517", "word1518", "word1519", "word1520", "word1521", "word1522", "word1523", "word1524", "word1525", "word1526", "word1527", "word1528", "word1529", "word1530", "word1531", "word1532", "word1533", "word1534", "word1535", "word1536", "word1537", "word1538", "word1539", "word1540", "word1541", "word1542", "word1543", "word1544", "word1545", "word1546", "word1547", "word1548", "word1549", "word1550", "word1551", "word1552", "word1553", "word1554", "word1555", "word1556", "word1557", "word1558", "word1559", "word1560", "word1561", "word1562", "word1563", "word1564", "word1565", "word1566", "word1567", "word1568", "word1569", "word1570", "word1571", "word1572", "word1573", "word1574", "word1575", "word1576", "word1577", "word1578", "word1579", "word1580", "word1581", "word1582", "word1583", "word1584", "word1585", "word1586", "word1587", "word1588", "word1589", "word1590", "word1591", "word1592", "word1593", "word1594", "word1595", "word1596", "word1597", "word1598", "word1599", "word1600", "word1601", "word1602", "word1603", "word1604", "word1605", "word1606", "word1607", "word1608", "word1609", "word1610", "word1611", "word1612", "word1613", "word1614", "word1615", "word1616", "word1617", "word1618", "word1619", "word1620", "word1621", "word1622", "word1623", "word1624", "word1625", "word1626", "word1627", "word1628", "word1629", "word1630", "word1631", "word1632", "word1633", "word1634", "word1635", "word1636", "word1637", "word1638", "word1639", "word1640", "word1641", "word1642", "word1643", "word1644", "word1645", "word1646", "word1647", "word1648", "word1649", "word1650", "word1651", "word1652", "word1653", "word1654", "word1655", "word1656", "word1657", "word1658", "word1659", "word1660", "word1661", "word1662", "word1663", "word1664", "word1665", "word1666", "word1667", "word1668", "word1669", "word1670", "word1671", "word1672", "word1673", "word1674", "word1675", "word1676", "word1677", "word1678", "word1679", "word1680", "word1681", "word1682", "word1683", "word1684", "word1685", "word1686", "word1687", "word1688", "word1689", "word1690", "word1691", "word1692", "word1693", "word1694", "word1695", "word1696", "word1697", "word1698", "word1699", "word1700", "word1701", "word1702", "word1703", "word1704", "word1705", "word1706", "word1707", "word1708", "word1709", "word1710", "word1711", "word1712", "word1713", "word1714", "word1715", "word1716", "word1717", "word1718", "word1719", "word1720", "word1721", "word1722", "word1723", "word1724", "word1725", "word1726", "word1727", "word1728", "word1729", "word1730", "word1731", "word1732", "word1733", "word1734", "word1735", "word1736", "word1737", "word1738", "word1739", "word1740", "word1741", "word1742", "word1743", "word1744", "word1745", "word1746", "word1747", "word1748", "word1749", "word1750", "word1751", "word1752", "word1753", "word1754", "word1755", "word1756", "word1757", "word1758", "word1759", "word1760", "word1761", "word1762", "word1763", "word1764", "word1765", "word1766", "word1767", "word1768", "word1769", "word1770", "word1771", "word1772", "word1773", "word1774", "word1775", "word1776", "word1777", "word1778", "word1779", "word1780", "word1781", "word1782", "word1783", "word1784", "word1785", "word1786", "word1787", "word1788", "word1789", "word1790", "word1791", "word1792", "word1793", "word1794", "word1795", "word1796", "word1797", "word1798", "word1799", "word1800", "word1801", "word1802", "word1803", "word1804", "word1805", "word1806", "word1807", "word1808", "word1809", "word1810", "word1811", "word1812", "word1813", "word1814", "word1815", "word1816", "word1817", "word1818", "word1819", "word1820", "word1821", "word1822", "word1823", "word1824", "word1825", "word1826", "word1827", "word1828", "word1829", "word1830", "word1831", "word1832", "word1833", "word1834", "word1835", "word1836", "word1837", "word1838", "word1839", "word1840", "word1841", "word1842", "word1843", "word1844", "word1845", "word1846", "word1847", "word1848", "word1849", "word1850", "word1851", "word1852", "word1853", "word1854", "word1855", "word1856", "word1857", "word1858", "word1859", "word1860", "word1861", "word1862", "word1863", "word1864", "word1865", "word1866", "word1867", "word1868", "word1869", "word1870", "word1871", "word1872", "word1873", "word1874", "word1875", "word1876", "word1877", "word1878", "word1879", "word1880", "word1881", "word1882", "word1883", "word1884", "word1885", "word1886", "word1887", "word1888", "word1889", "word1890", "word1891", "word1892", "word1893", "word1894", "word1895", "word1896", "word1897", "word1898", "word1899", "word1900", "word1901", "word1902", "word1903", "word1904", "word1905", "word1906", "word1907", "word1908", "word1909", "word1910", "word1911", "word1912", "word1913", "word1914", "word1915", "word1916", "word1917", "word1918", "word1919", "word1920", "word1921", "word1922", "word1923", "word1924", "word1925", "word1926", "word1927", "word1928", "word1929", "word1930", "word1931", "word1932", "word1933", "word1934", "word1935", "word1936", "word1937", "word1938", "word1939", "word1940", "word1941", "word1942", "word1943", "word1944", "word1945", "word1946", "word1947", "word1948", "word1949", "word1950", "word1951", "word1952", "word1953", "word1954", "word1955", "word1956", "word1957", "word1958", "word1959", "word1960", "word1961", "word1962", "word1963", "word1964", "word1965", "word1966", "word1967", "word1968", "word1969", "word1970", "word1971", "word1972", "word1973", "word1974", "word1975", "word1976", "word1977", "word1978", "word1979", "word1980", "word1981", "word1982", "word1983", "word1984", "word1985", "word1986", "word1987", "word1988", "word1989", "word1990", "word1991", "word1992", "word1993", "word1994", "word1995", "word1996", "word1997", "word1998", "word1999", "word2000", "word2001", "word2002", "word2003", "word2004", "word2005", "word2006", "word2007", "word2008", "word2009", "word2010", "word2011", "word2012", "word2013", "word2014", "word2015", "word2016", "word2017", "word2018", "word2019", "word2020", "word2021", "word2022", "word2023", "word2024", "word2025", "word2026", "word2027", "word2028", "word2029", "word2030", "word2031", "word2032", "word2033", "word2034", "word2035", "word2036", "word2037", "word2038", "word2039", "word2040", "word2041", "word2042", "word2043", "word2044", "word2045", "word2046", "word2047", "word2048"]; diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f844ab4 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,78 @@ +# Internal module API + +BlindCrypt has no network API. These browser modules are internal interfaces used by the page and tests. + +## `assets/crypto.js` public facade + +### `encryptBlobV3(source, passphrase, options)` + +Encrypts a `Blob` into format v3. + +Options: + +- `name`: original filename; normalized before encrypted storage +- `type`: declared media type; normalized before encrypted storage +- `levelKey`: `standard`, `strong`, `high`, or `critical` +- `onProgress(percent, message)`: optional local progress callback + +Returns `{ blob, header, metadata }`. The returned Blob uses `application/octet-stream`. + +Example: + +```js +const result = await encryptBlobV3(file, passphrase, { + name: file.name, + type: file.type, + levelKey: "strong", +}); +``` + +### `decryptBlobAny(source, passphrase, onProgress?)` + +Detects and decrypts v1, v2, or v3. + +Returns: + +- `blob`: neutral downloadable plaintext Blob +- `metadata`: normalized filename, declared media type, and writer indicator +- `formatVersion`: `1`, `2`, or `3` +- `authenticatedMetadata`: true only for v3 +- `legacyWarning`: null for v3; warning text for legacy files +- `publicHeader`: parsed public header for diagnostic display or tests + +### `sanitizeFilename(value)` + +Removes controls, bidirectional overrides, separators, reserved filename characters, unsafe trailing characters, and excessive length. + +### `sanitizeMimeType(value)` + +Accepts a restricted lowercase `type/subtype` form. Invalid values become `application/octet-stream`. + +### Constants + +- `APP_VERSION` +- `FORMAT_VERSION` +- `CHUNK_SIZE` +- `METADATA_BLOCK_SIZE` +- `MAX_PLAINTEXT_SIZE` +- `MAX_PASSPHRASE_BYTES` +- `LEVELS` +- `LIMITS` + +## `assets/passphrase.js` + +### `buildWordSet(words)` + +Requires exactly 2,048 unique lowercase words. + +### `generatePassphrase(words, count)` + +Generates 6 through 16 independently selected words through `crypto.getRandomValues`. + +### `assessPassphrase(passphrase, wordSet)` + +Returns transparent word-count information for bundled-word phrases. Custom passphrases return no entropy estimate. + +### `validateNewPassphrase(passphrase, wordSet)` + +Accepts at least six bundled words or a non-repetitive custom passphrase meeting the configured length rules. Returns the NFC-normalized value used by format v3. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5f143db --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,69 @@ +# Architecture + +BlindCrypt is a static single-page application with no server-side data path. + +```text +User-selected File/Blob + | + v +assets/app.js <---- assets/passphrase.js <---- bundled word list + | + v +assets/crypto.js public facade + | + +--> assets/crypto-v3.js + +--> assets/crypto-legacy.js + +--> assets/crypto-core.js + | validate limits and format + | PBKDF2 through WebCrypto + | AES-GCM metadata and records + v +Downloadable application/octet-stream Blob +``` + +For decryption: + +```text +.blindcrypt File/Blob + | + v +Bounded format detector + | | + | v3 | v1/v2 legacy + v v +Canonical parser Strict legacy parser +Exact geometry Bounds and exact lengths +Header AAD Neutral metadata handling + | | + +----------+----------+ + v + WebCrypto + | + v + Downloadable neutral Blob +``` + +## Components + +### `assets/app.js` + +Owns DOM interaction, accessibility state, progress reporting, user-facing validation, local downloads, generic failure messages, and legacy warnings. It contains no cryptographic primitive logic. + +### `assets/passphrase.js` + +Validates the 2,048-word list, generates uniformly indexed word phrases, assesses generated word-list phrases, and applies minimum rules to new custom passphrases. It does not estimate custom entropy. + +### `assets/crypto-core.js`, `assets/crypto-v3.js`, `assets/crypto-legacy.js`, and the `assets/crypto.js` public facade + +Owns format framing, bounds, canonical parsing, passphrase normalization for v3, PBKDF2, IV construction, AES-GCM additional authenticated data, metadata encryption, v3 encryption/decryption, and bounded legacy readers. + +### Build and validation + +Node scripts provide syntax checks, policy linting, strict type checking, tests, custom SAST, configuration checks, deterministic static builds, SHA-256 manifests, and a performance smoke test. GitHub Actions run the same validation in a clean environment and retain the resulting artifact. + +## Data flow constraints + +- No application code calls `fetch`, `XMLHttpRequest`, `WebSocket`, or `EventSource`. +- No plaintext, passphrase, metadata, or filename is placed in local storage, session storage, cookies, URL parameters, logs, or telemetry. +- Runtime executable resources are same-origin files covered by CSP. +- Output MIME type is `application/octet-stream`; authenticated original type is informational metadata only. diff --git a/docs/FORMAT.md b/docs/FORMAT.md new file mode 100644 index 0000000..76497a1 --- /dev/null +++ b/docs/FORMAT.md @@ -0,0 +1,125 @@ +# BlindCrypt format v3 + +## Overview + +All integers are unsigned big-endian. Text is UTF-8. JSON is canonical for this implementation: `JSON.stringify(JSON.parse(text))` must equal the original text byte-for-byte. + +```text ++--------------------------+ +| Magic "BC03" 4 B | ++--------------------------+ +| Header length 4 B | ++--------------------------+ +| Public header N B | ++--------------------------+ +| Metadata cipher 1040 B | 1024 plaintext + 16-byte GCM tag ++--------------------------+ +| Data record 0 ... | ++--------------------------+ +| Data record 1 ... | ++--------------------------+ +| ... | ++--------------------------+ +``` + +The complete container length must equal the length derived from the public header. Missing bytes and trailing bytes are invalid. + +## Public header + +The header contains exactly these keys in this insertion order: + +```json +{"v":3,"mode":"chunked-aesgcm-aad","kdf":"PBKDF2","hash":"SHA-256","iter":900000,"alg":"AES-256-GCM","salt":"...","iv":"...","size":1234,"chunk":524288,"chunks":1,"last":1234,"meta":1024,"norm":"NFC","writer":"01.01.00"} +``` + +Fields: + +- `v`: integer `3` +- `mode`: `chunked-aesgcm-aad` +- `kdf`: `PBKDF2` +- `hash`: `SHA-256` +- `iter`: integer from 600,000 through 2,400,000 +- `alg`: `AES-256-GCM` +- `salt`: canonical unpadded base64url encoding of 16 random bytes +- `iv`: canonical unpadded base64url encoding of an 8-byte random IV prefix +- `size`: plaintext size from 0 through 67,108,864 bytes +- `chunk`: integer `524288` +- `chunks`: `0` for an empty file; otherwise `ceil(size / chunk)` +- `last`: `0` for an empty file; otherwise `size - ((chunks - 1) * chunk)` +- `meta`: integer `1024` +- `norm`: `NFC` +- `writer`: application version in `xx.xx.xx` form + +The header frame used for authentication is the exact concatenation of the 4-byte magic, 4-byte header length, and raw header bytes. Readers do not reserialize the header when constructing authenticated data. + +## Key derivation + +1. Normalize the passphrase to Unicode NFC. +2. Encode it as UTF-8. The encoded length must be 1 through 1,024 bytes. +3. Import it as PBKDF2 key material. +4. Derive a nonextractable 256-bit AES-GCM key using SHA-256, the public 16-byte salt, and the validated iteration count. + +Legacy v1 and v2 passphrases are not normalized. + +## Record IVs + +Every AES-GCM IV is 12 bytes: + +```text +[random 8-byte prefix][32-bit record counter] +``` + +- metadata record counter: `0` +- data record `i` counter: `i + 1` + +The file-size ceiling keeps the counter far below exhaustion. + +## Additional authenticated data + +Every record authenticates: + +```text +"BlindCrypt-v3\0" || headerFrame || recordType || recordIndex || plaintextLength +``` + +- domain string: UTF-8 bytes shown above +- `recordType`: one byte, `0` for metadata and `1` for file data +- `recordIndex`: 4-byte unsigned integer; metadata uses `0` +- `plaintextLength`: 4-byte unsigned integer + +This binding prevents valid records from being transplanted, reordered, reinterpreted, or accepted under a changed header. + +## Encrypted metadata + +Metadata plaintext is always 1,024 bytes: + +```text +[JSON length 4 B][canonical JSON][random padding] +``` + +The JSON contains exactly: + +```json +{"name":"safe-file.txt","type":"text/plain","writer":"01.01.00"} +``` + +Filename and media type must already satisfy the reader's safety normalization. The fixed block limits metadata-length disclosure. + +## Data records + +Each plaintext record is at most 524,288 bytes. AES-GCM appends a 16-byte authentication tag. The last record length is taken from the authenticated public header. Empty files have no data records but still contain the authenticated encrypted metadata record. + +## Reader validation order + +1. Enforce the overall encrypted-file ceiling. +2. Read and validate magic and public-header length. +3. Decode canonical UTF-8 JSON and exact fields. +4. Validate constants, numeric types, bounds, salt, IV, record geometry, and exact total length. +5. Derive the key. +6. Authenticate and parse metadata. +7. Authenticate each data record in order. +8. Require the final offset to equal the file length. + +## Legacy formats + +Version 1 and version 2 remain read-only. Their public metadata is untrusted. Version 2 record tags do not cryptographically commit to the complete original file. The application applies strict resource bounds and neutral output handling but cannot retrofit missing authentication. diff --git a/docs/RELEASE_01.01.00.md b/docs/RELEASE_01.01.00.md new file mode 100644 index 0000000..ce569e1 --- /dev/null +++ b/docs/RELEASE_01.01.00.md @@ -0,0 +1,104 @@ +# Release candidate 01.01.00 + +Date: 2026-08-13 + +Tag after protected merge: `v01.01.00` + +Reference: `SEC-AUDIT-2026-08-13` + +Baseline: `4ed8c157c6015340b363848c12527d9499fb8d69` + +## Summary + +This release replaces new-file format v2 with authenticated format v3, retains bounded read compatibility for v1 and v2, adds encrypted metadata, closes parser and resource-exhaustion paths, corrects passphrase guidance, and introduces a reproducible security-focused release pipeline. + +## Change classification + +- Breaking: old BlindCrypt builds cannot decrypt newly written v3 files. +- Additive: the new reader supports v1, v2, and v3. +- Fix: public-header integrity, encrypted metadata, exact-length validation, KDF and parser bounds, memory ceiling, neutral legacy output, passphrase assessment, CSP, and deployment validation. + +## Validation evidence + +Local validation environment: + +- Node.js 22.16.0 +- 25 unit, integration, and regression tests +- strict JavaScript type check through TypeScript 5.8.3 +- syntax and policy lint +- custom static security analysis +- configuration validation +- clean `dist/` build with `SHA256SUMS` +- local HTTP smoke test of the built artifact +- 1 MiB standard-level authenticated round trip performance smoke test + +Local performance smoke result: 1,048,576 input bytes, 1,049,908 encrypted bytes, 471.5 ms encryption, and 453.3 ms decryption on the recorded host. + +GitHub Security validation run `31763503108` passed on code commit `01116bc7b7613980ef14a19ad082e3ecab6edca5`. It completed a fresh locked install, reported zero npm vulnerabilities, executed the complete validation suite, and retained artifact `blindcrypt-01116bc7b7613980ef14a19ad082e3ecab6edca5` with digest `sha256:dbd4c61f31fc539e9c62c9552df7e4b7d612837f90e0074f7ba4a4e6c9d636c3`. CodeQL run `31763503112` also passed. Production release remains blocked on protected review, repository settings, Pages promotion, and the release tag. + +## Dependency validation + +Runtime dependencies: none. + +Development dependency: TypeScript 5.8.3, pinned in `package.json` and `package-lock.json`. CI installs with `npm ci --ignore-scripts` and fails on high or critical audit findings. + +## Configuration and migration review + +- Environment variables: none. +- Feature flags: none. +- Backend services: none. +- Database schema or migration: none. +- Runtime logging or telemetry: none. +- Pages deployment: must use the committed GitHub Actions workflow. + +## Performance and resource behavior + +The application processes file data in 512 KiB slices and enforces a 64 MiB plaintext ceiling. The release smoke test measures a 1 MiB encrypt/decrypt round trip and fails when either operation exceeds 30 seconds on the CI host. Performance numbers are host-specific and are not a device guarantee. + +## Logging and observability + +The browser application emits no console logs, network telemetry, filenames, passphrases, plaintext, or decryption details. CI records only command output, versioned source, test status, artifact metadata, and deployment status. + +## Release artifacts + +- validated `dist/` directory +- `dist/SHA256SUMS` +- `dist/SBOM.spdx.json` +- source commit +- tag `v01.01.00` +- this release note +- changelog +- workflow validation and CodeQL results + +## Promotion checklist + +1. Confirm `dev` commit and diff contain only intended files. Completed for the validated code commit. +2. Confirm Security validation and CodeQL succeed. Completed on runs `31763503108` and `31763503112`. +3. Apply required repository rules and GitHub Pages settings. +4. Merge through a protected pull request. +5. Confirm the `main` Pages workflow deploys the validated artifact. +6. Create annotated tag `v01.01.00` on the deployed commit. +7. Attach the artifact, checksum, SBOM, and release notes. +8. Preserve the previous artifact and rollback instructions. + +## Commit notes + +```text +security: release BlindCrypt 01.01.00 with authenticated format v3 + +Release: 01.01.00 +Tag after protected merge: v01.01.00 +Refs: SEC-AUDIT-2026-08-13 +Baseline: 4ed8c157c6015340b363848c12527d9499fb8d69 + +- authenticate the exact v3 header and record context with AES-GCM AAD +- encrypt and validate fixed-size filename and media-type metadata +- reject truncation, trailing bytes, malformed geometry, and excessive KDF inputs +- cap plaintext at 64 MiB and process source data in bounded slices +- retain bounded v1/v2 reads with neutral legacy output and explicit warnings +- remove four-word generation and unverified custom entropy labels +- add CSP, tests, type checking, SAST, CodeQL, locked builds, and release docs + +Change type: breaking producer format, additive reader support, security fixes +Rollback: preserve the v3 reader; revert deployment or interface changes separately +``` diff --git a/docs/RELEASE_01.01.01.md b/docs/RELEASE_01.01.01.md new file mode 100644 index 0000000..b37b983 --- /dev/null +++ b/docs/RELEASE_01.01.01.md @@ -0,0 +1,45 @@ +# BlindCrypt 01.01.01 release notes + +Status: development candidate on `dev`. + +Target tag after protected merge and production validation: `v01.01.01`. + +References: `GHAS-PR-1`, CodeQL alert 1, PR #1. + +## Summary + +Version 01.01.01 fixes the CodeQL finding reported against the build-artifact smoke server. The validation utility previously checked a requested file with `stat()` and then reopened the path with `readFile()`. A concurrent filesystem change between those operations could cause the second operation to act on a different object. + +The smoke server now exposes only a fixed set of validation routes. Incoming request paths are map keys, not filesystem paths. The server performs one file read for the fixed route target and rejects unlisted paths, traversal-shaped requests, and non-GET methods. + +## Security impact + +- Eliminates the reported filesystem check/use race in `scripts/smoke.mjs`. +- Removes request-derived filesystem path construction from the validation server. +- Narrows the local server to the exact build assets required by the smoke test. +- Adds source-level regression checks and negative HTTP requests. + +The affected server is a short-lived local CI and developer validation utility bound to `127.0.0.1`; it is not shipped in the browser artifact. The fix is still required because release tooling is part of the software supply chain. + +## Compatibility + +- No encryption-format change. +- New files remain format v3. +- Reading v1, v2, and v3 remains supported. +- No API, database, configuration, environment-variable, or user-data migration. +- No runtime dependency change. + +## Validation requirements + +Before promotion: + +1. Run a fresh locked install with scripts disabled. +2. Run `npm audit --audit-level=high`. +3. Run `npm run validate`, including unit, integration, regression, SAST, build, smoke, and performance checks. +4. Run CodeQL with the extended security suite and confirm alert 1 is resolved. +5. Retain the build artifact and SHA-256 digest. +6. Merge through protected review, deploy from `main`, verify Pages, then create `v01.01.01`. + +## Rollback + +Revert the 01.01.01 commits to the previously validated 01.01.00 development candidate. Format v3 support must remain available. Do not deploy the old smoke-server implementation in release validation after the CodeQL finding is known. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..5cbb67f --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,64 @@ +# Release checklist + +This checklist is mandatory for each BlindCrypt version. Record evidence in the matching validation and release documents. + +## Version and traceability + +- [x] `VERSION` incremented with `xx.xx.xx` as `..`. +- [x] Application version, changelog, release notes, SBOM, security policy, and commit notes agree on `01.01.01`. +- [ ] Create tag `v01.01.01` only after the validated commit is deployed from protected `main`. + +## Change record + +- [x] Changelog records the CodeQL finding, reason for the fix, classification, references, and baseline commit. +- [x] Commit notes are copy-ready in `COMMIT_NOTES.md`. + +## Automated validation + +- [x] Unit tests pass. +- [x] Integration and legacy compatibility tests pass. +- [x] Regression tests, including the smoke-server race policy and deterministic header tampering, pass. +- [x] Performance smoke test passes. +- [x] Built-artifact HTTP smoke test passes. +- [x] Hosted Security validation passed on commit `a0ca628f36b609856fd14292088007f26ebc9e97` in run `31767647395`. +- [x] Hosted CodeQL passed in run `31767647419` and the alert 1 review thread was automatically resolved. + +## Static quality and security + +- [x] Syntax and policy lint pass across 19 JavaScript files. +- [x] Strict type checking passes. +- [x] Local SAST passes. +- [x] The smoke server uses fixed routes and no request-derived filesystem path. +- [x] Authentication inputs, authorization scope, input bounds, logging, secrets, and deployment permissions reviewed. +- [x] No backend authentication or authorization surface exists. +- [x] No browser telemetry, persistent storage, or network API exists. + +## Dependencies and build + +- [x] Runtime dependency count remains zero. +- [x] TypeScript 5.8.3 remains exact-pinned with lock integrity. +- [x] GitHub Actions remain pinned to full commit SHAs. +- [x] Hosted `npm ci --ignore-scripts` and `npm audit --audit-level=high` passed with zero vulnerabilities. +- [x] Clean `dist/` build produced 14 release files, checksums, license, and SPDX SBOM. +- [x] Artifact `blindcrypt-692edf143bc4744637bdabe5bec760423688a4c7` retained with digest `sha256:d9d0c8a5c23d1cd24953460ccbd80eefa36275b9a629a171c8ef9eea37b7252b`. + +## Configuration and data + +- [x] No environment variables, secrets, feature flags, backend services, or database migrations are required. +- [x] Encryption format, default security level, CSP, limits, and user behavior are unchanged. +- [x] Database migration review: not applicable. +- [x] Rollback retains the format v3 reader and previously validated artifact. + +## Compatibility and documentation + +- [x] Reader support remains v1, v2, and v3. +- [x] Producer output remains v3. +- [x] Change is declared a non-breaking security bug fix. +- [x] README, security policy, validation record, release notes, changelog, SBOM, checklist, and commit notes are updated. + +## Production controls + +- [ ] Apply the repository rules in `docs/REPOSITORY_SETTINGS.md`. +- [ ] Change Pages deployment source from legacy branch deployment to GitHub Actions. +- [ ] Preserve the previous production artifact and checksum. +- [ ] Merge through protected review, deploy, verify, tag, and publish release artifacts. diff --git a/docs/REPOSITORY_SETTINGS.md b/docs/REPOSITORY_SETTINGS.md new file mode 100644 index 0000000..0a147f0 --- /dev/null +++ b/docs/REPOSITORY_SETTINGS.md @@ -0,0 +1,53 @@ +# Required repository settings + +These controls cannot be enforced by files in a branch alone. Apply them before promoting `01.01.00` to production. + +## Main branch ruleset + +Target: `main` + +- require a pull request before merge +- require at least one approving review +- dismiss stale approvals when new commits are pushed +- require review of the latest push +- require conversation resolution +- require signed commits when all maintainers can comply +- require status checks from **Security validation** and **CodeQL** +- require branches to be up to date before merge +- block force pushes +- block branch deletion +- restrict bypass permissions to emergency maintainers + +## Development branch + +Target: `dev` + +- block force pushes and deletion +- require **Security validation** and **CodeQL** before merging elsewhere +- allow direct maintainer pushes only when necessary for development + +## GitHub Pages + +- set deployment source to **GitHub Actions** +- keep HTTPS enforcement enabled +- use the `github-pages` environment +- restrict production deployment to `main` +- require environment approval when operationally appropriate + +## Security features + +Enable where available: + +- private vulnerability reporting +- dependency graph +- Dependabot alerts +- Dependabot security updates +- Code scanning default setup or the committed CodeQL workflow +- secret scanning and push protection + +## Merge and release policy + +- prefer squash merge for a single auditable release commit +- delete merged development branches only after the release tag and rollback artifact are preserved +- create tag `vXX.XX.XX` only on the validated `main` commit whose `VERSION` matches +- retain the previous release artifact and checksum for rollback diff --git a/docs/ROLLBACK.md b/docs/ROLLBACK.md new file mode 100644 index 0000000..6bc1537 --- /dev/null +++ b/docs/ROLLBACK.md @@ -0,0 +1,37 @@ +# Rollback plan + +## Before production release + +The `dev` branch can be reset or deleted without affecting deployed Pages because production remains on `main`. Preserve the failed commit SHA for investigation. + +## After format v3 is released + +Do not restore the unversioned baseline as the only production reader. It cannot open v3 files. + +Preferred rollback sequence: + +1. Stop further deployment from the faulty commit. +2. Restore the most recent validated static artifact that still contains the v3 reader. +3. Revert interface, styling, workflow, or non-format changes independently. +4. Keep v1, v2, and v3 decryption support available. +5. If the v3 writer itself is defective, disable new encryption while retaining decryption and publish a security notice. +6. Issue a corrected `xx.xx.xx` version and tag after full validation. + +## Git operations + +- identify the deployed tag and commit SHA +- create a rollback branch from the last validated compatible tag +- revert the faulty commit without force-pushing protected branches +- run the complete validation suite +- merge through the protected process +- redeploy the validated artifact + +## Verification + +After rollback, confirm: + +- the visible application version and artifact checksum match the intended rollback version +- v1, v2, and representative v3 fixtures decrypt +- new encryption is either verified or intentionally disabled +- CSP, no-network behavior, file limits, and neutral legacy output remain intact +- Pages reports the expected deployment commit diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..d8144ac --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,60 @@ +# Threat model + +## Assets + +- plaintext file contents +- passphrases +- authenticated filename and declared media type +- integrity and completeness of format v3 containers +- build and deployment provenance + +## Trust assumptions + +BlindCrypt assumes the browser, operating system, device, WebCrypto implementation, loaded application files, and hosting origin are trustworthy during use. The user must transfer the passphrase through a channel separate from the encrypted file. + +## Adversaries considered + +- a storage or transport provider that can read, replace, truncate, append, or reorder encrypted bytes +- an attacker who can supply a malformed local file intended to consume CPU or memory +- an attacker who changes public metadata or record geometry +- an attacker who guesses passphrases offline +- a contributor or dependency change that introduces unsafe browser APIs, dynamic code, external executable resources, or vulnerable build tooling + +## Security controls + +- AES-256-GCM record authentication with header and record context as additional authenticated data +- PBKDF2-HMAC-SHA-256 with bounded configurable work factors and random salts +- unique per-record IV construction under each derived key +- encrypted fixed-size metadata +- canonical parsing, exact field sets, safe integers, exact lengths, and hard resource ceilings before key derivation +- neutral legacy downloads and explicit legacy warnings +- no runtime dependencies, storage, telemetry, logs, or network APIs +- strict CSP and no-referrer policy +- locked development dependency, dependency audit, custom SAST, CodeQL, tests, type checking, and pinned Actions + +## Out of scope + +- malware, keyloggers, hostile extensions, compromised browsers, screen capture, clipboard monitoring, memory inspection, and physical access +- compromise of the GitHub account, repository settings, Actions platform, Pages origin, DNS, or certificate chain +- traffic analysis based on encrypted-file size or timing +- recovery of lost passphrases +- denial of service below configured local resource limits +- cryptographic guarantees for unauthenticated metadata or whole-file completeness in legacy v1 and v2 files + +## Abuse and failure cases + +### Offline guessing + +An attacker holding a container can attempt passphrase guesses. Generated phrases are preferred. The minimum generator output is six uniformly selected words; custom passphrases are not assigned entropy estimates. + +### Malformed input + +The reader rejects oversized files, excessive KDF parameters, invalid encodings, noncanonical v3 headers, inconsistent record geometry, incorrect total length, and failed GCM tags. Validation occurs before expensive work whenever the format permits. + +### Hosting compromise + +A modified page can capture plaintext and passphrases before encryption. Users handling high-value data should verify a trusted release artifact and its checksum, then serve it from a controlled local origin. + +### Rollback + +After v3 files have been created, rolling back to the unversioned baseline would make those files unreadable. Rollbacks must retain the v3 reader even when reverting interface or deployment changes. diff --git a/docs/VALIDATION_01.01.00.md b/docs/VALIDATION_01.01.00.md new file mode 100644 index 0000000..bec7bb0 --- /dev/null +++ b/docs/VALIDATION_01.01.00.md @@ -0,0 +1,85 @@ +# Validation record for 01.01.00 + +Date: 2026-08-13 + +Baseline: `4ed8c157c6015340b363848c12527d9499fb8d69` + +Reference: `SEC-AUDIT-2026-08-13` + +## Local environment + +- Node.js 22.16.0 +- npm 10.9.2 +- TypeScript 5.8.3 +- Linux validation container + +## Full validation result + +Command: `npm run validate` + +Result: passed. + +- syntax and repository-policy lint: passed across 18 JavaScript files +- strict JavaScript type checking: passed +- unit, integration, and regression tests: 25 passed, 0 failed +- local static security analysis: passed +- workflow and configuration validation: passed +- clean static build: passed +- built-artifact HTTP smoke test: passed +- 1 MiB authenticated round-trip performance smoke test: passed + +## Security regression coverage + +The suite verifies: + +- empty, one-byte, boundary, and multi-record v3 round trips +- NFC-equivalent v3 passphrases +- exact legacy passphrase behavior +- wrong-passphrase rejection +- public-header tamper rejection +- encrypted-metadata tamper rejection +- ciphertext-record tamper rejection +- truncation and trailing-data rejection +- excessive KDF rejection before derivation +- noncanonical header rejection +- bounded v1 and v2 compatibility +- legacy v2 trailing-data rejection +- filename and media-type normalization +- six-word minimum generation +- rejection of four-word and repetitive custom passphrases +- duplicate word-list rejection + +## Performance evidence + +Input: 1,048,576 bytes + +Encrypted container: 1,049,908 bytes + +- encryption: 471.5 ms +- decryption: 453.3 ms + +These measurements describe the local host only. CI enforces a 30-second ceiling for each operation and does not treat the local values as a browser or mobile guarantee. + +## Dependencies + +Runtime dependencies: none. + +Development dependency: TypeScript 5.8.3, exact-pinned in the lock file. Installation scripts are disabled. The GitHub validation workflow performs a fresh `npm ci --ignore-scripts` and `npm audit --audit-level=high` before rerunning the complete suite. + +## Build outputs + +The clean `dist/` artifact contains the application, version, license, SPDX SBOM, `.nojekyll`, and `SHA256SUMS`. The workflow retains the artifact by commit SHA. + +## Hosted validation + +Code commit: `01116bc7b7613980ef14a19ad082e3ecab6edca5` + +- **Security validation** run `31763503108`: passed. +- Fresh `npm ci --ignore-scripts`: passed. +- `npm audit --audit-level=high`: passed with zero vulnerabilities. +- Complete `npm run validate`: passed. +- Validated artifact: `blindcrypt-01116bc7b7613980ef14a19ad082e3ecab6edca5`. +- Artifact digest: `sha256:dbd4c61f31fc539e9c62c9552df7e4b7d612837f90e0074f7ba4a4e6c9d636c3`. +- **CodeQL** run `31763503112`: passed with the extended security query suite. + +Promotion remains blocked on the repository settings, protected review, production Pages deployment, and release-tag steps documented in the release checklist. diff --git a/docs/VALIDATION_01.01.01.md b/docs/VALIDATION_01.01.01.md new file mode 100644 index 0000000..077b7b8 --- /dev/null +++ b/docs/VALIDATION_01.01.01.md @@ -0,0 +1,91 @@ +# Validation record for 01.01.01 + +Date: 2026-08-13 + +Baseline: `b57c01dd515011273832064f0645842655196be7` + +References: `GHAS-PR-1`, CodeQL alert 1, PR #1. + +Validated head commit: `a0ca628f36b609856fd14292088007f26ebc9e97` + +Pull-request merge commit used by GitHub Actions: `692edf143bc4744637bdabe5bec760423688a4c7` + +## Change validated + +The build-artifact smoke server now uses a fixed route allowlist. It no longer performs a filesystem metadata check followed by a separate path-based read, and request paths never become filesystem paths. The server accepts only `GET`, rejects unlisted and traversal-shaped paths, and opens each fixed target once. + +The public-header tamper regression also chooses a writer value guaranteed to differ from the current release, preventing future version increments from turning the mutation into a no-op. + +## Hosted validation evidence + +Security validation run `31767647395`: passed. + +CodeQL run `31767647419`: passed with the extended security query suite. + +The GitHub Advanced Security review thread for CodeQL alert 1 was automatically resolved after analysis of the corrected code. + +### Environment and dependencies + +- Node.js 22.16.0 +- npm 10.9.2 +- fresh `npm ci --ignore-scripts`: passed +- installed packages: 2 including the root project +- `npm audit --audit-level=high`: passed with zero vulnerabilities +- runtime dependencies: zero +- development dependency: TypeScript 5.8.3, exact-pinned with lock integrity + +### Full validation suite + +Command: `npm run validate` + +Result: passed. + +- syntax and repository-policy lint: passed across 19 JavaScript files +- strict JavaScript type checking: passed +- unit, integration, compatibility, security, and regression tests: 27 passed, 0 failed +- local static security analysis: passed +- workflow and configuration validation: passed +- clean static build: passed, producing 14 release files +- allowlisted built-artifact HTTP smoke test: passed +- path traversal, unlisted route, and non-GET rejection checks: passed +- 1 MiB authenticated round-trip performance check: passed + +### Performance evidence + +Input: 1,048,576 bytes + +Encrypted container: 1,049,908 bytes + +- encryption: 154.2 ms +- decryption: 148.4 ms + +These measurements describe the hosted Linux runner only. The workflow enforces a 30-second ceiling for each operation and does not treat these values as a browser or mobile guarantee. + +### Build artifact + +Artifact: `blindcrypt-692edf143bc4744637bdabe5bec760423688a4c7` + +Artifact ID: `9206902305` + +Artifact size: 29,221 bytes + +Artifact digest: `sha256:d9d0c8a5c23d1cd24953460ccbd80eefa36275b9a629a171c8ef9eea37b7252b` + +The artifact contains the application, version, license, SPDX SBOM, `.nojekyll`, and `SHA256SUMS` as produced by the clean build. + +## Security review + +The affected HTTP server is a short-lived validation utility bound to `127.0.0.1`; it is not included in the browser artifact. The fixed route map prevents request input from controlling filesystem resolution. No authentication, authorization, backend, database, secrets, environment variables, feature flags, telemetry, or application logging behavior changed. + +## Compatibility and migrations + +- writer format remains v3 +- reader support remains v1, v2, and v3 +- cryptographic framing, KDF settings, IV construction, authentication inputs, and browser file limits are unchanged +- API and user workflow compatibility is unchanged +- database migration review: not applicable +- rollback retains the format v3 reader and the previous validated artifact + +## Release gate + +Code and hosted security validation are complete. Promotion remains blocked until repository protection is applied, Pages uses the committed GitHub Actions deployment workflow, the pull request is merged through review, the production deployment is verified, and tag `v01.01.01` is created from the validated production commit. diff --git a/index.html b/index.html index 374e520..00c4a20 100644 --- a/index.html +++ b/index.html @@ -1,170 +1,188 @@ - - + + + + + BlindCrypt - - + -
+
-
- BlindCrypt - - Client-side crypto for file handoff - - Repository + BlindCrypt 01.01.01 + + Client-side authenticated file encryption + + Repository
- + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d56a2c5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "blindcrypt", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "blindcrypt", + "private": true, + "devDependencies": { + "typescript": "5.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..064f9a9 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "blindcrypt", + "private": true, + "type": "module", + "description": "Dependency-free browser application for authenticated client-side file encryption.", + "engines": { + "node": ">=22" + }, + "scripts": { + "lint": "node scripts/lint.mjs", + "typecheck": "tsc --project tsconfig.json", + "test": "node --test tests/*.test.mjs", + "security": "node scripts/sast.mjs", + "config": "node scripts/config-check.mjs", + "build": "node scripts/build.mjs", + "smoke": "node scripts/smoke.mjs", + "perf": "node scripts/perf.mjs", + "validate": "npm run lint && npm run typecheck && npm test && npm run security && npm run config && npm run build && npm run smoke && npm run perf" + }, + "devDependencies": { + "typescript": "5.8.3" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..c8786f2 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const dist = resolve(root, "dist"); +const files = [ + "index.html", + "VERSION", + "LICENSE", + "SBOM.spdx.json", + "assets/app.css", + "assets/app.js", + "assets/crypto-core.js", + "assets/crypto-v3.js", + "assets/crypto-legacy.js", + "assets/crypto.js", + "assets/passphrase.js", + "assets/wordlist.js", +]; + +await rm(dist, { recursive: true, force: true }); +for (const file of files) { + const source = resolve(root, file); + const destination = resolve(dist, file); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination); +} +await writeFile(resolve(dist, ".nojekyll"), "", "utf8"); + +const manifest = []; +for (const file of [...files, ".nojekyll"].sort()) { + const bytes = await readFile(resolve(dist, file)); + const digest = createHash("sha256").update(bytes).digest("hex"); + manifest.push(`${digest} ${file}`); +} +await writeFile(resolve(dist, "SHA256SUMS"), `${manifest.join("\n")}\n`, "utf8"); +console.log(`Built ${files.length + 2} release files in dist/.`); diff --git a/scripts/config-check.mjs b/scripts/config-check.mjs new file mode 100644 index 0000000..726435f --- /dev/null +++ b/scripts/config-check.mjs @@ -0,0 +1,62 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const failures = []; +const version = (await readFile(resolve(root, "VERSION"), "utf8")).trim(); +const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); + +for (const script of ["lint", "typecheck", "test", "security", "config", "build", "smoke", "perf", "validate"]) { + if (!packageJson.scripts?.[script]) failures.push(`package.json is missing ${script} script`); +} +if (packageJson.engines?.node !== ">=22") failures.push("Node engine must be >=22"); +if (!/^\d{2}\.\d{2}\.\d{2}$/u.test(version)) failures.push("Invalid release version"); + +const ci = await readFile(resolve(root, ".github/workflows/ci.yml"), "utf8"); +for (const required of ["push:", "dev", "pull_request:", "permissions:", "contents: read", "npm audit --audit-level=high", "npm run validate"]) { + if (!ci.includes(required)) failures.push(`CI workflow is missing ${required}`); +} +const codeql = await readFile(resolve(root, ".github/workflows/codeql.yml"), "utf8"); +if (!/github\/codeql-action\/(?:init|analyze)@[a-f0-9]{40}/u.test(codeql)) { + failures.push("CodeQL actions must be pinned to commit SHAs"); +} +const pages = await readFile(resolve(root, ".github/workflows/pages.yml"), "utf8"); +for (const required of ["branches: [main]", "pages: write", "id-token: write", "environment:", "github-pages"]) { + if (!pages.includes(required)) failures.push(`Pages workflow is missing ${required}`); +} + +for (const [name, workflow] of [["CI", ci], ["CodeQL", codeql], ["Pages", pages]]) { + for (const match of workflow.matchAll(/uses:\s+([^@\s]+)@([^\s#]+)/gu)) { + if (!/^[a-f0-9]{40}$/u.test(match[2])) { + failures.push(`${name} workflow action ${match[1]} is not pinned to a full commit SHA`); + } + } +} + +const dependabot = await readFile(resolve(root, ".github/dependabot.yml"), "utf8"); +for (const required of ["package-ecosystem: npm", "package-ecosystem: github-actions", "timezone: America/Denver"]) { + if (!dependabot.includes(required)) failures.push(`Dependabot configuration is missing ${required}`); +} +const codeowners = await readFile(resolve(root, ".github/CODEOWNERS"), "utf8"); +if (!codeowners.includes("/assets/crypto*.js @paulkakell")) failures.push("Cryptographic code owner is missing"); + +const npmrc = await readFile(resolve(root, ".npmrc"), "utf8"); +for (const required of ["audit=true", "ignore-scripts=true", "save-exact=true", "engine-strict=true"]) { + if (!npmrc.includes(required)) failures.push(`.npmrc is missing ${required}`); +} + +const sbom = JSON.parse(await readFile(resolve(root, "SBOM.spdx.json"), "utf8")); +if (sbom.spdxVersion !== "SPDX-2.3") failures.push("SBOM must use SPDX 2.3"); +if (!Array.isArray(sbom.packages) || !sbom.packages.some((entry) => entry.name === "BlindCrypt" && entry.versionInfo === version)) { + failures.push("SBOM does not describe the current BlindCrypt version"); +} + +const index = await readFile(resolve(root, "index.html"), "utf8"); +if (!index.includes("value=\"strong\" selected")) failures.push("Strong must remain the default security level"); +if (!index.includes("64 MiB")) failures.push("File-size safety limit is not documented in the interface"); + +if (failures.length) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exit(1); +} +console.log("Configuration validation passed."); diff --git a/scripts/lint.mjs b/scripts/lint.mjs new file mode 100644 index 0000000..1ea320b --- /dev/null +++ b/scripts/lint.mjs @@ -0,0 +1,98 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { resolve, relative } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const failures = []; + +async function walk(directory) { + const entries = await readdir(directory); + const files = []; + for (const entry of entries) { + if (["node_modules", "dist", ".git"].includes(entry)) continue; + const path = resolve(directory, entry); + const info = await stat(path); + if (info.isDirectory()) files.push(...await walk(path)); + else files.push(path); + } + return files; +} + +function fail(message) { + failures.push(message); +} + +const files = await walk(root); +const scripts = files.filter((file) => /\.(?:js|mjs)$/u.test(file)); +for (const file of scripts) { + const result = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" }); + if (result.status !== 0) fail(`${relative(root, file)}: ${result.stderr.trim()}`); +} + +const productionScripts = scripts.filter((file) => { + const path = relative(root, file); + // The bundled word list is validated below as static data. Words such as + // "fetch" are not executable API references and must not trigger sink scans. + return path.startsWith("assets/") && path !== "assets/wordlist.js"; +}); +const forbiddenPatterns = [ + [/(?:innerHTML|outerHTML|insertAdjacentHTML|document\.write)\b/u, "unsafe DOM HTML sink"], + [/\beval\s*\(|\bnew\s+Function\b/u, "dynamic code execution"], + [/\b(?:localStorage|sessionStorage)\b/u, "persistent browser storage"], + [/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\b/u, "network API"], + [/\bconsole\./u, "console logging"], +]; +for (const file of productionScripts) { + const content = await readFile(file, "utf8"); + for (const [pattern, label] of forbiddenPatterns) { + if (pattern.test(content)) fail(`${relative(root, file)} contains ${label}`); + } +} + +const html = await readFile(resolve(root, "index.html"), "utf8"); +if (/\sstyle\s*=/iu.test(html)) fail("index.html contains an inline style attribute"); +if (/\son[a-z]+\s*=/iu.test(html)) fail("index.html contains an inline event handler"); +if (/unsafe-inline|unsafe-eval/iu.test(html)) fail("index.html weakens CSP with an unsafe source"); +const requiredCsp = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "connect-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", +]; +for (const directive of requiredCsp) { + if (!html.includes(directive)) fail(`index.html CSP is missing: ${directive}`); +} +if (!//iu.test(html)) { + fail("index.html is missing the no-referrer policy"); +} +for (const match of html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/giu)) { + if (/^(?:https?:)?\/\//iu.test(match[1])) fail(`remote executable resource: ${match[1]}`); +} + +const version = (await readFile(resolve(root, "VERSION"), "utf8")).trim(); +if (!/^\d{2}\.\d{2}\.\d{2}$/u.test(version)) fail("VERSION does not use xx.xx.xx"); +const cryptoSource = await readFile(resolve(root, "assets/crypto-core.js"), "utf8"); +if (!cryptoSource.includes(`APP_VERSION = "${version}"`)) fail("VERSION and APP_VERSION differ"); +if (!html.includes(`data-app-version>${version}<`)) fail("index.html fallback version differs"); + +const placeholderPath = resolve(root, "assets/wordlist_2048.js"); +if (files.includes(placeholderPath)) fail("unused placeholder assets/wordlist_2048.js is present"); +const wordlistSource = await readFile(resolve(root, "assets/wordlist.js"), "utf8"); +const wordMatch = wordlistSource.match(/const\s+WORDS_TEXT\s*=\s*`([\s\S]*?)`;/u); +if (!wordMatch) { + fail("assets/wordlist.js does not expose WORDS_TEXT"); +} else { + const words = wordMatch[1].trim().split(/\s+/u); + if (words.length !== 2048) fail(`word list contains ${words.length} words instead of 2048`); + if (new Set(words).size !== words.length) fail("word list contains duplicate words"); + if (!words.every((word) => /^[a-z]+$/u.test(word))) fail("word list contains a non-lowercase word"); +} + +if (failures.length) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exit(1); +} +console.log(`Lint passed for ${scripts.length} JavaScript files.`); diff --git a/scripts/perf.mjs b/scripts/perf.mjs new file mode 100644 index 0000000..7db5953 --- /dev/null +++ b/scripts/perf.mjs @@ -0,0 +1,33 @@ +import { performance } from "node:perf_hooks"; +import { decryptBlobAny, encryptBlobV3 } from "../assets/crypto.js"; + +const size = 1024 * 1024; +const plain = new Uint8Array(size); +for (let index = 0; index < plain.length; index += 1) plain[index] = (index * 31) % 251; +const passphrase = "abandon ability able about above absent"; + +const encryptStart = performance.now(); +const encrypted = await encryptBlobV3(new Blob([plain]), passphrase, { + name: "performance.bin", + type: "application/octet-stream", + levelKey: "standard", +}); +const encryptMs = performance.now() - encryptStart; + +const decryptStart = performance.now(); +const decrypted = await decryptBlobAny(encrypted.blob, passphrase); +const decryptMs = performance.now() - decryptStart; +const result = new Uint8Array(await decrypted.blob.arrayBuffer()); + +if (result.length !== plain.length || result.some((value, index) => value !== plain[index])) { + throw new Error("Performance smoke test round trip failed"); +} +if (encryptMs > 30_000 || decryptMs > 30_000) { + throw new Error(`Performance smoke test exceeded 30 seconds: encrypt=${encryptMs.toFixed(1)}ms decrypt=${decryptMs.toFixed(1)}ms`); +} +console.log(JSON.stringify({ + bytes: size, + encryptedBytes: encrypted.blob.size, + encryptMs: Number(encryptMs.toFixed(1)), + decryptMs: Number(decryptMs.toFixed(1)), +})); diff --git a/scripts/sast.mjs b/scripts/sast.mjs new file mode 100644 index 0000000..e0ab375 --- /dev/null +++ b/scripts/sast.mjs @@ -0,0 +1,67 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const failures = []; +const assets = [ + "assets/crypto-core.js", + "assets/crypto-v3.js", + "assets/crypto-legacy.js", + "assets/crypto.js", + "assets/passphrase.js", + "assets/app.js", +]; +const content = (await Promise.all(assets.map((path) => readFile(resolve(root, path), "utf8")))).join("\n"); + +const disallowed = [ + [/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u, "private key material"], + [/\bgh[oprsu]_[A-Za-z0-9_]{20,}\b/u, "GitHub token"], + [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/u, "AWS access key"], + [/\b(?:password|passphrase)\s*[:=]\s*["'][^"']{8,}["']/iu, "hard-coded credential-like value"], + [/\bpostMessage\s*\(/u, "cross-window message channel"], + [/\bSharedArrayBuffer\b/u, "shared memory"], + [/\bMath\.random\s*\(/u, "non-cryptographic randomness"], +]; +for (const [pattern, label] of disallowed) { + if (pattern.test(content)) failures.push(`Detected ${label}`); +} + +const cryptoSource = content; +const requiredSecurityMarkers = [ + "additionalData: makeRecordAad", + "tagLength: 128", + "Container length does not match its authenticated geometry", + "KDF iteration count is outside the supported range", + "METADATA_BLOCK_SIZE = 1024", + "MAX_PLAINTEXT_SIZE = 64 * 1024 * 1024", + "Legacy v2 authenticates records separately but not its metadata or whole-file completeness", +]; +for (const marker of requiredSecurityMarkers) { + if (!cryptoSource.includes(marker)) failures.push(`Missing security control marker: ${marker}`); +} +if ((cryptoSource.match(/additionalData:/gu) || []).length < 3) { + failures.push("Format v3 does not use associated data for every record path"); +} + +const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); +if (packageJson.dependencies && Object.keys(packageJson.dependencies).length) { + failures.push("Runtime dependencies are not allowed"); +} +const allowedDevDependencies = { typescript: "5.8.3" }; +if (JSON.stringify(packageJson.devDependencies) !== JSON.stringify(allowedDevDependencies)) { + failures.push("Development dependency allowlist changed"); +} +const lock = JSON.parse(await readFile(resolve(root, "package-lock.json"), "utf8")); +if (lock.lockfileVersion !== 3 || lock.packages?.["node_modules/typescript"]?.integrity !== "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==") { + failures.push("TypeScript lock integrity changed"); +} +const lockedPackages = Object.keys(lock.packages).filter(Boolean); +if (lockedPackages.length !== 1 || lockedPackages[0] !== "node_modules/typescript") { + failures.push("Unexpected package appears in package-lock.json"); +} + +if (failures.length) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exit(1); +} +console.log("Static security analysis passed."); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..87e6054 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,81 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; + +const root = resolve(new URL("../dist", import.meta.url).pathname); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".txt": "text/plain; charset=utf-8", +}; + +// The smoke server exposes only the exact files exercised by validation. +// Request data is used solely as a map key and never becomes a filesystem path. +const routes = new Map([ + ["/", "index.html"], + ["/VERSION", "VERSION"], + ["/assets/app.js", "assets/app.js"], + ["/assets/crypto.js", "assets/crypto.js"], + ["/assets/passphrase.js", "assets/passphrase.js"], + ["/assets/wordlist.js", "assets/wordlist.js"], + ["/SHA256SUMS", "SHA256SUMS"], +]); + +const server = createServer(async (request, response) => { + try { + if (request.method !== "GET") { + response.writeHead(405, { Allow: "GET" }).end("Method not allowed"); + return; + } + + const url = new URL(request.url || "/", "http://127.0.0.1"); + const relativePath = routes.get(url.pathname); + if (!relativePath) { + response.writeHead(404).end("Not found"); + return; + } + + const filePath = resolve(root, relativePath); + const body = await readFile(filePath); + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": contentTypes[extname(filePath)] || "application/octet-stream", + "X-Content-Type-Options": "nosniff", + }); + response.end(body); + } catch { + response.writeHead(404).end("Not found"); + } +}); + +await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", resolveListen); +}); + +try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Unable to determine smoke-test port"); + const base = `http://127.0.0.1:${address.port}`; + for (const path of routes.keys()) { + const response = await fetch(`${base}${path}`, { redirect: "error" }); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + const body = await response.arrayBuffer(); + if (body.byteLength === 0) throw new Error(`${path} is empty`); + } + + for (const path of ["/..%2Fpackage.json", "/assets%2F..%2FVERSION", "/not-allowlisted.txt"]) { + const response = await fetch(`${base}${path}`, { redirect: "error" }); + if (response.status !== 404) throw new Error(`${path} returned ${response.status}`); + } + + const post = await fetch(`${base}/`, { method: "POST", redirect: "error" }); + if (post.status !== 405) throw new Error(`POST request returned ${post.status}`); + + console.log("Built artifact HTTP smoke test passed."); +} finally { + await new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + }); +} diff --git a/tests/crypto.unit.test.mjs b/tests/crypto.unit.test.mjs new file mode 100644 index 0000000..731d1ad --- /dev/null +++ b/tests/crypto.unit.test.mjs @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + BlindCryptError, + CHUNK_SIZE, + decryptBlobAny, + encryptBlobV3, + sanitizeFilename, + sanitizeMimeType, +} from "../assets/crypto.js"; +import { assertBytesEqual, blobBytes } from "./helpers.mjs"; + +const passphrase = "abandon ability able about above absent"; + +for (const size of [0, 1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1]) { + test(`v3 round trip for ${size} bytes`, async () => { + const plain = new Uint8Array(size); + for (let index = 0; index < plain.length; index += 1) plain[index] = index % 251; + const encrypted = await encryptBlobV3(new Blob([plain]), passphrase, { + name: "sample.txt", + type: "text/plain", + levelKey: "standard", + }); + const decrypted = await decryptBlobAny(encrypted.blob, passphrase); + assert.equal(decrypted.formatVersion, 3); + assert.equal(decrypted.authenticatedMetadata, true); + assert.equal(decrypted.metadata.name, "sample.txt"); + assert.equal(decrypted.metadata.type, "text/plain"); + assert.equal(decrypted.legacyWarning, null); + assertBytesEqual(assert, await blobBytes(decrypted.blob), plain); + }); +} + +test("v3 normalizes passphrases with NFC", async () => { + const composed = "caf\u00e9 passphrase with enough length"; + const decomposed = "cafe\u0301 passphrase with enough length"; + const encrypted = await encryptBlobV3(new Blob(["normalized"]), decomposed, { + name: "unicode.txt", + type: "text/plain", + levelKey: "standard", + }); + const decrypted = await decryptBlobAny(encrypted.blob, composed); + assert.equal(await decrypted.blob.text(), "normalized"); +}); + +test("wrong v3 passphrase fails authentication", async () => { + const encrypted = await encryptBlobV3(new Blob(["secret"]), passphrase, { + name: "secret.txt", + type: "text/plain", + levelKey: "standard", + }); + await assert.rejects( + decryptBlobAny(encrypted.blob, "wrong passphrase that is long enough"), + (error) => error instanceof BlindCryptError && error.code === "AUTHENTICATION_FAILED", + ); +}); + +test("filename and MIME sanitization remove dangerous values", () => { + assert.equal(sanitizeFilename("../CON\u202e.txt"), "_CON.txt"); + assert.equal(sanitizeFilename(" report / final?.pdf "), "report _ final_.pdf"); + assert.equal(sanitizeMimeType("Text/Plain"), "text/plain"); + assert.equal(sanitizeMimeType("text/html; charset=utf-8"), "application/octet-stream"); +}); diff --git a/tests/format.regression.test.mjs b/tests/format.regression.test.mjs new file mode 100644 index 0000000..082f81d --- /dev/null +++ b/tests/format.regression.test.mjs @@ -0,0 +1,99 @@ +import test, { before } from "node:test"; +import assert from "node:assert/strict"; +import { + BlindCryptError, + CHUNK_SIZE, + decryptBlobAny, + encryptBlobV3, + readU32be, +} from "../assets/crypto.js"; +import { blobBytes, mutateV3Header } from "./helpers.mjs"; + +const passphrase = "abandon ability able about above absent"; +let encrypted; +let encryptedBytes; + +before(async () => { + const plain = new Uint8Array(CHUNK_SIZE + 37); + for (let index = 0; index < plain.length; index += 1) plain[index] = (index * 17) % 251; + encrypted = await encryptBlobV3(new Blob([plain]), passphrase, { + name: "integrity.bin", + type: "application/octet-stream", + levelKey: "standard", + }); + encryptedBytes = await blobBytes(encrypted.blob); +}); + +test("v3 rejects authenticated public-header tampering", async () => { + const tampered = await mutateV3Header(encrypted.blob, (header) => { + header.writer = header.writer === "99.99.99" ? "98.98.98" : "99.99.99"; + }); + await assert.rejects( + decryptBlobAny(tampered, passphrase), + (error) => error instanceof BlindCryptError && error.code === "AUTHENTICATION_FAILED", + ); +}); + +test("v3 rejects encrypted metadata tampering", async () => { + const bytes = encryptedBytes.slice(); + const headerLength = readU32be(bytes, 4); + bytes[8 + headerLength + 10] ^= 0x80; + await assert.rejects( + decryptBlobAny(new Blob([bytes]), passphrase), + (error) => error instanceof BlindCryptError && error.code === "AUTHENTICATION_FAILED", + ); +}); + +test("v3 rejects ciphertext record tampering", async () => { + const bytes = encryptedBytes.slice(); + const headerLength = readU32be(bytes, 4); + const firstDataOffset = 8 + headerLength + 1024 + 16; + bytes[firstDataOffset + 20] ^= 0x01; + await assert.rejects( + decryptBlobAny(new Blob([bytes]), passphrase), + (error) => error instanceof BlindCryptError && error.code === "AUTHENTICATION_FAILED", + ); +}); + +test("v3 rejects trailing data", async () => { + const withTrailing = new Blob([encrypted.blob, Uint8Array.of(1, 2, 3, 4)]); + await assert.rejects( + decryptBlobAny(withTrailing, passphrase), + (error) => error instanceof BlindCryptError && error.code === "INVALID_FORMAT", + ); +}); + +test("v3 rejects truncation", async () => { + const truncated = encrypted.blob.slice(0, encrypted.blob.size - 16); + await assert.rejects( + decryptBlobAny(truncated, passphrase), + (error) => error instanceof BlindCryptError && error.code === "INVALID_FORMAT", + ); +}); + +test("v3 rejects excessive KDF settings before key derivation", async () => { + const tampered = await mutateV3Header(encrypted.blob, (header) => { + header.iter = 2_400_001; + }); + await assert.rejects( + decryptBlobAny(tampered, passphrase), + (error) => error instanceof BlindCryptError && error.code === "INVALID_KDF", + ); +}); + +test("v3 rejects noncanonical header JSON", async () => { + const bytes = encryptedBytes; + const headerLength = readU32be(bytes, 4); + const headerText = new TextDecoder().decode(bytes.subarray(8, 8 + headerLength)); + const spaced = new TextEncoder().encode(` ${headerText}`); + const changed = new Blob([ + bytes.subarray(0, 4), + new Uint8Array([0, 0, spaced.length >>> 8, spaced.length & 0xff]), + spaced, + bytes.subarray(8 + headerLength), + ]); + await assert.rejects( + decryptBlobAny(changed, passphrase), + (error) => error instanceof BlindCryptError && error.code === "INVALID_FORMAT", + ); +}); diff --git a/tests/helpers.mjs b/tests/helpers.mjs new file mode 100644 index 0000000..bf0830e --- /dev/null +++ b/tests/helpers.mjs @@ -0,0 +1,146 @@ +import { base64urlEncode, readU32be, u32be } from "../assets/crypto.js"; + +const encoder = new TextEncoder(); + +export function makeWordList() { + const words = []; + for (let index = 0; index < 2048; index += 1) { + let value = index; + let suffix = ""; + for (let place = 0; place < 4; place += 1) { + suffix = String.fromCharCode(97 + (value % 26)) + suffix; + value = Math.floor(value / 26); + } + words.push(`w${suffix}`); + } + return words; +} + +export async function blobBytes(blob) { + return new Uint8Array(await blob.arrayBuffer()); +} + +export function assertBytesEqual(assert, actual, expected) { + assert.equal(actual.length, expected.length); + for (let index = 0; index < actual.length; index += 1) { + assert.equal(actual[index], expected[index], `byte ${index}`); + } +} + +async function deriveLegacyKey(passphrase, salt, iterations) { + const base = await crypto.subtle.importKey( + "raw", + encoder.encode(passphrase), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function concat(...parts) { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +export async function createLegacyV1(plain, passphrase, metadata = {}) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const iterations = 10_000; + const key = await deriveLegacyKey(passphrase, salt, iterations); + const cipher = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv, tagLength: 128 }, key, plain), + ); + const header = { + v: 1, + kdf: "PBKDF2", + hash: "SHA-256", + iter: iterations, + alg: "AES-256-GCM", + salt: base64urlEncode(salt), + iv: base64urlEncode(iv), + name: metadata.name || "legacy-v1.txt", + type: metadata.type || "text/plain", + }; + const headerBytes = encoder.encode(JSON.stringify(header)); + return new Blob([u32be(headerBytes.length), headerBytes, cipher]); +} + +export async function createLegacyV2(plain, passphrase, metadata = {}) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const ivBase = crypto.getRandomValues(new Uint8Array(12)); + const iterations = 10_000; + const chunkSize = 512 * 1024; + const chunks = Math.max(1, Math.ceil(plain.length / chunkSize)); + const last = plain.length - (chunks - 1) * chunkSize; + const key = await deriveLegacyKey(passphrase, salt, iterations); + const cipherParts = []; + + for (let index = 0; index < chunks; index += 1) { + const start = index * chunkSize; + const end = Math.min(plain.length, start + chunkSize); + const iv = new Uint8Array(12); + iv.set(ivBase.subarray(0, 8), 0); + iv.set(u32be(index), 8); + cipherParts.push( + new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv, tagLength: 128 }, + key, + plain.subarray(start, end), + ), + ), + ); + } + + const header = { + v: 2, + mode: "chunked-aesgcm", + kdf: "PBKDF2", + hash: "SHA-256", + iter: iterations, + alg: "AES-256-GCM", + salt: base64urlEncode(salt), + iv: base64urlEncode(ivBase), + size: plain.length, + chunk: chunkSize, + chunks, + last, + name: metadata.name || "legacy-v2.txt", + type: metadata.type || "text/plain", + }; + const headerBytes = encoder.encode(JSON.stringify(header)); + return new Blob([u32be(headerBytes.length), headerBytes, ...cipherParts]); +} + +export async function mutateV3Header(blob, mutator) { + const bytes = await blobBytes(blob); + const headerLength = readU32be(bytes, 4); + const headerStart = 8; + const headerEnd = headerStart + headerLength; + const header = JSON.parse(new TextDecoder().decode(bytes.subarray(headerStart, headerEnd))); + mutator(header); + const headerBytes = encoder.encode(JSON.stringify(header)); + return new Blob([ + bytes.subarray(0, 4), + u32be(headerBytes.length), + headerBytes, + bytes.subarray(headerEnd), + ]); +} + +export function concatBytes(...parts) { + return concat(...parts); +} diff --git a/tests/legacy.integration.test.mjs b/tests/legacy.integration.test.mjs new file mode 100644 index 0000000..0d6d2f2 --- /dev/null +++ b/tests/legacy.integration.test.mjs @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { BlindCryptError, decryptBlobAny } from "../assets/crypto.js"; +import { + assertBytesEqual, + blobBytes, + createLegacyV1, + createLegacyV2, +} from "./helpers.mjs"; + +const passphrase = "legacy exact passphrase"; + +for (const version of [1, 2]) { + test(`legacy v${version} remains readable with neutral output handling`, async () => { + const plain = new TextEncoder().encode(`legacy format ${version}`); + const container = version === 1 + ? await createLegacyV1(plain, passphrase, { name: "../../report.html", type: "text/html" }) + : await createLegacyV2(plain, passphrase, { name: "../../report.html", type: "text/html" }); + const result = await decryptBlobAny(container, passphrase); + assert.equal(result.formatVersion, version); + assert.equal(result.authenticatedMetadata, false); + assert.match(result.legacyWarning, /Legacy/u); + assert.equal(result.blob.type, "application/octet-stream"); + assert.equal(result.metadata.name, "_report.html"); + assertBytesEqual(assert, await blobBytes(result.blob), plain); + }); +} + +test("legacy v2 rejects trailing data", async () => { + const plain = new TextEncoder().encode("legacy trailing test"); + const container = await createLegacyV2(plain, passphrase); + await assert.rejects( + decryptBlobAny(new Blob([container, Uint8Array.of(9)]), passphrase), + (error) => error instanceof BlindCryptError && error.code === "INVALID_FORMAT", + ); +}); + +test("legacy decryption keeps exact passphrase semantics", async () => { + const decomposed = "cafe\u0301 legacy phrase"; + const composed = "caf\u00e9 legacy phrase"; + const plain = new TextEncoder().encode("legacy normalization"); + const container = await createLegacyV1(plain, decomposed); + await assert.rejects( + decryptBlobAny(container, composed), + (error) => error instanceof BlindCryptError && error.code === "AUTHENTICATION_FAILED", + ); + assert.equal(await (await decryptBlobAny(container, decomposed)).blob.text(), "legacy normalization"); +}); diff --git a/tests/passphrase.unit.test.mjs b/tests/passphrase.unit.test.mjs new file mode 100644 index 0000000..4f3c203 --- /dev/null +++ b/tests/passphrase.unit.test.mjs @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assessPassphrase, + buildWordSet, + generatePassphrase, + validateNewPassphrase, +} from "../assets/passphrase.js"; +import { makeWordList } from "./helpers.mjs"; + +const words = makeWordList(); +const wordSet = buildWordSet(words); + +test("generated passphrases contain the requested secure word count", () => { + const generated = generatePassphrase(words, 6); + const parts = generated.split(" "); + assert.equal(parts.length, 6); + assert.ok(parts.every((word) => wordSet.has(word))); + const assessment = assessPassphrase(generated, wordSet); + assert.equal(assessment.accepted, true); + assert.equal(assessment.bits, 66); + assert.equal(assessment.label, "Standard"); +}); + +test("four-word phrases are rejected", () => { + const phrase = words.slice(0, 4).join(" "); + assert.equal(assessPassphrase(phrase, wordSet).accepted, false); + assert.throws(() => validateNewPassphrase(phrase, wordSet), /At least 6/u); + assert.throws(() => generatePassphrase(words, 4), /6-16/u); +}); + +test("custom passphrases are not assigned estimated entropy", () => { + const assessment = assessPassphrase("Correct horse? Battery 47!", wordSet); + assert.equal(assessment.kind, "custom"); + assert.equal(assessment.accepted, true); + assert.equal(assessment.bits, null); + assert.equal(assessment.label, "Custom"); + assert.match(assessment.text, /Strength is not estimated/u); +}); + +test("repetitive custom passphrases are rejected", () => { + for (const repeated of ["1".repeat(32), "password".repeat(4), "ab".repeat(16)]) { + const assessment = assessPassphrase(repeated, wordSet); + assert.equal(assessment.kind, "custom"); + assert.equal(assessment.accepted, false); + assert.equal(assessment.bits, null); + assert.match(assessment.text, /too repetitive/u); + assert.throws(() => validateNewPassphrase(repeated, wordSet), /too repetitive/u); + } +}); + +test("custom passphrases reject outer whitespace and short values", () => { + assert.equal(assessPassphrase(" short passphrase ", wordSet).accepted, false); + assert.equal(assessPassphrase("short", wordSet).accepted, false); +}); + +test("word list validation rejects duplicates", () => { + const duplicate = [...words]; + duplicate[2047] = duplicate[0]; + assert.throws(() => buildWordSet(duplicate), /duplicates/u); +}); diff --git a/tests/smoke-security.test.mjs b/tests/smoke-security.test.mjs new file mode 100644 index 0000000..5430101 --- /dev/null +++ b/tests/smoke-security.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(new URL("..", import.meta.url).pathname); +const source = await readFile(resolve(root, "scripts/smoke.mjs"), "utf8"); + +test("smoke server avoids filesystem check-use races", () => { + assert.doesNotMatch(source, /\bstat\s*\(/u); + assert.doesNotMatch(source, /decodeURIComponent/u); + assert.match(source, /const routes = new Map\(/u); + assert.match(source, /routes\.get\(url\.pathname\)/u); +}); + +test("smoke server rejects paths outside its fixed route allowlist", () => { + assert.match(source, /if \(!relativePath\)/u); + assert.match(source, /\/\.\.%2Fpackage\.json/u); + assert.match(source, /\/not-allowlisted\.txt/u); + assert.match(source, /request\.method !== "GET"/u); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aa02c22 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "target": "ES2023", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "skipLibCheck": false, + "useUnknownInCatchVariables": true + }, + "include": ["assets/crypto.js", "assets/passphrase.js", "assets/app.js"] +}